use axum::{
Router,
extract::Request,
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
routing::any,
};
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,
}
type BinaryInfoMemo = (std::time::SystemTime, u64, BinaryInfo);
static BINARY_INFO_CACHE: LazyLock<std::sync::Mutex<HashMap<String, BinaryInfoMemo>>> =
LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
pub fn collect_binary_info(program: &str) -> Option<BinaryInfo> {
let path = std::path::PathBuf::from(program);
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
if let Some(cache) = BINARY_INFO_CACHE.lock().unwrap().get(program) {
if cache.0 == modified && cache.1 == metadata.len() {
return Some(cache.2.clone());
}
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_string());
let path_str = path.display().to_string();
let compile_time = modified
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| {
use chrono::TimeZone;
let dt = chrono::Local.timestamp_opt(d.as_secs() as i64, 0).unwrap();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "unknown".to_string());
let bytes = std::fs::read(&path).ok()?;
let (hash, hash_short) = match scan_family_identity(&bytes) {
Some(stamp) => (stamp.clone(), stamp),
None => {
let hash = compute_file_hash(&bytes);
let trimmed = hash.trim_end_matches('=');
let short = if trimmed.len() > 6 {
trimmed[trimmed.len() - 6..].to_string()
} else {
trimmed.to_string()
};
(hash, short)
}
};
let info = BinaryInfo {
name,
path: path_str,
compile_time,
hash,
hash_short,
};
BINARY_INFO_CACHE.lock().unwrap().insert(
program.to_string(),
(modified, metadata.len(), info.clone()),
);
Some(info)
}
fn is_branch_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'/' | b'-')
}
fn is_hex_byte(b: u8) -> bool {
b.is_ascii_digit() || (b'a'..=b'f').contains(&b)
}
fn scan_family_identity(bytes: &[u8]) -> Option<String> {
let mut identities: Vec<(String, String)> = Vec::new();
let mut run: Vec<u8> = Vec::with_capacity(128);
let flush = |run: &mut Vec<u8>, identities: &mut Vec<(String, String)>| {
if !run.is_empty() {
scan_ascii_run(run, identities);
run.clear();
}
};
for &b in bytes {
if b.is_ascii_graphic() || b == b' ' {
run.push(b);
} else {
flush(&mut run, &mut identities);
}
}
flush(&mut run, &mut identities);
if identities.len() != 1 {
return None;
}
let (branch, hash) = &identities[0];
Some(format!("{branch}:{hash}"))
}
fn scan_ascii_run(run: &[u8], identities: &mut Vec<(String, String)>) {
let n = run.len();
let mut i = 0;
while i < n {
if !run[i].is_ascii_digit()
|| (i > 0 && (run[i - 1].is_ascii_digit() || run[i - 1] == b'.'))
{
i += 1;
continue;
}
let Some(after_base) = match_version_base(run, i) else {
i += 1;
continue;
};
if after_base >= n || run[after_base] != b' ' {
i += 1;
continue;
}
let branch_start = after_base + 1;
let mut j = branch_start;
while j < n && is_branch_byte(run[j]) {
j += 1;
}
if j == branch_start || j + 1 >= n {
i += 1;
continue;
}
let mut k = j;
if run[k] == b':' {
k += 1;
}
if k < n && run[k] == b':' {
k += 1;
}
if k == j || k >= n || !is_hex_byte(run[k]) {
i += 1;
continue;
}
let hash_start = k;
let mut e = k;
while e < n && is_hex_byte(run[e]) && e - hash_start < 40 {
e += 1;
}
let hash_len = e - hash_start;
if !(7..=40).contains(&hash_len) {
i += 1;
continue;
}
let branch = String::from_utf8_lossy(&run[branch_start..j]).into_owned();
let hash7 = String::from_utf8_lossy(&run[hash_start..hash_start + 7]).into_owned();
let identity = (branch, hash7);
if !identities.contains(&identity) {
identities.push(identity);
}
i = e;
}
}
fn match_version_base(run: &[u8], start: usize) -> Option<usize> {
let mut i = start;
let mut dots = 0;
while i < run.len() && run[i].is_ascii_digit() {
i += 1;
if i < run.len() && run[i] == b'.' && dots < 2 {
dots += 1;
i += 1;
}
}
if dots == 2 && i > start + 1 && (i >= run.len() || run[i] != b'.') {
Some(i)
} else {
None
}
}
fn compute_file_hash(bytes: &[u8]) -> String {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
base32_encode(&hasher.finalize())
}
const BASE32_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
fn base32_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity((bytes.len() * 8).div_ceil(5));
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | b as u32;
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(BASE32_ALPHABET[((buffer >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 31) as usize] as char);
}
while out.len() % 8 != 0 {
out.push('=');
}
out
}
async fn collect_binaries(programs: &[String]) -> Vec<BinaryInfo> {
if programs.is_empty() {
return vec![];
}
let programs = programs.to_vec();
tokio::task::spawn_blocking(move || {
programs
.iter()
.filter_map(|p| collect_binary_info(p))
.collect()
})
.await
.unwrap_or_default()
}
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>,
binary_programs: Vec<String>,
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));
let epoch = std::sync::Arc::new(tokio::sync::RwLock::new("1".to_string()));
if let Some(ref backend_url) = serve_backend {
let state_arc = backend_state.clone();
let epoch_arc = epoch.clone();
let url = backend_url.clone();
let initial = probe_backend(&url);
if let Some(e) = probe_backend_epoch(&url) {
if let Ok(mut w) = epoch_arc.try_write() {
*w = e;
}
}
if let Ok(mut w) = state_arc.try_write() {
*w = initial;
}
tokio::spawn(async move {
loop {
let probe_url = url.clone();
let (state, token) = tokio::task::spawn_blocking(move || {
(probe_backend(&probe_url), probe_backend_epoch(&probe_url))
})
.await
.unwrap_or((BackendState::Down, None));
let mut w = state_arc.write().await;
*w = state;
drop(w);
if let Some(e) = token {
let mut ew = epoch_arc.write().await;
*ew = e;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
}
let state = InfoState {
version: version.into(),
status,
watch_paths,
proxy_endpoint,
binary_programs,
serve_backend,
serve_hosts,
build_progress,
build_log,
runtime_log,
backend_state,
epoch,
};
Router::new()
.route("/", any(info_page))
.fallback(any(info_page))
.with_state(state)
}
#[derive(Clone)]
struct InfoState {
version: String,
status: InfoStatus,
watch_paths: Vec<String>,
proxy_endpoint: Option<String>,
binary_programs: Vec<String>,
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>>,
epoch: std::sync::Arc<tokio::sync::RwLock<String>>,
}
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 hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn probe_backend_epoch(url: &str) -> Option<String> {
use sha2::Digest;
let host_port = url
.trim_start_matches("http://")
.trim_start_matches("https://")
.split(['/', '?'])
.next()
.unwrap_or("");
let addr: std::net::SocketAddr = host_port.parse().ok()?;
let mut stream = match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
Ok(s) => s,
Err(_) => return None,
};
use std::io::{Read, Write};
let req = format!(
"GET / HTTP/1.0\r\nHost: {host_port}\r\nAccept: text/html\r\nConnection: close\r\n\r\n"
);
stream.write_all(req.as_bytes()).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(1500)));
let mut buf = Vec::with_capacity(8 * 1024);
if stream.read_to_end(&mut buf).is_err() {
tracing::warn!(
bytes = buf.len(),
"epoch probe body read incomplete; keeping previous build token"
);
return None;
}
if buf.len() > 512 * 1024 {
buf.truncate(512 * 1024);
}
let head_end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
let head = std::str::from_utf8(&buf[..head_end]).ok()?;
let mut lines = head.split("\r\n");
let status_is_200 = lines
.next()?
.split_whitespace()
.nth(1)
.is_some_and(|code| code == "200");
if !status_is_200 {
return None;
}
let is_html = lines.any(|l| {
l.split_once(':').is_some_and(|(name, value)| {
name.trim().eq_ignore_ascii_case("content-type")
&& value.to_ascii_lowercase().contains("text/html")
})
});
if !is_html {
return None;
}
let body = &buf[head_end + 4..];
if body.is_empty() {
return None;
}
let hash = sha2::Sha256::digest(body);
Some(hex_encode(&hash)[..12].to_string())
}
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, allowed: bool) -> serde_json::Value {
let i18n = get_i18n(lang);
let binaries = collect_binaries(&state.binary_programs).await;
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": binaries,
"watch_paths": state.watch_paths,
"proxy_endpoint": state.proxy_endpoint,
"version": state.version,
"epoch": state.epoch.read().await.clone(),
"logo_base64": base64_encode(LOGO_BYTES),
"serve": state.serve_backend.is_some() && allowed,
})
}
async fn serve_spa(state: &InfoState, lang: &str, allowed: bool) -> Response {
let init_data = build_spa_init(state, lang, allowed).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"))
})
}
const MAX_PROXY_BODY_BYTES: usize = 1024 * 1024;
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)
}
fn nonce_cookie(epoch: &str) -> header::HeaderValue {
let raw = format!("__malkuth_nonce={epoch}; max-age=604800; path=/");
header::HeaderValue::from_str(&raw).unwrap_or_else(|_| {
header::HeaderValue::from_static("__malkuth_nonce=1; max-age=604800; path=/")
})
}
async fn proxy_upgrade(req: Request, backend: &str, epoch: &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, epoch).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, MAX_PROXY_BODY_BYTES)
.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, nonce_cookie(epoch));
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(|_| ())?;
const MAX_PROXY_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
if size > MAX_PROXY_RESPONSE_BYTES || out.len() + size > MAX_PROXY_RESPONSE_BYTES {
return 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, epoch: &str) -> Result<Response, ()> {
let (parts, body) = req.into_parts();
proxy_to_backend_parts(parts, body, backend, epoch).await
}
async fn proxy_to_backend_parts(
parts: axum::http::request::Parts,
body: axum::body::Body,
backend: &str,
epoch: &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, MAX_PROXY_BODY_BYTES)
.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();
const MAX_PROXY_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
if let Some(len) = resp.content_length() {
if len > MAX_PROXY_RESPONSE_BYTES {
return Err(());
}
}
let resp_body = resp.bytes().await.map_err(|_| ())?;
if resp_body.len() as u64 > MAX_PROXY_RESPONSE_BYTES {
return Err(());
}
let mut response = Response::new(axum::body::Body::from(resp_body));
*response.status_mut() = status;
response
.headers_mut()
.append(header::SET_COOKIE, nonce_cookie(epoch));
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) -> String {
let cookie_header = req.headers().get(header::COOKIE);
let Some(cookies) = cookie_header.and_then(|v| v.to_str().ok()) else {
return String::new();
};
for part in cookies.split(';') {
let kv = part.trim();
if let Some(val) = kv.strip_prefix("__malkuth_nonce=") {
return val.trim().to_string();
}
}
String::new()
}
fn request_interceptable(req: &Request) -> bool {
if !matches!(
*req.method(),
axum::http::Method::GET | axum::http::Method::HEAD
) {
return false;
}
if let Some(dest) = req
.headers()
.get("sec-fetch-dest")
.and_then(|v| v.to_str().ok())
{
return matches!(dest, "document" | "iframe");
}
req.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.to_ascii_lowercase().contains("text/html"))
}
async fn serve_probe(lang: &str, state: &InfoState, allowed: bool) -> 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_none() || 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
.binary_programs
.first()
.and_then(|p| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
.unwrap_or_default();
let json = serde_json::json!({
"state": probe_state,
"message": message,
"progress": progress,
"epoch": state.epoch.read().await.clone(),
"serve": state.serve_backend.is_some() && allowed,
"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 binaries = collect_binaries(&state.binary_programs).await;
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_none() || 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("malkuth_epoch", &*state.epoch.read().await);
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", &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));
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());
let serve_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 req.headers().get("x-malkuth-probe").is_some() {
return serve_probe(&lang, &state, serve_allowed).await;
}
if let Some(ref backend) = state.serve_backend {
let allowed = serve_allowed;
if allowed {
let epoch = state.epoch.read().await.clone();
let nonce = read_nonce(&req);
let seen_current_build = !nonce.is_empty() && nonce == epoch;
let intercept = !seen_current_build
&& !is_upgrade_request(req.headers())
&& request_interceptable(&req);
if !intercept {
let proxy_result = if is_upgrade_request(req.headers()) {
proxy_upgrade(req, backend, &epoch).await
} else {
proxy_to_backend(req, backend, &epoch).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, allowed).await;
}
return serve_landing(&lang, &state).await;
}
}
if !LANDING_PAGE_HTML.starts_with("<html><body><h1>Malkuth</h1>") {
return serve_spa(&state, &lang, serve_allowed).await;
}
let i18n = get_i18n(&lang);
let binaries = collect_binaries(&state.binary_programs).await;
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("initial_state", "");
context.insert("task", task);
context.insert("version", &state.version);
context.insert("malkuth_epoch", "1");
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", &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::*;
fn temp_binary(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"malkuth-identity-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn test_scan_extracts_single_family_identity() {
let bytes = b"\x7fELF\x00\x02\x00 0.1.0 master:c350cb4 \x00\x01 trailing".to_vec();
assert_eq!(
scan_family_identity(&bytes).as_deref(),
Some("master:c350cb4")
);
}
#[test]
fn test_scan_normalizes_full_hash_to_seven() {
let bytes = b"x 0.1.0 master:c350cb4fa9b2d3e4f5a6b7c8d9e0f1a2b3c4d5e y".to_vec();
assert_eq!(
scan_family_identity(&bytes).as_deref(),
Some("master:c350cb4")
);
}
#[test]
fn test_scan_accepts_both_separator_generations() {
assert_eq!(
scan_family_identity(b"0.1.0 feat/landing::770f625 end").as_deref(),
Some("feat/landing:770f625")
);
assert_eq!(
scan_family_identity(b"0.1.0 release:1a2b3c4").as_deref(),
Some("release:1a2b3c4")
);
}
#[test]
fn test_scan_rejects_absent_ambiguous_and_marker_identities() {
assert_eq!(scan_family_identity(b"1.2.3 no identity here"), None);
assert_eq!(scan_family_identity(b"0.1.26 detached:unknown"), None);
assert_eq!(scan_family_identity(b"0.1.0 x:abcdef"), None);
assert_eq!(
scan_family_identity(b"0.1.0 master:aaaaaaa and 0.2.0 release:bbbbbbb"),
None
);
assert_eq!(
scan_family_identity(b"0.1.0 master:c350cb4 0.1.0 master:c350cb4fa99"),
Some("master:c350cb4".to_string())
);
}
#[test]
fn test_collect_prefers_stated_identity_over_content_hash() {
let path = temp_binary("stamped");
std::fs::write(&path, b"\x00\x00 0.1.0 master:c350cb4 \x00").unwrap();
let info = collect_binary_info(path.to_str().unwrap()).unwrap();
assert_eq!(info.hash, "master:c350cb4");
assert_eq!(info.hash_short, "master:c350cb4");
assert_eq!(info.name, path.file_name().unwrap().to_str().unwrap());
assert!(!info.compile_time.is_empty());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_collect_falls_back_to_content_fingerprint() {
let path = temp_binary("plain");
std::fs::write(&path, b"an ordinary binary with no family line").unwrap();
let info = collect_binary_info(path.to_str().unwrap()).unwrap();
assert!(
info.hash
.chars()
.all(|c| { c.is_ascii_uppercase() || c.is_ascii_digit() || c == '=' })
);
assert_eq!(info.hash.len() % 8, 0);
let trimmed = info.hash.trim_end_matches('=');
assert_eq!(info.hash_short, &trimmed[trimmed.len() - 6..]);
assert_ne!(info.hash, info.hash_short);
std::fs::remove_file(&path).ok();
}
#[test]
fn test_collect_refreshes_when_the_binary_changes() {
let path = temp_binary("refresh");
std::fs::write(&path, format!("{:<64}", "0.1.0 master:aaaaaaa")).unwrap();
let first = collect_binary_info(path.to_str().unwrap()).unwrap();
assert_eq!(first.hash, "master:aaaaaaa");
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(1);
std::fs::write(&path, format!("{:<64}", "0.1.0 master:bbbbbbb")).unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(later)
.unwrap();
let second = collect_binary_info(path.to_str().unwrap()).unwrap();
assert_eq!(second.hash, "master:bbbbbbb");
std::fs::remove_file(&path).ok();
}
async fn spawn_landing_door(program: String) -> String {
let router = info_router(
"test".to_string(),
InfoStatus::Landing,
vec![],
None,
vec![program],
None,
vec![],
std::sync::Arc::new(std::sync::Mutex::new(None)),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let door = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
format!("http://{door}")
}
fn strip_css_comments(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("/*") {
out.push_str(&rest[..i]);
match rest[i..].find("*/") {
Some(j) => rest = &rest[i + j + 2..],
None => rest = "",
}
}
out.push_str(rest);
out
}
#[test]
fn test_binary_detail_two_line_wrap_contract() {
let t = include_str!("info_page/template.html");
let css = strip_css_comments(t);
let start = css.find(".binary-detail {").expect("detail rule exists");
let block_end = css[start..]
.find("\n }")
.map(|i| start + i)
.expect("detail rule closes");
let detail = &css[start..block_end];
let wstart = css
.find(".binary-detail.is-wrapped {")
.expect("wrapped rule exists");
let wrapped = &css[wstart
..css[wstart..]
.find("\n }")
.map(|i| wstart + i)
.expect("wrapped rule closes")];
assert!(
detail.contains("display: flex") && detail.contains("flex-wrap: nowrap"),
"template one-line state is a no-wrap flex row"
);
assert!(
detail.contains("min-width: 0;"),
"template detail lets the 1fr track shrink to the real width"
);
assert!(
detail.contains("text-align: end;"),
"template detail aligns its lines to the end edge"
);
assert!(
detail.contains("column-gap: 0.6ch;"),
"template detail gap feeds the probe's one-line width"
);
assert!(
css.contains(".binary-detail > span"),
"template children selector present"
);
assert!(
css.split(".binary-detail > span")
.nth(1)
.unwrap_or("")
.starts_with(" {")
&& css.contains("flex: none;"),
"template children are unsplrinkable one-line atoms"
);
assert!(
css.contains(".binary-detail.is-wrapped"),
"wrapped state class"
);
assert!(
wrapped.contains("flex-direction: column") && wrapped.contains("align-items: stretch"),
"wrapped state is a stretched column"
);
assert!(
css.contains(".binary-detail.is-wrapped .binary-sep"),
"separator hides in the wrapped state"
);
assert!(
t.contains("class=\"binary-sep\""),
"separator is a dedicated element"
);
assert!(
!t.contains("</span> · <span class=\"binary-hash\""),
"no literal separator text can dangle on wrap"
);
assert!(t.contains("probeDetailWrap"), "wrap probe script present");
if LANDING_PAGE_HTML.starts_with("<html><body><h1>Malkuth</h1>") {
return;
}
let spa_start = LANDING_PAGE_HTML
.find(".binary-detail{")
.expect("SPA detail rule exists");
let spa_detail = &LANDING_PAGE_HTML[spa_start
..LANDING_PAGE_HTML[spa_start..]
.find('}')
.map(|i| spa_start + i)
.expect("SPA detail rule closes")];
for marker in [
"flex-wrap:nowrap",
"min-width:0",
"text-align:end",
"column-gap:.6ch",
] {
assert!(
spa_detail.contains(marker),
"embedded SPA `.binary-detail` rule must carry `{marker}`"
);
}
for marker in [
"__wrapProbe",
"flex:none",
".binary-detail.is-wrapped{flex-direction:column;align-items:stretch;row-gap:2px}",
".binary-detail.is-wrapped .binary-row__sep{display:none}",
] {
assert!(
LANDING_PAGE_HTML.contains(marker),
"embedded SPA build must carry `{marker}` (minified CSS or probe)"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_landing_page_states_the_binary_identity_per_render() {
let path = temp_binary("e2e");
std::fs::write(&path, format!("{:<96}", "0.1.0 master:c350cb4")).unwrap();
let door = spawn_landing_door(path.to_string_lossy().into_owned()).await;
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let first = client
.get(&door)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(
first.contains("master:c350cb4"),
"landing page must state the binary's family identity"
);
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(1);
std::fs::write(&path, format!("{:<96}", "0.1.0 master:ddddddd")).unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(later)
.unwrap();
let second = client
.get(&door)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(second.contains("master:ddddddd"));
assert!(!second.contains("master:c350cb4"));
std::fs::remove_file(&path).ok();
}
#[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("malkuth_epoch", "1");
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("malkuth_epoch", "1");
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("malkuth_epoch", "1");
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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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}"), "1")
.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
}
#[test]
fn test_probe_backend_epoch_hashes_html() {
let html = "<html><body>build A</body></html>";
let hp = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{html}", html.len())
.into_boxed_str(),
));
let first = probe_backend_epoch(&hp).expect("HTML document must yield a token");
assert_eq!(first.len(), 12, "token is a 12-char hex prefix");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
let hp = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{html}", html.len())
.into_boxed_str(),
));
assert_eq!(probe_backend_epoch(&hp).as_deref(), Some(first.as_str()));
let rebuilt = "<html><body>build B with new asset hashes</body></html>";
let hp = spawn_stub_backend(Box::leak(
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{rebuilt}", rebuilt.len())
.into_boxed_str(),
));
let second = probe_backend_epoch(&hp).expect("rebuilt HTML must yield a token");
assert_ne!(first, second, "a rebuild must change the token");
}
#[test]
fn test_probe_backend_epoch_rejects_partial_body() {
let hp = spawn_custom_backend(|mut stream| {
use std::io::Write;
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\nContent-Length: 100\r\n\r\n<html><body>part",
);
std::thread::sleep(Duration::from_millis(3000));
});
assert_eq!(
probe_backend_epoch(&hp),
None,
"a stalled partial body must not be hashed into a build token"
);
}
#[test]
fn test_probe_backend_epoch_rejects_non_html() {
let hp = spawn_stub_backend(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: 2\r\n\r\n{}",
);
assert_eq!(probe_backend_epoch(&hp), None);
let hp = spawn_stub_backend(
"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
);
assert_eq!(probe_backend_epoch(&hp), None);
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
assert_eq!(probe_backend_epoch(&format!("127.0.0.1:{port}")), None);
}
#[test]
fn test_read_nonce_raw_value() {
let req = Request::builder()
.header(
header::COOKIE,
"theme=dark; __malkuth_nonce=deadbeefcafe; other=1",
)
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(read_nonce(&req), "deadbeefcafe");
let req = Request::builder()
.header(header::COOKIE, "theme=dark")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(read_nonce(&req), "");
let req = Request::builder().body(axum::body::Body::empty()).unwrap();
assert_eq!(read_nonce(&req), "");
}
#[test]
fn test_request_interceptable_classification() {
let build = |method: &str, hdrs: &[(&str, &str)]| -> Request {
let mut b = Request::builder().method(method).uri("/");
for (n, v) in hdrs {
b = b.header(*n, *v);
}
b.body(axum::body::Body::empty()).unwrap()
};
assert!(request_interceptable(&build(
"GET",
&[("accept", "text/html,application/xhtml+xml,*/*;q=0.8")]
)));
assert!(request_interceptable(&build(
"GET",
&[("sec-fetch-dest", "document")]
)));
assert!(request_interceptable(&build(
"GET",
&[("sec-fetch-dest", "iframe"), ("accept", "*/*")]
)));
assert!(!request_interceptable(&build(
"GET",
&[("accept", "application/json")]
)));
assert!(!request_interceptable(&build("GET", &[("accept", "*/*")])));
assert!(!request_interceptable(&build(
"GET",
&[("sec-fetch-dest", "empty"), ("accept", "text/html")]
)));
assert!(!request_interceptable(&build(
"POST",
&[("accept", "text/html")]
)));
}
struct RebuildableBackend {
port: u16,
doc: std::sync::Arc<std::sync::Mutex<String>>,
}
impl RebuildableBackend {
fn start() -> Self {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let doc = std::sync::Arc::new(std::sync::Mutex::new(
"<html><body>app build one</body></html>".to_string(),
));
let doc2 = std::sync::Arc::clone(&doc);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let doc = std::sync::Arc::clone(&doc2);
std::thread::spawn(move || {
use std::io::{Read, Write};
let mut buf = [0u8; 2048];
let _ = stream.read(&mut buf);
let head = String::from_utf8_lossy(&buf);
let path = head
.split_whitespace()
.nth(1)
.unwrap_or("/")
.split('?')
.next()
.unwrap_or("/")
.to_string();
let (ctype, body) = match path.as_str() {
"/readyz" => ("text/plain", "ok".to_string()),
"/api/data" => ("application/json", r#"{"data":42}"#.to_string()),
_ => ("text/html; charset=utf-8", doc.lock().unwrap().clone()),
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
});
}
});
Self { port, doc }
}
fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
fn rebuild(&self, html: &str) {
*self.doc.lock().unwrap() = html.to_string();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_serve_door_landing_per_build() {
let backend = RebuildableBackend::start();
let router = info_router(
"test".to_string(),
InfoStatus::Landing,
vec![],
None,
vec![],
Some(backend.url()),
vec![],
std::sync::Arc::new(std::sync::Mutex::new(None)),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let door = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let door_url = format!("http://{door}");
let resp = client
.get(format!("{door_url}/"))
.header("accept", "text/html,application/xhtml+xml,*/*;q=0.8")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(
resp.headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"))
);
let body = resp.text().await.unwrap();
assert!(
!body.contains("app build one"),
"first document visit must see the landing page, not the backend document"
);
let resp = client
.get(format!("{door_url}/api/data"))
.header("accept", "application/json")
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let stamp = resp
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.expect("proxied response must stamp the build token")
.to_string();
let epoch = stamp
.strip_prefix("__malkuth_nonce=")
.and_then(|rest| rest.split(';').next())
.expect("stamp must be the __malkuth_nonce cookie")
.to_string();
assert_eq!(epoch.len(), 12, "token must be the hashed document");
let body = resp.text().await.unwrap();
assert_eq!(body, "{\"data\":42}", "API traffic must reach the backend");
let resp = client
.get(format!("{door_url}/"))
.header("accept", "text/html")
.header(header::COOKIE, format!("__malkuth_nonce={epoch}"))
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("app build one"),
"a matching token must unlock the backend document"
);
backend.rebuild("<html><body>app build two</body></html>");
let mut new_epoch = None;
for _ in 0..80 {
tokio::time::sleep(Duration::from_millis(250)).await;
let resp = client
.get(format!("{door_url}/api/data"))
.header("accept", "application/json")
.send()
.await
.unwrap();
let stamp = resp
.headers()
.get(header::SET_COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if let Some(e) = stamp
.strip_prefix("__malkuth_nonce=")
.and_then(|rest| rest.split(';').next())
{
if e != epoch {
new_epoch = Some(e.to_string());
break;
}
}
}
let new_epoch = new_epoch.expect("probe loop must observe the rebuilt document token");
let resp = client
.get(format!("{door_url}/"))
.header("accept", "text/html")
.header(header::COOKIE, format!("__malkuth_nonce={epoch}"))
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
!body.contains("app build"),
"a stale token must re-show the landing page after a rebuild"
);
let resp = client
.get(format!("{door_url}/"))
.header("accept", "text/html")
.header(header::COOKIE, format!("__malkuth_nonce={new_epoch}"))
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert!(
body.contains("app build two"),
"the new token must unlock the rebuilt document"
);
let resp = client
.get(format!("{door_url}/api/data"))
.header("accept", "application/json")
.header(header::COOKIE, format!("__malkuth_nonce={epoch}"))
.send()
.await
.unwrap();
let body = resp.text().await.unwrap();
assert_eq!(body, "{\"data\":42}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_probe_reports_serve_capability() {
async fn spawn_router(
serve_backend: Option<String>,
serve_hosts_mode: &str,
) -> (reqwest::Client, String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let door = listener.local_addr().unwrap();
let serve_hosts = match serve_hosts_mode {
"own" => vec![door.to_string()],
"foreign" => vec!["other.example".to_string()],
_ => vec![],
};
let router = info_router(
"test".to_string(),
InfoStatus::Landing,
vec![],
None,
vec![],
serve_backend,
serve_hosts,
std::sync::Arc::new(std::sync::Mutex::new(None)),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
std::sync::Arc::new(std::sync::Mutex::new(vec![])),
);
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, format!("http://{door}"))
}
async fn probe_serve(client: &reqwest::Client, door_url: &str) -> bool {
let body = client
.get(format!("{door_url}/"))
.header("x-malkuth-probe", "1")
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
json.get("serve")
.and_then(|v| v.as_bool())
.expect("probe JSON must carry a boolean serve capability")
}
let backend = RebuildableBackend::start();
let (client, door_url) = spawn_router(None, "empty").await;
assert!(
!probe_serve(&client, &door_url).await,
"a door without --serve must report serve=false"
);
let (client, door_url) = spawn_router(Some(backend.url()), "empty").await;
assert!(
probe_serve(&client, &door_url).await,
"an unrestricted --serve door must report serve=true"
);
let (client, door_url) = spawn_router(Some(backend.url()), "own").await;
assert!(
probe_serve(&client, &door_url).await,
"a --serve-host matching the request Host must report serve=true"
);
let body = client
.get(format!("{door_url}/"))
.header("accept", "text/html")
.send()
.await
.unwrap()
.text()
.await
.unwrap();
if let Some(start) = body.find("__MALKUTH_INIT__ = ") {
let rest = &body[start + "__MALKUTH_INIT__ = ".len()..];
let end = rest.find("</script>").expect("init script must terminate");
let json_str = rest[..end].trim().trim_end_matches(';');
let json: serde_json::Value =
serde_json::from_str(json_str).expect("init must be valid JSON");
assert_eq!(
json.get("serve").and_then(|v| v.as_bool()),
Some(true),
"the SPA init must report serve=true on an allowed origin"
);
}
let (client, door_url) = spawn_router(Some(backend.url()), "foreign").await;
assert!(
!probe_serve(&client, &door_url).await,
"a --serve-host mismatch must report serve=false"
);
}
}