mod ws;
use axum::{
Router,
body::Body,
extract::{ConnectInfo, FromRequestParts, OriginalUri, Path, Request, State, WebSocketUpgrade},
http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::any,
};
use std::net::IpAddr;
use std::ops::RangeInclusive;
use std::sync::OnceLock;
use futures_util::StreamExt;
use regex::Regex;
use crate::AppState;
struct OptionalWebSocketUpgrade(Option<WebSocketUpgrade>);
impl<S> FromRequestParts<S> for OptionalWebSocketUpgrade
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let ws = WebSocketUpgrade::from_request_parts(parts, state).await.ok();
Ok(Self(ws))
}
}
pub const ALLOWED_PORT_RANGE: RangeInclusive<u16> = 3000..=65535;
const DENIED_PORTS: &[u16] = &[3306, 5432, 6379, 27017, 11211];
const DENIED_PORT_RANGES: &[(u16, u16)] = &[(8500, 8503), (9200, 9300)];
pub const MAX_REQUEST_BODY: usize = 2 * 1024 * 1024;
const REWRITE_HTML_MAX: usize = 4 * 1024 * 1024;
const REWRITE_JS_MAX: usize = 16 * 1024 * 1024;
const HOP_BY_HOP_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
pub fn is_port_allowed(port: u16, self_port: u16) -> bool {
if !ALLOWED_PORT_RANGE.contains(&port) {
return false;
}
if DENIED_PORTS.contains(&port) {
return false;
}
if DENIED_PORT_RANGES.iter().any(|&(lo, hi)| (lo..=hi).contains(&port)) {
return false;
}
port != self_port
}
#[derive(Clone)]
pub struct ProxyState {
pub client: reqwest::Client,
pub self_port: u16,
pub base_host: Option<String>,
pub max_request_body: usize,
}
pub fn routes(state: AppState) -> Router<AppState> {
Router::new()
.route("/proxy/{*path}", any(proxy_handler))
.route_layer(middleware::from_fn_with_state(state, crate::auth::require_auth_mw))
}
async fn proxy_handler(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
Path(path): Path<String>,
OptionalWebSocketUpgrade(upgrade): OptionalWebSocketUpgrade,
request: Request,
) -> Response {
let Some(port) = path.split('/').next().and_then(|p| p.parse::<u16>().ok()) else {
return (StatusCode::BAD_REQUEST, "invalid port").into_response();
};
dispatch_proxy(state, port, uri, upgrade, request).await
}
async fn dispatch_proxy(
state: AppState,
port: u16,
uri: axum::http::Uri,
upgrade: Option<WebSocketUpgrade>,
request: Request,
) -> Response {
if !is_port_allowed(port, state.proxy.self_port) {
tracing::warn!("proxy port denied: {}", port);
return (StatusCode::FORBIDDEN, "port not allowed").into_response();
}
let client_ip = request_client_ip(&request);
if let Some(ws) = upgrade {
if let Some(host) = host_from_request(&request)
&& let Some(origin) = request.headers().get(header::ORIGIN)
&& !origin_matches_host(origin, host)
{
tracing::warn!("ws proxy origin rejected (port {}): {:?}", port, origin);
return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
}
return ws::relay(ws, port, &uri, request.headers().clone(), client_ip).await;
}
forward_http(state, port, uri, request, client_ip).await
}
fn origin_matches_host(origin: &HeaderValue, host: &str) -> bool {
let Ok(o) = origin.to_str() else {
return false;
};
let Some(authority) = o.split_once("://").map(|(_, rest)| rest) else {
return false;
};
let origin_host = authority.split(['/', '?', '#']).next().unwrap_or("");
let origin_host = strip_port(origin_host);
let host = strip_port(host);
!origin_host.is_empty() && origin_host.eq_ignore_ascii_case(host)
}
fn strip_port(s: &str) -> &str {
if let Some(rest) = s.strip_prefix('[') {
return rest.split_once(']').map(|(h, _)| h).unwrap_or(rest);
}
s.split(':').next().unwrap_or(s)
}
pub fn parse_proxy_host(host: &str, base: &str) -> Option<u16> {
if base.is_empty() {
return None;
}
let host = if host.ends_with(']') {
host
} else {
host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host)
};
let host = host.to_ascii_lowercase();
let base = base.to_ascii_lowercase();
let prefix = host.strip_suffix(&format!(".{}", base))?;
if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
prefix.parse::<u16>().ok()
}
fn host_from_request(request: &Request) -> Option<&str> {
request.headers().get(header::HOST).and_then(|v| v.to_str().ok())
}
fn is_ws_upgrade(request: &Request) -> bool {
request
.headers()
.get(header::UPGRADE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
}
pub async fn proxy_host_mw(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let Some(base) = state.proxy.base_host.as_deref().filter(|b| !b.is_empty()) else {
return next.run(request).await;
};
let Some(host) = host_from_request(&request) else {
return next.run(request).await;
};
let Some(port) = parse_proxy_host(host, base) else {
return next.run(request).await;
};
if !is_port_allowed(port, state.proxy.self_port) {
tracing::warn!("proxy host port denied: {}", port);
return (StatusCode::FORBIDDEN, "port not allowed").into_response();
}
let token = crate::auth::extract_token(&request);
if let Err(status) = crate::auth::verify_request(&state, token.as_deref()).await {
return status.into_response();
}
let orig = request.uri().clone();
let normalized: axum::http::Uri = match orig.query() {
Some(q) => format!("/proxy/{}{}?{}", port, orig.path(), q).parse().unwrap_or(orig.clone()),
None => format!("/proxy/{}{}", port, orig.path()).parse().unwrap_or(orig.clone()),
};
let client_ip = request_client_ip(&request);
if is_ws_upgrade(&request) {
let (mut parts, _body) = request.into_parts();
return match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
Ok(ws) => ws::relay(ws, port, &normalized, parts.headers.clone(), client_ip).await,
Err(_) => (StatusCode::BAD_REQUEST, "invalid websocket upgrade").into_response(),
};
}
dispatch_proxy(state, port, normalized, None, request).await
}
fn upstream_url(uri: &axum::http::Uri, port: u16) -> String {
let prefix = format!("/proxy/{}", port);
let remaining = uri.path().strip_prefix(&prefix).unwrap_or("").trim_start_matches('/');
match uri.query() {
Some(q) => format!("http://127.0.0.1:{}/{}?{}", port, remaining, q),
None => format!("http://127.0.0.1:{}/{}", port, remaining),
}
}
async fn forward_http(
state: AppState,
port: u16,
uri: axum::http::Uri,
request: Request,
client_ip: Option<IpAddr>,
) -> Response {
let target = upstream_url(&uri, port);
let method = request.method().clone();
let req_headers = rewrite_request_headers(request.headers(), port, false, client_ip);
let body = match axum::body::to_bytes(request.into_body(), state.proxy.max_request_body).await {
Ok(b) => b,
Err(e) => {
tracing::warn!("proxy failed to read request body (port {}): {}", port, e);
return (StatusCode::BAD_REQUEST, "failed to read body").into_response();
}
};
let upstream_resp = match state
.proxy
.client
.request(method, &target)
.headers(req_headers)
.body(body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
let status =
if e.is_timeout() { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY };
tracing::warn!("proxy upstream failed (port {}): {}", port, e);
return (status, "upstream unreachable").into_response();
}
};
let status = upstream_resp.status();
let resp_headers = rewrite_response_headers(upstream_resp.headers(), port);
let mut builder = Response::builder().status(status);
if let Some(hm) = builder.headers_mut() {
*hm = resp_headers;
}
let content_encoding =
upstream_resp.headers().get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok());
let content_type = upstream_resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(';').next())
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
if rewrite_allowed_for_encoding(content_encoding)
&& let Some(kind) = RewriteKind::from_content_type(&content_type)
{
match try_rewrite_body(upstream_resp, kind, port).await {
Ok(RewriteOutcome::Done(body)) => {
return match builder.body(Body::from(body)) {
Ok(resp) => resp,
Err(e) => {
tracing::error!("proxy failed to build response (port {}): {}", port, e);
(StatusCode::INTERNAL_SERVER_ERROR, "proxy error").into_response()
}
};
}
Ok(RewriteOutcome::Fallthrough(head, resp)) => {
let head_stream = futures_util::stream::iter(
head.chunks(64 * 1024)
.map(|c| Ok::<_, std::io::Error>(axum::body::Bytes::copy_from_slice(c)))
.collect::<Vec<_>>(),
);
let stream = head_stream.chain(body_stream(resp));
return match builder.body(Body::from_stream(stream)) {
Ok(resp) => resp,
Err(e) => {
tracing::error!("proxy failed to build response (port {}): {}", port, e);
(StatusCode::INTERNAL_SERVER_ERROR, "proxy error").into_response()
}
};
}
Err(e) => {
tracing::warn!("proxy failed to read body for rewrite (port {}): {}", port, e);
return (StatusCode::BAD_GATEWAY, "upstream unreachable").into_response();
}
}
}
match builder.body(Body::from_stream(body_stream(upstream_resp))) {
Ok(resp) => resp,
Err(e) => {
tracing::error!("proxy failed to build response (port {}): {}", port, e);
(StatusCode::INTERNAL_SERVER_ERROR, "proxy error").into_response()
}
}
}
fn body_stream(
resp: reqwest::Response,
) -> impl futures_util::Stream<Item = Result<axum::body::Bytes, std::io::Error>> {
futures_util::stream::unfold(resp, |mut resp| async {
match resp.chunk().await {
Ok(Some(chunk)) => Some((Ok::<_, std::io::Error>(chunk), resp)),
Ok(None) => None,
Err(e) => Some((Err(std::io::Error::other(e)), resp)),
}
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum RewriteKind {
Html,
Js,
}
impl RewriteKind {
fn from_content_type(ct: &str) -> Option<Self> {
match ct {
"text/html" => Some(Self::Html),
"text/javascript" | "application/javascript" | "application/x-javascript" => {
Some(Self::Js)
}
_ => None,
}
}
fn max_bytes(self) -> usize {
match self {
Self::Html => REWRITE_HTML_MAX,
Self::Js => REWRITE_JS_MAX,
}
}
}
enum RewriteOutcome {
Done(Vec<u8>),
Fallthrough(Vec<u8>, reqwest::Response),
}
fn rewrite_allowed_for_encoding(content_encoding: Option<&str>) -> bool {
match content_encoding {
None => true,
Some(s) => {
let t = s.trim();
t.is_empty() || t.eq_ignore_ascii_case("identity")
}
}
}
async fn try_rewrite_body(
mut resp: reqwest::Response,
kind: RewriteKind,
port: u16,
) -> Result<RewriteOutcome, reqwest::Error> {
let limit = kind.max_bytes();
let mut buf = Vec::with_capacity(64 * 1024);
while let Some(c) = resp.chunk().await? {
if buf.len() + c.len() > limit {
return Ok(RewriteOutcome::Fallthrough(buf, resp));
}
buf.extend_from_slice(&c);
}
let out = match kind {
RewriteKind::Html => rewrite_html(&buf, port),
RewriteKind::Js => rewrite_js(&buf, port),
};
Ok(RewriteOutcome::Done(out))
}
static HTML_ATTR_RE: OnceLock<Regex> = OnceLock::new();
static JS_API_RE: OnceLock<Regex> = OnceLock::new();
static JS_BROWSER_ROUTER_RE: OnceLock<Regex> = OnceLock::new();
static VITE_PRELOAD_RE: OnceLock<Regex> = OnceLock::new();
static HTML_PRERENDER_RE: OnceLock<Regex> = OnceLock::new();
fn rewrite_html(body: &[u8], port: u16) -> Vec<u8> {
let s = String::from_utf8_lossy(body);
let prefix = format!("/proxy/{}", port);
let re = HTML_ATTR_RE.get_or_init(|| {
Regex::new(r#"(?i)(src|href|srcset|action|formaction|poster)=(["'])([^"']*)(["'])"#)
.expect("valid html attr regex")
});
let rewritten = re.replace_all(&s, |caps: ®ex::Captures| {
let attr = &caps[1];
let quote = &caps[2];
let value = rewrite_attr_value(&caps[3], &prefix);
format!("{}={}{}{}", attr, quote, value, &caps[4])
});
let prerender_re = HTML_PRERENDER_RE
.get_or_init(|| Regex::new(r#"(?i)rel=["']prerender["']"#).expect("valid rel regex"));
prerender_re.replace_all(&rewritten, r#"rel="prefetch""#).into_owned().into_bytes()
}
fn rewrite_attr_value(value: &str, prefix: &str) -> String {
if value.starts_with("/proxy/") {
return value.to_string();
}
if value.contains(',') {
return value
.split(',')
.map(|item| {
let t = item.trim();
if t.starts_with('/') && !t.starts_with("//") {
let (url, desc) = match t.split_once(char::is_whitespace) {
Some((u, d)) => (u, d),
None => (t, ""),
};
let mut out = format!("{}{}", prefix, url);
if !desc.is_empty() {
out.push(' ');
out.push_str(desc);
}
out
} else {
t.to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
}
if value.starts_with('/') && !value.starts_with("//") {
format!("{}{}", prefix, value)
} else {
value.to_string()
}
}
fn rewrite_js(body: &[u8], port: u16) -> Vec<u8> {
let s = String::from_utf8_lossy(body).into_owned();
let prefix = format!("/proxy/{}", port);
let re = JS_API_RE.get_or_init(|| Regex::new(r#"(["'`])/api"#).expect("valid js api regex"));
let out = re.replace_all(&s, |caps: ®ex::Captures| {
let whole = caps.get(0).unwrap();
let is_boundary = match s[whole.end()..].chars().next() {
None => true, Some(c) => {
c == '/'
|| c == '"'
|| c == '\''
|| c == '`'
|| c == '?'
|| c == '#'
|| c == '$'
|| c.is_whitespace()
}
};
if is_boundary { format!("{}{}/api", &caps[1], prefix) } else { whole.as_str().to_string() }
});
let basename = format!("basename:\"{}\",", prefix);
let br_re = JS_BROWSER_ROUTER_RE.get_or_init(|| {
Regex::new(r#"(\{)(future:\{v7_startTransition)"#).expect("valid browser router regex")
});
let injected = br_re
.replace(&out, |caps: ®ex::Captures| format!("{}{}{}", &caps[1], basename, &caps[2]));
let vp_re = VITE_PRELOAD_RE
.get_or_init(|| Regex::new(r#"return"/"\+e"#).expect("valid vite preload regex"));
let prefixed = vp_re.replace_all(&injected, format!("return\"{}/\"+e", prefix));
prefixed.into_owned().into_bytes()
}
fn is_hop_by_hop(name: &str) -> bool {
HOP_BY_HOP_HEADERS.contains(&name.to_ascii_lowercase().as_str())
}
fn host_value(port: u16) -> Result<HeaderValue, header::InvalidHeaderValue> {
HeaderValue::from_str(&format!("127.0.0.1:{}", port))
}
fn origin_value(port: u16) -> Result<HeaderValue, header::InvalidHeaderValue> {
HeaderValue::from_str(&format!("http://127.0.0.1:{}", port))
}
fn rewrite_request_headers(
original: &HeaderMap,
port: u16,
is_ws: bool,
client_ip: Option<IpAddr>,
) -> HeaderMap {
let mut out = HeaderMap::with_capacity(original.len());
let mut had_forwarded_for = false;
for (name, value) in original.iter() {
let lower = name.as_str().to_ascii_lowercase();
if is_hop_by_hop(&lower) {
continue;
}
match lower.as_str() {
"accept-encoding" => {}
"host" => {
if let Ok(v) = host_value(port) {
out.insert(name.clone(), v);
}
}
"cookie" => {
if let Some(v) = strip_omniterm_token(value) {
out.insert(name.clone(), v);
}
}
"origin" if is_ws => {
if let Ok(v) = origin_value(port) {
out.insert(name.clone(), v);
}
}
"x-forwarded-for" => {
had_forwarded_for = true;
let v = match client_ip {
Some(ip) => match value.to_str() {
Ok(s) if !s.is_empty() => HeaderValue::from_str(&format!("{}, {}", s, ip))
.unwrap_or_else(|_| value.clone()),
_ => {
HeaderValue::from_str(&ip.to_string()).unwrap_or_else(|_| value.clone())
}
},
None => value.clone(),
};
out.insert(name.clone(), v);
}
"x-forwarded-proto" | "x-forwarded-host" => {
out.insert(name.clone(), value.clone());
}
_ => {
out.insert(name.clone(), value.clone());
}
}
}
if !out.contains_key(header::HOST)
&& let Ok(v) = host_value(port)
{
out.insert(header::HOST, v);
}
if !out.contains_key("x-forwarded-proto") {
out.insert("x-forwarded-proto", HeaderValue::from_static("http"));
}
if !out.contains_key("x-forwarded-host")
&& let Some(h) = original.get(header::HOST)
{
out.insert("x-forwarded-host", h.clone());
}
if !had_forwarded_for
&& let Some(ip) = client_ip
&& let Ok(v) = HeaderValue::from_str(&ip.to_string())
{
out.insert("x-forwarded-for", v);
}
out
}
fn request_client_ip(request: &Request) -> Option<IpAddr> {
request.extensions().get::<ConnectInfo<std::net::SocketAddr>>().map(|c| c.0.ip())
}
fn strip_omniterm_token(value: &HeaderValue) -> Option<HeaderValue> {
let s = value.to_str().ok()?;
let kept: Vec<&str> = s
.split(';')
.map(str::trim)
.filter(|p| !p.is_empty() && !p.starts_with("omniterm_token="))
.collect();
if kept.is_empty() {
return None;
}
HeaderValue::from_str(&kept.join("; ")).ok()
}
fn rewrite_response_headers(original: &HeaderMap, port: u16) -> HeaderMap {
let mut out = HeaderMap::with_capacity(original.len());
for (name, value) in original.iter() {
let lower = name.as_str().to_ascii_lowercase();
if is_hop_by_hop(&lower) {
continue;
}
match lower.as_str() {
"content-length" => {}
"set-cookie" => {
if let Ok(v) = rewrite_set_cookie(value, port) {
out.append(name.clone(), v);
}
}
"location" => {
out.insert(name.clone(), rewrite_location(value, port));
}
_ => {
out.insert(name.clone(), value.clone());
}
}
}
out
}
fn rewrite_location(value: &HeaderValue, port: u16) -> HeaderValue {
let Ok(s) = value.to_str() else {
return value.clone();
};
let prefix = format!("/proxy/{}", port);
if let Some(rest) = s.strip_prefix('/')
&& !rest.starts_with('/')
&& let Ok(v) = HeaderValue::from_str(&format!("{}/{}", prefix, rest))
{
return v;
}
for base in [
format!("http://localhost:{}", port),
format!("http://127.0.0.1:{}", port),
format!("http://0.0.0.0:{}", port),
format!("https://localhost:{}", port),
format!("https://127.0.0.1:{}", port),
format!("https://0.0.0.0:{}", port),
] {
if let Some(rest) = s.strip_prefix(&base)
&& let Ok(v) = HeaderValue::from_str(&format!("{}{}", prefix, rest))
{
return v;
}
}
value.clone()
}
fn rewrite_set_cookie(value: &HeaderValue, port: u16) -> Result<HeaderValue, ()> {
let s = value.to_str().map_err(|_| ())?;
let mut parts: Vec<String> = Vec::new();
for seg in s.split(';') {
let seg = seg.trim();
if seg.is_empty() {
continue;
}
let lower = seg.to_ascii_lowercase();
if lower.starts_with("domain=") {
let domain = seg[7..].trim_matches('"').to_ascii_lowercase();
if domain == "localhost" || domain == "127.0.0.1" {
continue;
}
} else if lower.starts_with("path=") {
let path = seg[5..].trim().trim_matches('"');
let new_path = if path.is_empty() || path == "/" {
format!("/proxy/{}/", port)
} else if path.starts_with('/') {
format!("/proxy/{}{}", port, path)
} else {
format!("/proxy/{}/{}", port, path)
};
parts.push(format!("Path={}", new_path));
continue;
}
parts.push(seg.to_string());
}
if parts.is_empty() {
return Err(());
}
HeaderValue::from_str(&parts.join("; ")).map_err(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn port_allowlist_rejects_low_ports() {
assert!(!is_port_allowed(80, 9777));
assert!(!is_port_allowed(2999, 9777));
assert!(!is_port_allowed(22, 9777));
}
#[test]
fn port_allowlist_rejects_denylisted_ports() {
for &p in DENIED_PORTS {
assert!(!is_port_allowed(p, 9777), "port {p} should be denied");
}
assert!(!is_port_allowed(8501, 9777), "Consul range denied");
assert!(!is_port_allowed(9200, 9777), "ES range denied");
}
#[test]
fn port_allowlist_rejects_self_port() {
assert!(!is_port_allowed(9777, 9777), "self port must not loop back");
assert!(!is_port_allowed(9077, 9077));
}
#[test]
fn port_allowlist_accepts_dev_ports() {
assert!(is_port_allowed(3000, 9777));
assert!(is_port_allowed(5173, 9777));
assert!(is_port_allowed(65535, 9777));
}
#[test]
fn request_headers_strip_hop_by_hop() {
let mut h = HeaderMap::new();
h.insert("connection", HeaderValue::from_static("keep-alive"));
h.insert("upgrade", HeaderValue::from_static("websocket"));
h.insert("te", HeaderValue::from_static("trailers"));
h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert!(!out.contains_key("connection"));
assert!(!out.contains_key("upgrade"));
assert!(!out.contains_key("te"));
assert!(!out.contains_key("keep-alive"));
}
#[test]
fn request_headers_strip_accept_encoding() {
let mut h = HeaderMap::new();
h.insert("accept-encoding", HeaderValue::from_static("gzip, deflate, br"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert!(!out.contains_key("accept-encoding"));
}
#[test]
fn request_headers_rewrite_host() {
let mut h = HeaderMap::new();
h.insert("host", HeaderValue::from_static("example.com:9077"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out["host"], "127.0.0.1:3000");
}
#[test]
fn request_headers_inject_host_when_missing() {
let h = HeaderMap::new();
let out = rewrite_request_headers(&h, 5173, false, None);
assert_eq!(out["host"], "127.0.0.1:5173");
}
#[test]
fn request_headers_strip_omniterm_token_from_cookie() {
let mut h = HeaderMap::new();
h.insert(
"cookie",
HeaderValue::from_static("sid=abc; omniterm_token=secret.jwt; theme=dark"),
);
let out = rewrite_request_headers(&h, 3000, false, None);
let cookie = out["cookie"].to_str().unwrap();
assert!(cookie.contains("sid=abc"));
assert!(cookie.contains("theme=dark"));
assert!(!cookie.contains("omniterm_token"));
}
#[test]
fn request_headers_drop_cookie_when_only_token() {
let mut h = HeaderMap::new();
h.insert("cookie", HeaderValue::from_static("omniterm_token=secret.jwt"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert!(!out.contains_key("cookie"));
}
#[test]
fn request_headers_rewrite_origin_only_for_ws() {
let mut h = HeaderMap::new();
h.insert("origin", HeaderValue::from_static("http://example.com"));
let out_http = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out_http["origin"], "http://example.com");
let out_ws = rewrite_request_headers(&h, 3000, true, None);
assert_eq!(out_ws["origin"], "http://127.0.0.1:3000");
}
#[test]
fn request_headers_sets_x_forwarded_for_from_client_ip() {
let h = HeaderMap::new();
let ip: IpAddr = "192.168.1.5".parse().unwrap();
let out = rewrite_request_headers(&h, 3000, false, Some(ip));
assert_eq!(out["x-forwarded-for"], "192.168.1.5");
}
#[test]
fn request_headers_appends_x_forwarded_for_when_present() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
let ip: IpAddr = "192.168.1.5".parse().unwrap();
let out = rewrite_request_headers(&h, 3000, false, Some(ip));
assert_eq!(out["x-forwarded-for"], "10.0.0.1, 192.168.1.5");
let out2 = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out2["x-forwarded-for"], "10.0.0.1");
}
#[test]
fn request_headers_sets_x_forwarded_proto_when_missing() {
let h = HeaderMap::new();
let out = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out["x-forwarded-proto"], "http");
}
#[test]
fn request_headers_keeps_existing_x_forwarded_proto() {
let mut h = HeaderMap::new();
h.insert("x-forwarded-proto", HeaderValue::from_static("https"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out["x-forwarded-proto"], "https");
}
#[test]
fn request_headers_sets_x_forwarded_host_from_host() {
let mut h = HeaderMap::new();
h.insert("host", HeaderValue::from_static("3000.omniterm.lan:9777"));
let out = rewrite_request_headers(&h, 3000, false, None);
assert_eq!(out["x-forwarded-host"], "3000.omniterm.lan:9777");
assert_eq!(out["host"], "127.0.0.1:3000");
}
#[test]
fn strip_omniterm_token_pure() {
let v = HeaderValue::from_static("a=1; omniterm_token=x.y.z; b=2");
let out = strip_omniterm_token(&v).unwrap();
assert_eq!(out.to_str().unwrap(), "a=1; b=2");
let only = HeaderValue::from_static("omniterm_token=x");
assert!(strip_omniterm_token(&only).is_none());
}
#[test]
fn location_rewrites_absolute_path() {
let v = HeaderValue::from_static("/foo/bar");
assert_eq!(rewrite_location(&v, 3000).to_str().unwrap(), "/proxy/3000/foo/bar");
let root = HeaderValue::from_static("/");
assert_eq!(rewrite_location(&root, 3000).to_str().unwrap(), "/proxy/3000/");
}
#[test]
fn location_rewrites_local_origin_url() {
let v = HeaderValue::from_static("http://localhost:3000/x");
assert_eq!(rewrite_location(&v, 3000).to_str().unwrap(), "/proxy/3000/x");
let v2 = HeaderValue::from_static("http://127.0.0.1:3000/");
assert_eq!(rewrite_location(&v2, 3000).to_str().unwrap(), "/proxy/3000/");
}
#[test]
fn location_keeps_external_url() {
let v = HeaderValue::from_static("https://example.com/x");
assert_eq!(rewrite_location(&v, 3000).to_str().unwrap(), "https://example.com/x");
let v2 = HeaderValue::from_static("//cdn.example.com/x");
assert_eq!(rewrite_location(&v2, 3000).to_str().unwrap(), "//cdn.example.com/x");
}
#[test]
fn set_cookie_strips_localhost_domain_and_prefixes_path() {
let v = HeaderValue::from_static("sid=1; Path=/; Domain=localhost; HttpOnly");
let out = rewrite_set_cookie(&v, 3000).unwrap().to_str().unwrap().to_string();
assert!(!out.to_lowercase().contains("domain=localhost"));
assert!(out.contains("Path=/proxy/3000/"));
assert!(out.contains("HttpOnly"));
}
#[test]
fn set_cookie_prefixes_non_root_path() {
let v = HeaderValue::from_static("sid=1; Path=/api");
let out = rewrite_set_cookie(&v, 3000).unwrap();
assert_eq!(out.to_str().unwrap(), "sid=1; Path=/proxy/3000/api");
}
#[test]
fn upstream_url_builds_remaining_path_and_query() {
let uri: axum::http::Uri = "/proxy/3000/a/b?x=1&y=2".parse().unwrap();
assert_eq!(upstream_url(&uri, 3000), "http://127.0.0.1:3000/a/b?x=1&y=2");
let uri: axum::http::Uri = "/proxy/3000/".parse().unwrap();
assert_eq!(upstream_url(&uri, 3000), "http://127.0.0.1:3000/");
}
#[test]
fn proxy_host_parses_port_subdomain() {
assert_eq!(parse_proxy_host("3000.omniterm.lan", "omniterm.lan"), Some(3000));
assert_eq!(parse_proxy_host("5173.omniterm.lan", "omniterm.lan"), Some(5173));
}
#[test]
fn proxy_host_strips_listen_port() {
assert_eq!(parse_proxy_host("3000.omniterm.lan:9777", "omniterm.lan"), Some(3000));
}
#[test]
fn proxy_host_rejects_non_digit_prefix() {
assert_eq!(parse_proxy_host("www.omniterm.lan", "omniterm.lan"), None);
assert_eq!(parse_proxy_host("x3306.omniterm.lan", "omniterm.lan"), None);
assert_eq!(parse_proxy_host("omniterm.lan", "omniterm.lan"), None);
}
#[test]
fn proxy_host_rejects_mismatched_base() {
assert_eq!(parse_proxy_host("3000.example.com", "omniterm.lan"), None);
assert_eq!(parse_proxy_host("3000.omniterm.lan", ""), None);
}
#[test]
fn proxy_host_is_case_insensitive() {
assert_eq!(parse_proxy_host("3000.OMNITERM.LAN", "omniterm.lan"), Some(3000));
assert_eq!(parse_proxy_host("3000.omniterm.lan", "OMNITERM.LAN"), Some(3000));
}
#[test]
fn proxy_host_supports_multi_level_base() {
assert_eq!(
parse_proxy_host("3000.omniterm.example.com", "omniterm.example.com"),
Some(3000)
);
}
#[test]
fn proxy_host_parses_denylisted_port_for_later_allowlist_check() {
assert_eq!(parse_proxy_host("3306.omniterm.lan", "omniterm.lan"), Some(3306));
assert!(!is_port_allowed(3306, 9777));
}
fn html(s: &str) -> String {
String::from_utf8(rewrite_html(s.as_bytes(), 3000)).unwrap()
}
fn js(s: &str) -> String {
String::from_utf8(rewrite_js(s.as_bytes(), 3000)).unwrap()
}
#[test]
fn rewrite_html_prefixes_absolute_src_and_href() {
let out = html(r#"<script src="/assets/index-x.js"></script><link href="/logo.svg?v=2">"#);
assert_eq!(
out,
r#"<script src="/proxy/3000/assets/index-x.js"></script><link href="/proxy/3000/logo.svg?v=2">"#
);
}
#[test]
fn rewrite_html_supports_single_quotes_and_other_attrs() {
let out = html(r#"<form action='/submit'><img src='/img/a.png'><video poster='/p.jpg'>"#);
assert_eq!(
out,
r#"<form action='/proxy/3000/submit'><img src='/proxy/3000/img/a.png'><video poster='/proxy/3000/p.jpg'>"#
);
}
#[test]
fn rewrite_html_srcset_rewrites_each_candidate() {
let out = html(r#"<img srcset="/a.png 1x, /b.png 2x, https://cdn/x.png 3x">"#);
assert_eq!(
out,
r#"<img srcset="/proxy/3000/a.png 1x, /proxy/3000/b.png 2x, https://cdn/x.png 3x">"#
);
}
#[test]
fn rewrite_html_keeps_external_and_scheme_relative() {
let out = html(
r##"<a href="https://example.com/x"><a href="//cdn.example.com/y"><a href="mailto:a@b.c"><a href="#anchor">"##,
);
assert_eq!(
out,
r##"<a href="https://example.com/x"><a href="//cdn.example.com/y"><a href="mailto:a@b.c"><a href="#anchor">"##
);
}
#[test]
fn rewrite_html_keeps_already_prefixed() {
let out = html(
r#"<script src="/proxy/3000/assets/x.js"></script><script src="/proxy/9999/assets/y.js"></script>"#,
);
assert_eq!(
out,
r#"<script src="/proxy/3000/assets/x.js"></script><script src="/proxy/9999/assets/y.js"></script>"#
);
}
#[test]
fn rewrite_html_keeps_relative_and_non_attr_paths() {
let out = html(r#"<img src="assets/x.js"><script>const p = "/api/health";</script>"#);
assert_eq!(out, r#"<img src="assets/x.js"><script>const p = "/api/health";</script>"#);
}
#[test]
fn rewrite_html_downgrades_prerender_to_prefetch() {
let out = html(
r#"<link rel="prerender" href="/landing.html"><link rel="prefetch" href="/x.html">"#,
);
assert!(
out.contains(r#"rel="prefetch" href="/proxy/3000/landing.html""#),
"prerender downgraded: {out}"
);
assert!(!out.contains("prerender"), "no prerender left: {out}");
assert!(out.contains(r#"rel="prefetch" href="/proxy/3000/x.html""#));
}
#[test]
fn rewrite_js_prefixes_double_single_and_template_strings() {
let out =
js(r#"fe.post("/api/card/batch");get('/api/user');req(`/api/card/${id}/renew`);"#);
assert_eq!(
out,
r#"fe.post("/proxy/3000/api/card/batch");get('/proxy/3000/api/user');req(`/proxy/3000/api/card/${id}/renew`);"#
);
}
#[test]
fn rewrite_js_injects_basename_into_v7_browser_router() {
let out =
js(r#"a.jsx(Y3t,{future:{v7_startTransition:!0,v7_relativeSplatPath:!0},children:o})"#);
assert!(out.contains(r#"basename:"/proxy/3000","#), "basename injected: {out}");
assert!(out.contains(r#"{basename:"/proxy/3000",future:{v7_startTransition"#));
}
#[test]
fn rewrite_js_keeps_non_v7_router_unchanged() {
let out = js(r#"a.jsx(Rt,{children:o})"#);
assert_eq!(out, r#"a.jsx(Rt,{children:o})"#);
let out2 = js(r#"a.jsx(Bt,{future:{v7_relativeSplatPath:!0},children:o})"#);
assert_eq!(out2, r#"a.jsx(Bt,{future:{v7_relativeSplatPath:!0},children:o})"#);
}
#[test]
fn rewrite_js_basename_uses_requested_port() {
let out_3000 = js(r#"a.jsx(Y3t,{future:{v7_startTransition:!0},children:o})"#);
assert!(out_3000.contains(r#"basename:"/proxy/3000","#));
let out_9076 = String::from_utf8(rewrite_js(
r#"a.jsx(Y3t,{future:{v7_startTransition:!0},children:o})"#.as_bytes(),
9076,
))
.unwrap();
assert!(out_9076.contains(r#"basename:"/proxy/9076","#));
assert!(!out_9076.contains("/proxy/3000"));
}
#[test]
fn rewrite_js_vite_preload_helper_gets_prefix() {
let out = js(r#"k9r=function(e){return"/"+e}"#);
assert!(out.contains(r#"return"/proxy/3000/"+e"#), "vite preload prefixed: {out}");
assert!(!out.contains(r#"return"/"+e"#), "root path gone: {out}");
}
#[test]
fn rewrite_js_keeps_non_vite_preload_unchanged() {
let out = js(r#"const k9r=(e)=>"/"+e"#);
assert_eq!(out, r#"const k9r=(e)=>"/"+e"#);
let out2 = js(r#"var r=function(e){return "/" + e}"#);
assert_eq!(out2, r#"var r=function(e){return "/" + e}"#);
}
#[test]
fn rewrite_js_keeps_comparison_consistent() {
let out = js(
r#"const A = Y === "/api/ratio_config" ? "ratio_config" : Y === "/api/pricing" ? "pricing" : "";"#,
);
assert_eq!(
out,
r#"const A = Y === "/proxy/3000/api/ratio_config" ? "ratio_config" : Y === "/proxy/3000/api/pricing" ? "pricing" : "";"#
);
}
#[test]
fn rewrite_js_keeps_non_api_strings() {
let out = js(
r#"const a = "/foo"; const b = "http://x/api/v1"; const c = /\/api\//; const d = 'api/rel';"#,
);
assert_eq!(
out,
r#"const a = "/foo"; const b = "http://x/api/v1"; const c = /\/api\//; const d = 'api/rel';"#
);
}
#[test]
fn rewrite_kind_matches_content_types() {
assert_eq!(RewriteKind::from_content_type("text/html"), Some(RewriteKind::Html));
assert_eq!(RewriteKind::from_content_type("text/html; charset=utf-8"), None);
assert_eq!(RewriteKind::from_content_type("text/javascript"), Some(RewriteKind::Js));
assert_eq!(RewriteKind::from_content_type("application/javascript"), Some(RewriteKind::Js));
assert_eq!(RewriteKind::from_content_type("application/json"), None);
assert_eq!(RewriteKind::from_content_type("text/css"), None);
}
#[test]
fn rewrite_max_bytes_limits() {
assert_eq!(RewriteKind::Html.max_bytes(), REWRITE_HTML_MAX);
assert_eq!(RewriteKind::Js.max_bytes(), REWRITE_JS_MAX);
}
#[test]
fn rewrite_html_prefixes_base_tag() {
let out = html(
r#"<head><base href="/"><base href="/proxy/3000/"><base href="https://ext.example.com/"></head>"#,
);
assert_eq!(
out,
r#"<head><base href="/proxy/3000/"><base href="/proxy/3000/"><base href="https://ext.example.com/"></head>"#
);
}
#[test]
fn rewrite_html_prefixes_formaction() {
let out = html(r#"<button formaction="/submit">S</button><input formaction='/upload'>"#);
assert_eq!(
out,
r#"<button formaction="/proxy/3000/submit">S</button><input formaction='/proxy/3000/upload'>"#
);
}
#[test]
fn rewrite_js_prefixes_api_without_trailing_slash() {
let out = js(
r#"const a = "/api"; const b = "/api?key=1"; const c = '/api#x'; const d = `/api`;"#,
);
assert_eq!(
out,
r#"const a = "/proxy/3000/api"; const b = "/proxy/3000/api?key=1"; const c = '/proxy/3000/api#x'; const d = `/proxy/3000/api`;"#
);
}
#[test]
fn rewrite_js_does_not_match_api_prefix_of_longer_words() {
let out = js(r#"const a = "/apix"; const b = '/apikey';"#);
assert_eq!(out, r#"const a = "/apix"; const b = '/apikey';"#);
}
#[test]
fn location_rewrites_https_and_0_0_0_0() {
let v = HeaderValue::from_static("http://0.0.0.0:3000/");
assert_eq!(rewrite_location(&v, 3000).to_str().unwrap(), "/proxy/3000/");
let v2 = HeaderValue::from_static("https://localhost:3000/x");
assert_eq!(rewrite_location(&v2, 3000).to_str().unwrap(), "/proxy/3000/x");
let v3 = HeaderValue::from_static("https://127.0.0.1:3000/");
assert_eq!(rewrite_location(&v3, 3000).to_str().unwrap(), "/proxy/3000/");
let v4 = HeaderValue::from_static("https://0.0.0.0:3000/y");
assert_eq!(rewrite_location(&v4, 3000).to_str().unwrap(), "/proxy/3000/y");
}
#[test]
fn ws_origin_matches_subdomain_host() {
let o = HeaderValue::from_static("http://3000.omniterm.lan:9777");
assert!(origin_matches_host(&o, "3000.omniterm.lan:9777"));
let o2 = HeaderValue::from_static("https://3000.omniterm.lan");
assert!(origin_matches_host(&o2, "3000.omniterm.lan"));
}
#[test]
fn ws_origin_rejects_cross_site() {
let o = HeaderValue::from_static("https://evil.com");
assert!(!origin_matches_host(&o, "3000.omniterm.lan"));
let o2 = HeaderValue::from_static("3000.omniterm.lan");
assert!(!origin_matches_host(&o2, "3000.omniterm.lan"));
}
#[test]
fn strip_port_keeps_ipv6_literal() {
assert_eq!(strip_port("3000.omniterm.lan:9777"), "3000.omniterm.lan");
assert_eq!(strip_port("192.168.5.216:9077"), "192.168.5.216");
assert_eq!(strip_port("3000.omniterm.lan"), "3000.omniterm.lan");
assert_eq!(strip_port("[::1]:8080"), "::1");
assert_eq!(strip_port("[::1]"), "::1");
}
#[test]
fn rewrite_allowed_for_encoding_accepts_none_and_identity() {
assert!(rewrite_allowed_for_encoding(None));
assert!(rewrite_allowed_for_encoding(Some("")));
assert!(rewrite_allowed_for_encoding(Some("identity")));
assert!(rewrite_allowed_for_encoding(Some(" IDENTITY ")));
}
#[test]
fn rewrite_allowed_for_encoding_rejects_compressed() {
assert!(!rewrite_allowed_for_encoding(Some("gzip")));
assert!(!rewrite_allowed_for_encoding(Some("br")));
assert!(!rewrite_allowed_for_encoding(Some("deflate")));
assert!(!rewrite_allowed_for_encoding(Some("gzip, br")));
}
#[test]
fn proxy_host_parses_ipv6_port() {
assert_eq!(parse_proxy_host("[::1]:8080", "omniterm.lan"), None);
assert_eq!(parse_proxy_host("[::1]", "omniterm.lan"), None);
assert_eq!(parse_proxy_host("[::1]:8080", "::1"), None);
assert_eq!(parse_proxy_host("3000.omniterm.lan:9777", "omniterm.lan"), Some(3000));
}
#[test]
fn max_request_body_default_is_two_mib() {
assert_eq!(MAX_REQUEST_BODY, 2 * 1024 * 1024);
}
}