use axum::{
Router,
extract::Request,
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
routing::get,
};
use hyper::upgrade::OnUpgrade;
use hyper_util::rt::TokioIo;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
include!(concat!(env!("OUT_DIR"), "/landing_page_html.rs"));
const LOGO_BYTES: &[u8] = include_bytes!("info_page/logo.webp");
fn base64_encode(bytes: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[(n >> 18) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(n & 63) as usize] as char);
}
}
while out.len() % 4 != 0 {
out.push('=');
}
out
}
static I18N_DATA: LazyLock<HashMap<String, HashMap<String, String>>> = LazyLock::new(|| {
let raw: HashMap<String, HashMap<String, String>> =
serde_json::from_str(include_str!("info_page/i18n/all.json"))
.expect("Failed to parse i18n JSON");
raw
});
const TEMPLATE: &str = include_str!("info_page/template.html");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendState {
Unknown,
Up,
NotReady,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfoStatus {
Ready,
Working,
Landing,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BinaryInfo {
pub name: String,
pub path: String,
pub compile_time: String,
pub hash: String,
#[serde(rename = "hash_short")]
pub hash_short: String,
}
fn detect_install_method() -> &'static str {
if std::env::var_os("CARGO_MANIFEST_DIR").is_some() {
static C: &str = "cargo";
return C;
}
if let Ok(exe) = std::env::current_exe() {
if let Ok(p) = exe.canonicalize() {
let s = p.to_string_lossy();
if s.contains("/node_modules/") || s.contains("\\node_modules\\") {
static N: &str = "npm";
return N;
}
}
}
if let Ok(path) = std::env::var("PATH") {
for dir in path.split(':') {
let d = dir.trim();
if d.ends_with("/nvm")
|| d.ends_with("\\nvm")
|| std::path::Path::new(d).join("versions").exists()
{
static N: &str = "nvm";
return N;
}
}
}
static X: &str = "";
X
}
#[allow(clippy::too_many_arguments)]
pub fn info_router(
version: impl Into<String>,
status: InfoStatus,
watch_paths: Vec<String>,
proxy_endpoint: Option<String>,
binaries: Vec<BinaryInfo>,
serve_backend: Option<String>,
serve_hosts: Vec<String>,
build_progress: std::sync::Arc<std::sync::Mutex<Option<String>>>,
build_log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
runtime_log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) -> Router<()> {
let backend_state = std::sync::Arc::new(tokio::sync::RwLock::new(BackendState::Unknown));
if let Some(ref backend_url) = serve_backend {
let state_arc = backend_state.clone();
let url = backend_url.clone();
let initial = probe_backend(&url);
if let Ok(mut w) = state_arc.try_write() {
*w = initial;
}
tokio::spawn(async move {
loop {
let probe_url = url.clone();
let state = tokio::task::spawn_blocking(move || probe_backend(&probe_url))
.await
.unwrap_or(BackendState::Down);
let mut w = state_arc.write().await;
*w = state;
drop(w);
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
}
let state = InfoState {
version: version.into(),
status,
watch_paths,
proxy_endpoint,
binaries,
serve_backend,
serve_hosts,
build_progress,
build_log,
runtime_log,
backend_state,
};
Router::new()
.route("/", get(info_page))
.fallback(get(info_page))
.with_state(state)
}
#[derive(Clone)]
struct InfoState {
version: String,
status: InfoStatus,
watch_paths: Vec<String>,
proxy_endpoint: Option<String>,
binaries: Vec<BinaryInfo>,
serve_backend: Option<String>,
serve_hosts: Vec<String>,
build_progress: std::sync::Arc<std::sync::Mutex<Option<String>>>,
build_log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
runtime_log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
backend_state: std::sync::Arc<tokio::sync::RwLock<BackendState>>,
}
fn probe_backend(url: &str) -> BackendState {
let host_port = url
.trim_start_matches("http://")
.trim_start_matches("https://")
.split(['/', '?'])
.next()
.unwrap_or("");
let addr: std::net::SocketAddr = match host_port.parse().ok() {
Some(a) => a,
None => return BackendState::Down,
};
let mut stream = match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
Ok(s) => s,
Err(_) => return BackendState::Down,
};
use std::io::Write;
let req = format!("GET /readyz HTTP/1.0\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
if stream.write_all(req.as_bytes()).is_err() {
return BackendState::Down;
}
match read_http_status_code(&mut stream, Duration::from_millis(1500)) {
Some(503) => BackendState::NotReady,
Some(_) => BackendState::Up,
None => BackendState::Down,
}
}
fn read_http_status_code(stream: &mut std::net::TcpStream, budget: Duration) -> Option<u16> {
use std::io::Read;
let deadline = std::time::Instant::now() + budget;
let mut line = Vec::with_capacity(64);
let mut byte = [0u8; 1];
loop {
if line.len() >= 64 {
break;
}
let now = std::time::Instant::now();
if now >= deadline {
break;
}
let _ = stream.set_read_timeout(Some(deadline - now));
match stream.read(&mut byte) {
Ok(0) => break,
Ok(_) => {
if byte[0] == b'\n' {
break;
}
line.push(byte[0]);
}
Err(_) => break,
}
}
let head = std::str::from_utf8(&line).unwrap_or("");
let after = head
.strip_prefix("HTTP/1.")
.or_else(|| head.strip_prefix("HTTP/2"))
.or_else(|| head.strip_prefix("HTTP/3"))?;
let code_part = after
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.')
.trim_start();
code_part.split_whitespace().next()?.parse().ok()
}
async fn build_spa_init(state: &InfoState, lang: &str) -> serde_json::Value {
let i18n = get_i18n(lang);
let (spa_state, message) = if state.serve_backend.is_some() {
match *state.backend_state.read().await {
BackendState::Up | BackendState::Unknown => (
"landing",
i18n.get("status_landing")
.map_or("Redirecting shortly", |v| v.as_str()),
),
BackendState::NotReady => (
"starting",
i18n.get("status_starting")
.map_or("The service is starting up", |v| v.as_str()),
),
BackendState::Down => (
"offline",
i18n.get("status_offline")
.map_or("Service temporarily unavailable", |v| v.as_str()),
),
}
} else {
match state.status {
InfoStatus::Ready => (
"ready",
i18n.get("status_ready")
.map_or("All services running.", |v| v.as_str()),
),
InfoStatus::Working => (
"building",
i18n.get("status_building")
.map_or("Building...", |v| v.as_str()),
),
InfoStatus::Landing => (
"landing",
i18n.get("status_landing")
.map_or("Redirecting shortly", |v| v.as_str()),
),
}
};
serde_json::json!({
"state": spa_state,
"message": message,
"binaries": state.binaries,
"watch_paths": state.watch_paths,
"proxy_endpoint": state.proxy_endpoint,
"version": state.version,
"logo_base64": base64_encode(LOGO_BYTES),
})
}
async fn serve_spa(state: &InfoState, lang: &str) -> Response {
let init_data = build_spa_init(state, lang).await;
let init_json = serde_json::to_string(&init_data).unwrap_or_default();
let init_script = format!("<script>window.__MALKUTH_INIT__ = {};</script>", init_json);
let result = LANDING_PAGE_HTML.replacen("</head>", &format!("{init_script}</head>"), 1);
Html(result).into_response()
}
fn should_fall_back(status: u16) -> bool {
matches!(status, 502..=504)
}
fn is_upgrade_request(headers: &header::HeaderMap) -> bool {
headers.contains_key(header::UPGRADE)
&& headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| {
v.split(',')
.any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
})
}
fn backend_host_port(backend: &str) -> Option<&str> {
let host_port = backend
.trim_start_matches("http://")
.trim_start_matches("https://")
.split(['/', '?'])
.next()?;
(!host_port.is_empty()).then_some(host_port)
}
async fn proxy_upgrade(req: Request, backend: &str) -> Result<Response, ()> {
let (mut parts, body) = req.into_parts();
let Some(on_upgrade) = parts.extensions.remove::<OnUpgrade>() else {
return proxy_to_backend_parts(parts, body, backend).await;
};
let Some(host_port) = backend_host_port(backend) else {
return Err(());
};
let path_and_query = parts.uri.path_and_query().map_or("/", |pq| pq.as_str());
let body_bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|_| ())?;
let mut stream =
tokio::time::timeout(Duration::from_millis(2000), TcpStream::connect(host_port))
.await
.map_err(|_| ())?
.map_err(|_| ())?;
let mut wire: Vec<u8> = Vec::with_capacity(512);
wire.extend_from_slice(parts.method.as_str().as_bytes());
wire.push(b' ');
wire.extend_from_slice(path_and_query.as_bytes());
wire.extend_from_slice(b" HTTP/1.1\r\nHost: ");
wire.extend_from_slice(host_port.as_bytes());
wire.extend_from_slice(b"\r\n");
for (name, value) in parts.headers.iter() {
let lower = name.as_str().to_lowercase();
if lower == "host"
|| lower == "connection"
|| lower == "transfer-encoding"
|| lower == "content-length"
{
continue;
}
if lower == "cookie" {
let Some(cookies) = value.to_str().ok() else {
continue;
};
let cleaned: Vec<&str> = cookies
.split(';')
.filter(|c| !c.trim().starts_with("__malkuth_nonce="))
.collect();
if cleaned.is_empty() {
continue;
}
wire.extend_from_slice(b"Cookie: ");
wire.extend_from_slice(cleaned.join(";").as_bytes());
wire.extend_from_slice(b"\r\n");
continue;
}
wire.extend_from_slice(name.as_str().as_bytes());
wire.extend_from_slice(b": ");
wire.extend_from_slice(value.as_bytes());
wire.extend_from_slice(b"\r\n");
}
if !body_bytes.is_empty() {
wire.extend_from_slice(b"Content-Length: ");
wire.extend_from_slice(body_bytes.len().to_string().as_bytes());
wire.extend_from_slice(b"\r\n");
}
wire.extend_from_slice(b"Connection: Upgrade\r\n\r\n");
let wire_flush = async {
stream.write_all(&wire).await?;
if !body_bytes.is_empty() {
stream.write_all(&body_bytes).await?;
}
stream.flush().await
};
tokio::time::timeout(Duration::from_secs(5), wire_flush)
.await
.map_err(|_| ())?
.map_err(|_| ())?;
let (head, leftover) =
tokio::time::timeout(Duration::from_secs(10), read_response_head(&mut stream))
.await
.map_err(|_| ())?
.map_err(|_| ())?;
let (status, resp_headers) = parse_response_head(&head).ok_or(())?;
if status == 101 {
let mut response = Response::new(axum::body::Body::empty());
*response.status_mut() = StatusCode::SWITCHING_PROTOCOLS;
for (name, value) in resp_headers {
if let (Ok(n), Ok(v)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_bytes(&value),
) {
response.headers_mut().append(n, v);
}
}
tokio::spawn(async move {
let upgraded = match on_upgrade.await {
Ok(stream) => stream,
Err(_) => return,
};
let mut client_io = TokioIo::new(upgraded);
if !leftover.is_empty() {
let mut rest = std::io::Cursor::new(leftover);
if tokio::io::copy(&mut rest, &mut client_io).await.is_err() {
return;
}
}
let _ = tokio::io::copy_bidirectional(&mut client_io, &mut stream).await;
});
return Ok(response);
}
if should_fall_back(status) {
return Err(());
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
let resp_body = read_response_body(&mut stream, &resp_headers, leftover, deadline).await?;
let mut backend_cookies: Vec<Vec<u8>> = Vec::new();
let resp_headers: Vec<(String, Vec<u8>)> = resp_headers
.into_iter()
.filter(|(name, value)| {
let lower = name.to_lowercase();
if lower == "set-cookie" {
backend_cookies.push(value.clone());
return false;
}
lower != "transfer-encoding" && lower != "connection"
})
.collect();
let status_code = StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
let mut response = Response::new(axum::body::Body::from(resp_body));
*response.status_mut() = status_code;
response.headers_mut().append(
header::SET_COOKIE,
header::HeaderValue::from_static("__malkuth_nonce=1; max-age=1800; path=/"),
);
for value in backend_cookies {
if let Ok(v) = header::HeaderValue::from_bytes(&value) {
response.headers_mut().append(header::SET_COOKIE, v);
}
}
for (name, value) in resp_headers {
if let (Ok(n), Ok(v)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_bytes(&value),
) {
response.headers_mut().insert(n, v);
}
}
Ok(response)
}
async fn read_response_head(stream: &mut TcpStream) -> Result<(Vec<u8>, Vec<u8>), ()> {
const CAP: usize = 64 * 1024;
let mut buf: Vec<u8> = Vec::with_capacity(1024);
let mut chunk = [0u8; 4096];
loop {
if buf.len() >= CAP {
return Err(());
}
let n = stream.read(&mut chunk).await.map_err(|_| ())?;
if n == 0 {
return Err(());
}
buf.extend_from_slice(&chunk[..n]);
if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) {
let leftover = buf.split_off(end);
return Ok((buf, leftover));
}
}
}
type ParsedHeader = (String, Vec<u8>);
fn parse_response_head(head: &[u8]) -> Option<(u16, Vec<ParsedHeader>)> {
let text = std::str::from_utf8(head).ok()?;
let mut lines = text.split("\r\n");
let status_line = lines.next()?;
let status = status_line.split_whitespace().nth(1)?.parse::<u16>().ok()?;
let mut headers = Vec::new();
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
headers.push((
name.trim().to_ascii_lowercase(),
value.trim().as_bytes().to_vec(),
));
}
Some((status, headers))
}
async fn read_response_body(
stream: &mut TcpStream,
headers: &[ParsedHeader],
mut body: Vec<u8>,
deadline: tokio::time::Instant,
) -> Result<Vec<u8>, ()> {
let is_chunked = headers.iter().any(|(name, value)| {
name == "transfer-encoding"
&& String::from_utf8_lossy(value)
.to_ascii_lowercase()
.contains("chunked")
});
let content_length = headers
.iter()
.find(|(name, _)| name == "content-length")
.and_then(|(_, value)| std::str::from_utf8(value).ok())
.and_then(|v| v.trim().parse::<usize>().ok());
if is_chunked {
let mut out: Vec<u8> = Vec::new();
let mut rest: Vec<u8> = std::mem::take(&mut body);
loop {
let size_line = take_line(&mut rest, stream, 128, deadline).await?;
let size_hex = std::str::from_utf8(&size_line)
.ok()
.and_then(|s| s.split(';').next())
.map(str::trim)
.unwrap_or("");
let size = usize::from_str_radix(size_hex, 16).map_err(|_| ())?;
if size == 0 {
loop {
if take_line(&mut rest, stream, 4096, deadline)
.await?
.is_empty()
{
break;
}
}
break;
}
while rest.len() < size + 2 {
let n = read_some(stream, &mut rest, deadline).await?;
if n == 0 {
return Err(());
}
}
out.extend_from_slice(&rest[..size]);
rest.drain(..size + 2); }
Ok(out)
} else if let Some(len) = content_length {
while body.len() < len {
let n = read_some(stream, &mut body, deadline).await?;
if n == 0 {
return Err(());
}
}
body.truncate(len);
Ok(body)
} else {
loop {
if read_some(stream, &mut body, deadline).await? == 0 {
return Ok(body);
}
}
}
}
async fn take_line(
rest: &mut Vec<u8>,
stream: &mut TcpStream,
cap: usize,
deadline: tokio::time::Instant,
) -> Result<Vec<u8>, ()> {
loop {
if let Some(pos) = rest.windows(2).position(|w| w == b"\r\n") {
let line: Vec<u8> = rest.drain(..pos).collect();
rest.drain(..2);
if line.len() > cap {
return Err(());
}
return Ok(line);
}
if rest.len() > cap {
return Err(());
}
if read_some(stream, rest, deadline).await? == 0 {
return Err(());
}
}
}
async fn read_some(
stream: &mut TcpStream,
buf: &mut Vec<u8>,
deadline: tokio::time::Instant,
) -> Result<usize, ()> {
let mut chunk = [0u8; 8192];
let n = tokio::time::timeout_at(deadline, stream.read(&mut chunk))
.await
.map_err(|_| ())?
.map_err(|_| ())?;
if n > 0 {
buf.extend_from_slice(&chunk[..n]);
}
Ok(n)
}
async fn proxy_to_backend(req: Request, backend: &str) -> Result<Response, ()> {
let (parts, body) = req.into_parts();
proxy_to_backend_parts(parts, body, backend).await
}
async fn proxy_to_backend_parts(
parts: axum::http::request::Parts,
body: axum::body::Body,
backend: &str,
) -> Result<Response, ()> {
let path = parts.uri.path_and_query().map_or("/", |pq| pq.as_str());
let url = format!("{}{}", backend.trim_end_matches('/'), path);
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|_| ())?;
let method = parts.method;
let headers = parts.headers;
let body_bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|_| ())?;
let mut backend_req = match method.as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
_ => client.get(&url),
};
for (name, value) in headers.iter() {
let lower = name.as_str().to_lowercase();
if lower == "cookie" {
if let Ok(cookies) = value.to_str() {
let cleaned: Vec<&str> = cookies
.split(';')
.filter(|c| !c.trim().starts_with("__malkuth_nonce="))
.collect();
if !cleaned.is_empty() {
backend_req = backend_req.header(name.as_str(), cleaned.join(";").as_bytes());
}
}
} else if lower != "host" && lower != "connection" && lower != "transfer-encoding" {
backend_req = backend_req.header(name.as_str(), value.as_bytes());
}
}
if !body_bytes.is_empty() {
backend_req = backend_req.body(body_bytes.to_vec());
}
let resp = backend_req.send().await.map_err(|_| ())?;
let status_code = resp.status().as_u16();
if should_fall_back(status_code) {
return Err(());
}
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
let mut backend_cookies: Vec<Vec<u8>> = Vec::new();
let resp_headers: Vec<(String, Vec<u8>)> = resp
.headers()
.iter()
.filter(|(n, v)| {
let lower = n.as_str().to_lowercase();
if lower == "set-cookie" {
backend_cookies.push(v.as_bytes().to_vec());
return false;
}
lower != "transfer-encoding" && lower != "connection"
})
.map(|(n, v)| (n.as_str().to_string(), v.as_bytes().to_vec()))
.collect();
let resp_body = resp.bytes().await.map_err(|_| ())?;
let mut response = Response::new(axum::body::Body::from(resp_body));
*response.status_mut() = status;
response.headers_mut().append(
header::SET_COOKIE,
header::HeaderValue::from_static("__malkuth_nonce=1; max-age=1800; path=/"),
);
for value in backend_cookies {
if let Ok(v) = header::HeaderValue::from_bytes(&value) {
response.headers_mut().append(header::SET_COOKIE, v);
}
}
for (name, value) in resp_headers {
if let (Ok(n), Ok(v)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_bytes(&value),
) {
response.headers_mut().insert(n, v);
}
}
Ok(response)
}
fn read_nonce(req: &Request) -> u8 {
let cookie_header = req.headers().get(header::COOKIE);
let Some(cookies) = cookie_header.and_then(|v| v.to_str().ok()) else {
return 0;
};
for part in cookies.split(';') {
let kv = part.trim();
if let Some(val) = kv.strip_prefix("__malkuth_nonce=") {
return val.parse::<u8>().unwrap_or(0);
}
}
0
}
async fn serve_probe(lang: &str, state: &InfoState) -> Response {
let backend_state = *state.backend_state.read().await;
let backend_up = state.serve_backend.is_some() && backend_state == BackendState::Up;
let (probe_state, msg) = if !state.serve_backend.is_some() || backend_up {
("ready", "")
} else if backend_state == BackendState::Unknown {
("landing", "")
} else if backend_state == BackendState::NotReady {
("starting", "")
} else {
("offline", "")
};
let message = if msg.is_empty() {
let i18n = get_i18n(lang);
match probe_state {
"landing" => i18n
.get("status_landing")
.map_or("Redirecting shortly", |v| v.as_str())
.to_string(),
"starting" => i18n
.get("status_starting")
.map_or("The service is starting up", |v| v.as_str())
.to_string(),
"offline" => i18n
.get("status_offline")
.map_or("Service temporarily unavailable", |v| v.as_str())
.to_string(),
"ready" => i18n
.get("status_ready")
.map_or("All services running.", |v| v.as_str())
.to_string(),
_ => msg.to_string(),
}
} else {
msg.to_string()
};
let progress = state.build_progress.lock().ok().and_then(|g| g.clone());
let runtime_log: Vec<String> = state
.runtime_log
.lock()
.ok()
.map(|g| g.clone())
.unwrap_or_default();
let build_log: Vec<String> = state
.build_log
.lock()
.ok()
.map(|g| g.clone())
.unwrap_or_default();
let log: Vec<String> = if !runtime_log.is_empty() {
runtime_log
} else {
build_log
};
let vtty_name = state
.binaries
.first()
.map(|b| b.name.as_str())
.unwrap_or("");
let json = serde_json::json!({
"state": probe_state,
"message": message,
"progress": progress,
"vttys": [{
"name": vtty_name,
"log": log,
}],
})
.to_string();
let mut resp = Response::new(axum::body::Body::from(json));
resp.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
resp
}
async fn serve_landing(lang: &str, state: &InfoState) -> Response {
let i18n = get_i18n(lang);
let backend_state = *state.backend_state.read().await;
let backend_up = state.serve_backend.is_some() && backend_state == BackendState::Up;
let init_state: &str = if !state.serve_backend.is_some() || backend_up {
"ready"
} else if backend_state == BackendState::Unknown {
"landing"
} else if backend_state == BackendState::NotReady {
"starting"
} else {
"offline"
};
let init_msg = match init_state {
"ready" => i18n
.get("status_landing")
.map_or("Redirecting shortly", |v| v.as_str()),
"offline" => i18n
.get("status_offline")
.map_or("Service temporarily unavailable", |v| v.as_str()),
_ => i18n
.get("status_starting")
.map_or("The service is starting up", |v| v.as_str()),
};
let mut ctx = tera::Context::new();
ctx.insert("lang", lang);
ctx.insert("dir", if lang == "ar" { "rtl" } else { "ltr" });
ctx.insert("title", i18n.get("title").map_or("Malkuth", |v| v.as_str()));
ctx.insert(
"heading",
i18n.get("heading").map_or("Malkuth", |v| v.as_str()),
);
ctx.insert("tagline", i18n.get("tagline").map_or("", |v| v.as_str()));
ctx.insert("ready", &backend_up);
ctx.insert(
"landing",
&(init_state == "ready" || init_state == "landing"),
);
ctx.insert(
"task",
i18n.get("task_landing").map_or("Landing", |v| v.as_str()),
);
ctx.insert("version", &state.version);
ctx.insert("status_text", init_msg);
ctx.insert("initial_state", init_state);
ctx.insert("task_label", "");
ctx.insert(
"redirect_before",
i18n.get("redirect_before")
.map_or("Redirecting in", |v| v.as_str()),
);
ctx.insert(
"redirect_after",
i18n.get("redirect_after").map_or("seconds", |v| v.as_str()),
);
ctx.insert(
"cancel_label",
i18n.get("cancel_label").map_or("Cancel", |v| v.as_str()),
);
ctx.insert(
"refresh_label",
i18n.get("refresh_label")
.map_or("Refresh Now", |v| v.as_str()),
);
ctx.insert("retry_before", "");
ctx.insert("retry_after", "");
ctx.insert("retry_unit", "");
ctx.insert("retry_manual", "");
ctx.insert("footer", "");
ctx.insert("footer_prefix", "");
ctx.insert("footer_suffix", "");
ctx.insert("version_label", "");
ctx.insert(
"binaries_title",
i18n.get("binaries_title")
.map_or("Supervised Binaries", |v| v.as_str()),
);
ctx.insert("binaries", &state.binaries);
ctx.insert(
"proxy_endpoint",
state.proxy_endpoint.as_deref().unwrap_or(""),
);
ctx.insert(
"proxy_label",
i18n.get("proxy_label").unwrap_or(&"Proxy".to_string()),
);
ctx.insert(
"watch_label",
i18n.get("watch_label").unwrap_or(&"Watching".to_string()),
);
if !state.watch_paths.is_empty() {
ctx.insert("watch_paths", &state.watch_paths);
}
ctx.insert("install_label", "");
ctx.insert("install_method", "");
ctx.insert("copy_hint", "");
ctx.insert("copied_msg", "");
ctx.insert("copy_fail_msg", "");
ctx.insert("logo_base64", &base64_encode(LOGO_BYTES));
ctx.insert("landing_nonce", &0u8);
match tera::Tera::one_off(TEMPLATE, &ctx, false) {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed: {e}")).into_response(),
}
}
async fn info_page(state: axum::extract::State<InfoState>, req: Request) -> Response {
let lang = detect_language(req.headers());
if req.headers().get("x-malkuth-probe").is_some() {
return serve_probe(&lang, &state).await;
}
if let Some(ref backend) = state.serve_backend {
let allowed = state.serve_hosts.is_empty()
|| state.serve_hosts.iter().any(|h| {
req.headers()
.get(header::HOST)
.is_some_and(|v| v.as_bytes() == h.as_bytes())
});
if allowed {
let nonce = read_nonce(&req);
if nonce == 1 {
let proxy_result = if is_upgrade_request(req.headers()) {
proxy_upgrade(req, backend).await
} else {
proxy_to_backend(req, backend).await
};
if let Ok(resp) = proxy_result {
return resp;
}
}
if !LANDING_PAGE_HTML.starts_with("<html><body><h1>Malkuth</h1>") {
return serve_spa(&state, &lang).await;
}
return serve_landing(&lang, &state).await;
}
}
if !LANDING_PAGE_HTML.starts_with("<html><body><h1>Malkuth</h1>") {
return serve_spa(&state, &lang).await;
}
let i18n = get_i18n(&lang);
let ready = state.status == InfoStatus::Ready;
let landing = state.status == InfoStatus::Landing;
let task = match state.status {
InfoStatus::Ready => i18n.get("task_idle").map_or("Idle", |v| v.as_str()),
InfoStatus::Working => i18n
.get("task_working")
.map_or("Starting / Restarting", |v| v.as_str()),
InfoStatus::Landing => i18n.get("task_landing").map_or("Landing", |v| v.as_str()),
};
let mut context = tera::Context::new();
context.insert("lang", &lang);
context.insert("dir", if lang == "ar" { "rtl" } else { "ltr" });
context.insert("title", i18n.get("title").map_or("Malkuth", |v| v.as_str()));
context.insert(
"heading",
i18n.get("heading").map_or("Malkuth", |v| v.as_str()),
);
context.insert("tagline", i18n.get("tagline").map_or("", |v| v.as_str()));
context.insert(
"version_label",
i18n.get("version").map_or("Version", |v| v.as_str()),
);
context.insert(
"task_label",
i18n.get("task").map_or("Current Task", |v| v.as_str()),
);
let retry_full = i18n.get("retry").map_or("", |v| v.as_str());
let (retry_before, retry_after) = retry_full.split_once("{n}").unwrap_or((retry_full, ""));
context.insert("retry_before", retry_before);
context.insert("retry_after", retry_after);
context.insert("footer", i18n.get("footer").map_or("", |v| v.as_str()));
context.insert(
"status_text",
if landing {
i18n.get("status_landing")
.map_or("Redirecting shortly", |v| v.as_str())
} else if ready {
i18n.get("status_ready")
.map_or("All services running.", |v| v.as_str())
} else {
i18n.get("status_starting")
.map_or("Starting...", |v| v.as_str())
},
);
context.insert("ready", &ready);
context.insert("landing", &landing);
context.insert("landing_nonce", &0u8);
context.insert("initial_state", "");
context.insert("task", task);
context.insert("version", &state.version);
let install_method = detect_install_method();
context.insert(
"install_label",
i18n.get("install_label")
.map_or("Installed via", |v| v.as_str()),
);
context.insert("install_method", install_method);
context.insert("binaries", &state.binaries);
context.insert(
"binaries_title",
i18n.get("binaries_title")
.map_or("Supervised Binaries", |v| v.as_str()),
);
let redirect_full = i18n.get("redirect_before").map_or("", |v| v.as_str());
let (redirect_before, redirect_after) = (
redirect_full,
i18n.get("redirect_after")
.map_or("seconds...", |v| v.as_str()),
);
context.insert("redirect_before", redirect_before);
context.insert("redirect_after", redirect_after);
context.insert(
"cancel_label",
i18n.get("cancel_label").map_or("Cancel", |v| v.as_str()),
);
context.insert(
"refresh_label",
i18n.get("refresh_label")
.map_or("Refresh Now", |v| v.as_str()),
);
context.insert(
"retry_unit",
i18n.get("retry_unit").map_or("seconds", |v| v.as_str()),
);
context.insert(
"retry_manual",
i18n.get("retry_manual")
.map_or("You can also refresh manually.", |v| v.as_str()),
);
context.insert(
"copy_hint",
i18n.get("copy_hint")
.map_or("Click to copy", |v| v.as_str()),
);
context.insert(
"copied_msg",
i18n.get("copied_msg")
.map_or("Copied to clipboard", |v| v.as_str()),
);
context.insert(
"copy_fail_msg",
i18n.get("copy_fail_msg")
.map_or("Copy failed", |v| v.as_str()),
);
let footer_full = i18n.get("footer").map_or("", |v| v.as_str());
if let Some((before, after)) = footer_full.split_once("Malkuth") {
context.insert("footer_prefix", before);
context.insert("footer_suffix", after);
} else {
context.insert("footer_prefix", footer_full);
context.insert("footer_suffix", "");
}
context.insert("logo_base64", &base64_encode(LOGO_BYTES));
context.insert(
"proxy_label",
i18n.get("proxy_label").unwrap_or(&"Proxy".to_string()),
);
context.insert(
"watch_label",
i18n.get("watch_label").unwrap_or(&"Watching".to_string()),
);
if let Some(ref ep) = state.proxy_endpoint {
context.insert("proxy_endpoint", ep);
}
if !state.watch_paths.is_empty() {
context.insert("watch_paths", &state.watch_paths);
}
match tera::Tera::one_off(TEMPLATE, &context, false) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to render info page template");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Template error: {e}"),
)
.into_response()
}
}
}
const SUPPORTED: &[&str] = &[
"en", "zh-Hans", "zh-Hant", "ja", "ko", "fr", "de", "es", "pt", "ru", "ar",
];
fn detect_language(headers: &header::HeaderMap) -> String {
let raw = headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.unwrap_or("en");
let mut best_quality = 0.0f32;
let mut best_lang = "en".to_string();
for part in raw.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (tag, q) = part.split_once(';').unwrap_or((part, "q=1.0"));
let quality: f32 = q
.trim()
.strip_prefix("q=")
.and_then(|s| s.parse().ok())
.unwrap_or(1.0);
if quality <= best_quality {
continue;
}
let lang = tag.trim();
let matched = match_language(lang);
best_quality = quality;
best_lang = matched;
}
best_lang
}
fn match_language(tag: &str) -> String {
let tag_lower = tag.to_lowercase();
if SUPPORTED.contains(&tag_lower.as_str()) {
return tag_lower;
}
match tag_lower.as_str() {
"zh" | "zh-cn" | "zh-hans" | "zh-sg" => "zh-Hans".into(),
"zh-tw" | "zh-hk" | "zh-mo" | "zh-hant" => "zh-Hant".into(),
other => {
let base = other.split_once('-').map_or(other, |(b, _)| b);
if SUPPORTED.contains(&base) {
base.into()
} else {
"en".into()
}
}
}
}
fn get_i18n(lang: &str) -> &HashMap<String, String> {
static EMPTY: LazyLock<HashMap<String, String>> = LazyLock::new(HashMap::new);
I18N_DATA
.get(lang)
.unwrap_or_else(|| I18N_DATA.get("en").unwrap_or(&EMPTY))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_en() {
let mut headers = header::HeaderMap::new();
headers.insert("accept-language", "en-US,en;q=0.9".parse().unwrap());
assert_eq!(detect_language(&headers), "en");
}
fn spawn_stub_backend(response: &'static str) -> String {
spawn_custom_backend(move |mut stream| {
use std::io::Write;
let _ = stream.write_all(response.as_bytes());
})
}
fn spawn_custom_backend<F>(f: F) -> String
where
F: FnOnce(std::net::TcpStream) + Send + 'static,
{
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
use std::io::Read;
let mut buf = [0u8; 256];
let _ = stream.read(&mut buf);
f(stream);
}
});
format!("127.0.0.1:{port}")
}
#[test]
fn test_backend_up_on_any_non503_status() {
for status in [
"200 OK",
"301 Moved Permanently",
"404 Not Found",
"500 Boom",
] {
let hp = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n").into_boxed_str(),
));
assert_eq!(
probe_backend(&hp),
BackendState::Up,
"HTTP status `{status}` must count as reachable"
);
}
}
#[test]
fn test_backend_not_ready_on_503() {
let hp =
spawn_stub_backend("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n");
assert_eq!(probe_backend(&hp), BackendState::NotReady);
}
#[test]
fn test_backend_http10_and_scheme_path_url() {
let hp = spawn_stub_backend("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n");
assert_eq!(probe_backend(&hp), BackendState::Up);
let hp = spawn_stub_backend("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n");
assert_eq!(probe_backend(&format!("http://{hp}")), BackendState::Up);
let hp = spawn_stub_backend("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
assert_eq!(
probe_backend(&format!("http://{hp}/api/ready")),
BackendState::Up
);
}
#[test]
fn test_backend_up_on_slow_or_fragmented_response() {
let hp = spawn_custom_backend(|mut stream| {
use std::io::Write;
std::thread::sleep(Duration::from_millis(300));
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n");
});
assert_eq!(probe_backend(&hp), BackendState::Up);
let hp = spawn_custom_backend(|mut stream| {
use std::io::Write;
for b in b"HTTP/1.1 404 Not Found\r\n\r\n" {
let _ = stream.write_all(&[*b]);
std::thread::sleep(Duration::from_millis(5));
}
});
assert_eq!(probe_backend(&hp), BackendState::Up);
}
#[test]
fn test_backend_down_on_silent_garbage_or_refused() {
let hp = spawn_custom_backend(|stream| {
std::thread::sleep(Duration::from_millis(2500));
drop(stream);
});
assert_eq!(probe_backend(&hp), BackendState::Down);
let hp = spawn_stub_backend("this is not http");
assert_eq!(probe_backend(&hp), BackendState::Down);
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
assert_eq!(
probe_backend(&format!("127.0.0.1:{port}")),
BackendState::Down
);
assert_eq!(probe_backend("not-a-socket-addr"), BackendState::Down);
}
#[test]
fn test_detect_zh() {
let mut headers = header::HeaderMap::new();
headers.insert("accept-language", "zh-CN,zh;q=0.9".parse().unwrap());
assert_eq!(detect_language(&headers), "zh-Hans");
}
#[test]
fn test_detect_zht() {
let mut headers = header::HeaderMap::new();
headers.insert("accept-language", "zh-TW,zh;q=0.9".parse().unwrap());
assert_eq!(detect_language(&headers), "zh-Hant");
}
#[test]
fn test_detect_ja() {
let mut headers = header::HeaderMap::new();
headers.insert("accept-language", "ja".parse().unwrap());
assert_eq!(detect_language(&headers), "ja");
}
#[test]
fn test_detect_unknown() {
let mut headers = header::HeaderMap::new();
headers.insert("accept-language", "xx-XX".parse().unwrap());
assert_eq!(detect_language(&headers), "en");
}
#[test]
fn test_all_langs_have_data() {
for lang in SUPPORTED {
let i18n = get_i18n(lang);
assert!(
i18n.contains_key("title"),
"Missing 'title' for language: {lang}"
);
assert!(
i18n.contains_key("heading"),
"Missing 'heading' for language: {lang}"
);
}
}
#[test]
fn test_template_renders() {
let mut ctx = tera::Context::new();
ctx.insert("lang", "en");
ctx.insert("dir", "ltr");
ctx.insert("title", "Malkuth");
ctx.insert("heading", "Malkuth");
ctx.insert("tagline", "Supervisor");
ctx.insert("version_label", "Version");
ctx.insert("task_label", "Task");
ctx.insert("retry_before", "Auto-refresh in ");
ctx.insert("retry_after", " seconds...");
ctx.insert("footer", "GitHub");
ctx.insert("status_text", "Starting...");
ctx.insert("ready", &false);
ctx.insert("landing", &false);
ctx.insert("initial_state", "");
ctx.insert("task", "Startup");
ctx.insert("logo_base64", "dGVzdA==");
ctx.insert("proxy_label", "Proxy");
ctx.insert("watch_label", "Watching");
ctx.insert("version", "0.2.0");
ctx.insert("binaries", &Vec::<serde_json::Value>::new());
ctx.insert("binaries_title", "Supervised Binaries");
ctx.insert("redirect_before", "Redirecting in");
ctx.insert("redirect_after", "seconds...");
ctx.insert("cancel_label", "Cancel");
ctx.insert("refresh_label", "Refresh Now");
ctx.insert("retry_unit", "seconds");
ctx.insert("retry_manual", "You can also refresh manually.");
ctx.insert("copy_hint", "Click to copy");
ctx.insert("copied_msg", "Copied to clipboard");
ctx.insert("copy_fail_msg", "Copy failed");
ctx.insert("footer_prefix", "Powered by ");
ctx.insert(
"footer_suffix",
" — a composable service-supervision toolkit for Rust",
);
match tera::Tera::one_off(TEMPLATE, &ctx, false) {
Ok(html) => println!("OK: {} bytes", html.len()),
Err(e) => panic!("Template error: {:#?}", e),
}
}
#[test]
fn test_template_renders_offline_without_countdown() {
let mut ctx = tera::Context::new();
ctx.insert("lang", "zh-Hans");
ctx.insert("dir", "ltr");
ctx.insert("title", "Malkuth");
ctx.insert("heading", "Malkuth");
ctx.insert("tagline", "Supervisor");
ctx.insert("version_label", "Version");
ctx.insert("task_label", "");
ctx.insert("retry_before", "");
ctx.insert("retry_after", "");
ctx.insert("retry_unit", "");
ctx.insert("retry_manual", "");
ctx.insert("footer", "");
ctx.insert("status_text", "当前服务暂不可达");
ctx.insert("ready", &false);
ctx.insert("landing", &false);
ctx.insert("initial_state", "offline");
ctx.insert("task", "Landing");
ctx.insert("logo_base64", "dGVzdA==");
ctx.insert("proxy_label", "Proxy");
ctx.insert("watch_label", "Watching");
ctx.insert("version", "0.2.8");
ctx.insert("binaries", &Vec::<serde_json::Value>::new());
ctx.insert("binaries_title", "Supervised Binaries");
ctx.insert("redirect_before", "将在");
ctx.insert("redirect_after", "秒后跳转");
ctx.insert("cancel_label", "取消跳转");
ctx.insert("refresh_label", "立即刷新");
ctx.insert("copy_hint", "Click to copy");
ctx.insert("copied_msg", "Copied");
ctx.insert("copy_fail_msg", "Copy failed");
ctx.insert("footer_prefix", "");
ctx.insert("footer_suffix", "");
let html = tera::Tera::one_off(TEMPLATE, &ctx, false).expect("Template error");
assert!(
!html.contains("id=\"retryHint\""),
"offline page must not render the countdown hint"
);
assert!(
html.contains(">立即刷新</button>"),
"offline page must offer the refresh action"
);
assert!(
!html.contains(">取消跳转</button>"),
"offline page must not offer a cancel-redirect action"
);
}
#[test]
fn test_template_renders_starting_without_countdown() {
let mut ctx = tera::Context::new();
ctx.insert("lang", "zh-Hans");
ctx.insert("dir", "ltr");
ctx.insert("title", "Malkuth");
ctx.insert("heading", "Malkuth");
ctx.insert("tagline", "Supervisor");
ctx.insert("version_label", "Version");
ctx.insert("task_label", "");
ctx.insert("retry_before", "");
ctx.insert("retry_after", "");
ctx.insert("retry_unit", "");
ctx.insert("retry_manual", "");
ctx.insert("footer", "");
ctx.insert("status_text", "服务正在启动中");
ctx.insert("ready", &false);
ctx.insert("landing", &false);
ctx.insert("initial_state", "starting");
ctx.insert("task", "Landing");
ctx.insert("logo_base64", "dGVzdA==");
ctx.insert("proxy_label", "Proxy");
ctx.insert("watch_label", "Watching");
ctx.insert("version", "0.2.10");
ctx.insert("binaries", &Vec::<serde_json::Value>::new());
ctx.insert("binaries_title", "Supervised Binaries");
ctx.insert("redirect_before", "将在");
ctx.insert("redirect_after", "秒后跳转");
ctx.insert("cancel_label", "取消跳转");
ctx.insert("refresh_label", "立即刷新");
ctx.insert("copy_hint", "Click to copy");
ctx.insert("copied_msg", "Copied");
ctx.insert("copy_fail_msg", "Copy failed");
ctx.insert("footer_prefix", "");
ctx.insert("footer_suffix", "");
let html = tera::Tera::one_off(TEMPLATE, &ctx, false).expect("Template error");
assert!(
!html.contains("id=\"retryHint\""),
"starting page must not render the countdown hint"
);
assert!(
html.contains(">立即刷新</button>"),
"starting page must offer the refresh action"
);
assert!(
!html.contains(">取消跳转</button>"),
"starting page must not offer a cancel-redirect action"
);
}
#[tokio::test]
async fn test_proxy_preserves_backend_cookies() {
let backend = spawn_custom_backend(|mut stream| {
use std::io::Write;
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nSet-Cookie: session=abc; Path=/\r\nSet-Cookie: theme=dark; Path=/\r\n\r\nok",
);
});
tokio::time::sleep(Duration::from_millis(50)).await;
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let resp = proxy_to_backend(req, &format!("http://{backend}"))
.await
.expect("proxy should succeed");
let cookies: Vec<_> = resp.headers().get_all(header::SET_COOKIE).iter().collect();
assert_eq!(cookies.len(), 3, "nonce + both backend cookies expected");
let joined: String = cookies
.iter()
.filter_map(|v| v.to_str().ok())
.collect::<Vec<_>>()
.join("|");
assert!(joined.contains("__malkuth_nonce=1"), "nonce cookie missing");
assert!(joined.contains("session=abc"), "backend cookie 1 missing");
assert!(joined.contains("theme=dark"), "backend cookie 2 missing");
}
#[test]
fn test_should_fall_back_classification() {
for status in [200u16, 204, 301, 302, 304, 400, 404, 418, 500] {
assert!(
!should_fall_back(status),
"status {status} must pass through to the browser"
);
}
for status in [502u16, 503, 504] {
assert!(
should_fall_back(status),
"status {status} must fall back to the landing page"
);
}
}
#[tokio::test]
async fn test_proxy_passes_through_error_statuses() {
for (status_line, expected) in [
("404 Not Found", StatusCode::NOT_FOUND),
(
"500 Internal Server Error",
StatusCode::INTERNAL_SERVER_ERROR,
),
] {
let backend = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 {status_line}\r\nContent-Length: 2\r\nX-Marker: stub\r\n\r\nok")
.into_boxed_str(),
));
tokio::time::sleep(Duration::from_millis(50)).await;
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let resp = proxy_to_backend(req, &format!("http://{backend}"))
.await
.unwrap_or_else(|_| panic!("status `{status_line}` must pass through"));
assert_eq!(resp.status(), expected);
assert_eq!(resp.headers().get("x-marker").unwrap(), "stub");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"ok");
}
}
#[tokio::test]
async fn test_proxy_passes_through_redirect_without_following() {
let backend = spawn_stub_backend(
"HTTP/1.1 301 Moved Permanently\r\nContent-Length: 0\r\nLocation: http://example.com/new\r\n\r\n",
);
tokio::time::sleep(Duration::from_millis(50)).await;
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let resp = proxy_to_backend(req, &format!("http://{backend}"))
.await
.expect("301 must pass through instead of being followed");
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"http://example.com/new"
);
}
#[tokio::test]
async fn test_proxy_falls_back_on_gateway_statuses() {
for status_line in [
"502 Bad Gateway",
"503 Service Unavailable",
"504 Gateway Timeout",
] {
let backend = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\n\r\n").into_boxed_str(),
));
tokio::time::sleep(Duration::from_millis(50)).await;
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
assert!(
proxy_to_backend(req, &format!("http://{backend}"))
.await
.is_err(),
"status `{status_line}` must fall back to the landing page"
);
}
}
#[test]
fn test_is_upgrade_request_classification() {
fn headers(pairs: &[(&str, &str)]) -> header::HeaderMap {
let mut map = header::HeaderMap::new();
for (name, value) in pairs {
map.insert(
header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
}
map
}
assert!(is_upgrade_request(&headers(&[
("connection", "upgrade"),
("upgrade", "websocket"),
])));
assert!(is_upgrade_request(&headers(&[
("Connection", "keep-alive, UpGrAdE"),
("Upgrade", "WebSocket"),
])));
assert!(is_upgrade_request(&headers(&[
("connection", "Upgrade"),
("upgrade", "h2c"),
])));
assert!(!is_upgrade_request(&headers(&[("upgrade", "websocket")])));
assert!(!is_upgrade_request(&headers(&[("connection", "upgrade")])));
assert!(!is_upgrade_request(&headers(&[(
"connection",
"keep-alive"
)])));
assert!(!is_upgrade_request(&headers(&[("connection", "close")])));
assert!(!is_upgrade_request(&headers(&[])));
}
#[test]
fn test_backend_host_port_extraction() {
assert_eq!(backend_host_port("127.0.0.1:8080"), Some("127.0.0.1:8080"));
assert_eq!(
backend_host_port("http://127.0.0.1:8080"),
Some("127.0.0.1:8080")
);
assert_eq!(
backend_host_port("http://127.0.0.1:8080/api/ws"),
Some("127.0.0.1:8080")
);
assert_eq!(
backend_host_port("https://example.com:9443"),
Some("example.com:9443")
);
assert_eq!(backend_host_port(""), None);
assert_eq!(backend_host_port("http://"), None);
}
#[test]
fn test_parse_response_head() {
let (status, headers) = parse_response_head(
b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: abc\r\nConnection: Upgrade\r\n\r\n",
)
.unwrap();
assert_eq!(status, 101);
assert!(headers.contains(&("upgrade".to_string(), b"websocket".to_vec())));
assert!(headers.contains(&("sec-websocket-accept".to_string(), b"abc".to_vec())));
let (status, headers) =
parse_response_head(b"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\n\r\n").unwrap();
assert_eq!(status, 404);
assert_eq!(
headers
.iter()
.find(|(n, _)| n == "content-length")
.map(|(_, v)| v.as_slice()),
Some(b"2".as_slice())
);
assert!(parse_response_head(b"not http at all").is_none());
}
#[tokio::test]
async fn test_proxy_upgrade_tunnels_101_with_headers() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let n = stream.read(&mut buf).unwrap_or(0);
let req_text = String::from_utf8_lossy(&buf[..n]);
let lowered = req_text.to_ascii_lowercase();
if !lowered.contains("upgrade: websocket")
|| !lowered.contains("connection: upgrade")
|| !lowered.contains("host: 127.0.0.1:")
{
stream
.write_all(b"HTTP/1.1 500 Broken Proxy\r\nContent-Length: 0\r\n\r\n")
.ok();
return;
}
let key = req_text
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("sec-websocket-key"))
.and_then(|l| l.split_once(':').map(|(_, v)| v.trim().to_string()))
.unwrap_or_default();
let accept = ws_accept_for_test(&key);
let resp = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\nX-Backend: ws\r\n\r\n"
);
stream.write_all(resp.as_bytes()).ok();
std::thread::sleep(Duration::from_millis(200));
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut req = Request::builder()
.uri("/api/rpc?workspace=test")
.header("host", "front.door.example")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
.header("sec-websocket-version", "13")
.body(axum::body::Body::empty())
.unwrap();
let on_upgrade = hyper::upgrade::on(&mut req);
req.extensions_mut().insert(on_upgrade);
let resp = proxy_upgrade(req, &format!("http://127.0.0.1:{port}"))
.await
.expect("101 must pass through");
assert_eq!(resp.status(), StatusCode::SWITCHING_PROTOCOLS);
assert_eq!(resp.headers().get("upgrade").unwrap(), "websocket");
assert_eq!(resp.headers().get("connection").unwrap(), "Upgrade");
assert_eq!(
resp.headers()
.get("sec-websocket-accept")
.unwrap()
.to_str()
.unwrap(),
ws_accept_for_test("dGhlIHNhbXBsZSBub25jZQ==")
);
assert_eq!(resp.headers().get("x-backend").unwrap(), "ws");
}
#[tokio::test]
async fn test_proxy_upgrade_passes_through_non_101() {
let backend = spawn_stub_backend(
"HTTP/1.1 404 Not Found\r\nContent-Length: 2\r\nX-Marker: ws404\r\nSet-Cookie: session=abc; Path=/\r\n\r\nno",
);
tokio::time::sleep(Duration::from_millis(50)).await;
let mut req = Request::builder()
.uri("/missing")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.body(axum::body::Body::empty())
.unwrap();
let on_upgrade = hyper::upgrade::on(&mut req);
req.extensions_mut().insert(on_upgrade);
let resp = proxy_upgrade(req, &format!("http://{backend}"))
.await
.expect("404 must pass through instead of being masked");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert_eq!(resp.headers().get("x-marker").unwrap(), "ws404");
let cookies: Vec<_> = resp.headers().get_all(header::SET_COOKIE).iter().collect();
let joined: String = cookies
.iter()
.filter_map(|v| v.to_str().ok())
.collect::<Vec<_>>()
.join("|");
assert!(joined.contains("__malkuth_nonce=1"), "nonce cookie missing");
assert!(joined.contains("session=abc"), "backend cookie missing");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"no");
}
#[tokio::test]
async fn test_proxy_upgrade_decodes_chunked_body() {
let backend = spawn_stub_backend(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n",
);
tokio::time::sleep(Duration::from_millis(50)).await;
let mut req = Request::builder()
.uri("/ws")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.body(axum::body::Body::empty())
.unwrap();
let on_upgrade = hyper::upgrade::on(&mut req);
req.extensions_mut().insert(on_upgrade);
let resp = proxy_upgrade(req, &format!("http://{backend}"))
.await
.expect("chunked 200 must pass through");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"Wikipedia");
}
#[tokio::test]
async fn test_proxy_upgrade_falls_back_on_transport_and_gateway() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let mut req = Request::builder()
.uri("/ws")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.body(axum::body::Body::empty())
.unwrap();
let on_upgrade = hyper::upgrade::on(&mut req);
req.extensions_mut().insert(on_upgrade);
assert!(
proxy_upgrade(req, &format!("http://127.0.0.1:{port}"))
.await
.is_err(),
"unreachable backend must fall back to the landing page"
);
for status_line in [
"502 Bad Gateway",
"503 Service Unavailable",
"504 Gateway Timeout",
] {
let backend = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\n\r\n").into_boxed_str(),
));
tokio::time::sleep(Duration::from_millis(50)).await;
let mut req = Request::builder()
.uri("/ws")
.header("connection", "upgrade")
.header("upgrade", "websocket")
.body(axum::body::Body::empty())
.unwrap();
let on_upgrade = hyper::upgrade::on(&mut req);
req.extensions_mut().insert(on_upgrade);
assert!(
proxy_upgrade(req, &format!("http://{backend}"))
.await
.is_err(),
"status `{status_line}` must fall back to the landing page"
);
}
}
#[test]
fn test_ws_accept_derivation() {
assert_eq!(
ws_accept_for_test("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
fn ws_accept_for_test(key: &str) -> String {
const GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
let mut input = key.as_bytes().to_vec();
input.extend_from_slice(GUID);
base64_encode(&sha1(&input))
}
fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
];
let bit_len = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 80];
for (i, word) in chunk.chunks(4).enumerate() {
w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
for (i, &wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | (!b & d), 0x5A82_7999),
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
_ => (b ^ c ^ d, 0xCA62_C1D6),
};
let tmp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = tmp;
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = [0u8; 20];
for (i, hh) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&hh.to_be_bytes());
}
out
}
}