use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use microvms_app::error::{Error, ErrorKind};
use microvms_app::session::proxy::{PROXY_AUTH_HEADER, PROXY_PORT_HEADER, ProxyAuth};
pub const DEFAULT_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
#[derive(Clone, Debug)]
pub enum ForwardEvent {
Listening { local: SocketAddr, guest_port: u16 },
Forwarded { status: u16, upgraded: bool },
Refused { status: u16, explanation: String },
ConnectionError { detail: String },
}
pub fn refusal_explanation(status: u16, guest_port: u16) -> Option<String> {
match status {
403 => Some(format!(
"the endpoint proxy refused the request for port {guest_port} (403 Access to port \
denied): the minted token's scope does not cover it. Nothing was wrong with the \
guest — the credential never authorized this port."
)),
502 => Some(format!(
"the endpoint proxy reached the VM and found nothing listening on port \
{guest_port} (502). The credential is correct and the scope covers the port, so \
this is a dead server in the guest rather than an auth problem."
)),
401 => Some(
"the endpoint proxy rejected the credential (401). On a long-lived tunnel this \
usually means a minted token expired without being refreshed; the mint count is \
the observable that says whether a refresh happened."
.to_string(),
),
_ => None,
}
}
pub async fn splice<A, B>(a: &mut A, b: &mut B) -> Result<(u64, u64), Error>
where
A: AsyncRead + AsyncWrite + Unpin + ?Sized,
B: AsyncRead + AsyncWrite + Unpin + ?Sized,
{
tokio::io::copy_bidirectional(a, b).await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the upgraded connection ended abnormally: {err}"),
)
})
}
#[derive(Clone, Debug)]
pub struct ForwardSpec {
pub bind: SocketAddr,
pub guest_port: u16,
pub endpoint: String,
pub exchange_timeout: std::time::Duration,
}
impl ForwardSpec {
pub fn new(bind: SocketAddr, guest_port: u16, endpoint: impl Into<String>) -> Self {
Self {
bind,
guest_port,
endpoint: endpoint.into(),
exchange_timeout: DEFAULT_EXCHANGE_TIMEOUT,
}
}
}
pub fn upstream_url(endpoint: &str, target: &str) -> String {
let base = endpoint.trim_end_matches('/');
let base = if base.starts_with("https://") || base.starts_with("http://") {
base.to_string()
} else {
format!("https://{base}")
};
if target.starts_with('/') {
format!("{base}{target}")
} else {
format!("{base}/{target}")
}
}
pub fn direct_url(endpoint: &str, guest_port: u16, target: &str) -> Result<String, Error> {
let mut url = reqwest::Url::parse(&upstream_url(endpoint, target)).map_err(|err| {
Error::new(
ErrorKind::InvalidArg,
format!("{endpoint} is not an endpoint a request can be addressed to: {err}"),
)
})?;
url.set_port(Some(guest_port)).map_err(|()| {
Error::new(
ErrorKind::InvalidArg,
format!("{endpoint} names no host a port can be set on"),
)
})?;
Ok(url.to_string())
}
fn local_host_matches(headers: &[(String, String)], bind: SocketAddr) -> bool {
let mut hosts = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("host"));
let Some((_, value)) = hosts.next() else {
return false;
};
if hosts.next().is_some() {
return false;
}
let Ok(authority) = value.parse::<http::uri::Authority>() else {
return false;
};
if value.contains('@') {
return false;
}
let port = match authority.port_u16() {
Some(port) => port,
None if authority.as_str() == authority.host() => 80,
None => return false,
};
if port != bind.port() {
return false;
}
let host = authority.host();
if host.eq_ignore_ascii_case("localhost") {
return bind.ip().is_loopback();
}
let host = host
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or(host);
host.parse::<std::net::IpAddr>().is_ok_and(|ip| {
if bind.ip().is_loopback() {
ip.is_loopback()
} else {
ip == bind.ip()
}
})
}
pub const STRIPPED_REQUEST_HEADERS: [&str; 3] = [PROXY_AUTH_HEADER, PROXY_PORT_HEADER, "host"];
pub fn forwards_request_header(name: &str) -> bool {
!STRIPPED_REQUEST_HEADERS
.iter()
.any(|stripped| stripped.eq_ignore_ascii_case(name))
}
pub struct ForwardClient {
inner: reqwest::Client,
}
impl ForwardClient {
pub fn new() -> Result<Self, Error> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map(|inner| Self { inner })
.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not build the forwarder's HTTP client: {err}"),
)
})
}
}
impl std::fmt::Debug for ForwardClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForwardClient").finish_non_exhaustive()
}
}
pub async fn bind(spec: &ForwardSpec) -> Result<TcpListener, Error> {
TcpListener::bind(spec.bind).await.map_err(|err| {
Error::new(
ErrorKind::InvalidArg,
format!(
"could not bind {}: {err}. Another process is probably already listening \
there; pass a different local port.",
spec.bind
),
)
})
}
pub async fn hop_headers(
auth: &Arc<ProxyAuth>,
guest_port: u16,
) -> Result<Vec<(String, String)>, Error> {
auth.headers_for_port(guest_port).await
}
pub async fn accept(listener: &TcpListener) -> Result<(TcpStream, SocketAddr), Error> {
listener.accept().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the local listener stopped accepting: {err}"),
)
})
}
pub fn is_upgrade(status: u16) -> bool {
status == 101
}
pub trait LocalStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> LocalStream for T {}
pub async fn serve_connection<S, F>(
local: S,
spec: &ForwardSpec,
auth: &Arc<ProxyAuth>,
client: &ForwardClient,
on_event: F,
) -> Result<(), Error>
where
S: LocalStream,
F: FnMut(ForwardEvent),
{
serve_connection_via(local, spec, Some(auth), client, on_event).await
}
pub(crate) async fn serve_connection_via<S, F>(
mut local: S,
spec: &ForwardSpec,
auth: Option<&Arc<ProxyAuth>>,
client: &ForwardClient,
mut on_event: F,
) -> Result<(), Error>
where
S: LocalStream,
F: FnMut(ForwardEvent),
{
let head = match read_request_head(&mut local).await? {
Some(head) => head,
None => return Ok(()),
};
if !local_host_matches(&head.headers, spec.bind) {
let explanation = "the request Host must name the local forward and its port".to_string();
write_local_error(&mut local, 403, &explanation).await?;
on_event(ForwardEvent::Refused {
status: 403,
explanation,
});
return Ok(());
}
let (minted, url) = match auth {
Some(auth) => (
hop_headers(auth, spec.guest_port).await?,
upstream_url(&spec.endpoint, &head.target),
),
None => (
Vec::new(),
direct_url(&spec.endpoint, spec.guest_port, &head.target)?,
),
};
let method = reqwest::Method::from_bytes(head.method.as_bytes()).map_err(|err| {
Error::new(
ErrorKind::InvalidArg,
format!(
"the local client sent an unusable method {:?}: {err}",
head.method
),
)
})?;
let mut request = client
.inner
.request(method, &url)
.timeout(spec.exchange_timeout);
for (name, value) in head
.headers
.iter()
.filter(|(name, _)| forwards_request_header(name))
{
request = request.header(name, value);
}
for (name, value) in &minted {
request = request.header(name, value);
}
if !head.body.is_empty() {
request = request.body(head.body.clone());
}
let response = request.send().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!(
"forwarding {} {} to the endpoint failed: {err}",
head.method, head.target
),
)
})?;
let status = response.status().as_u16();
if let Some(explanation) = refusal_explanation(status, spec.guest_port) {
on_event(ForwardEvent::Refused {
status,
explanation: explanation.clone(),
});
write_local_error(&mut local, status, &explanation).await?;
return Ok(());
}
if is_upgrade(status) {
write_response_head(&mut local, status, response.headers()).await?;
let mut upstream = response.upgrade().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the endpoint answered 101 but the connection did not upgrade: {err}"),
)
})?;
on_event(ForwardEvent::Forwarded {
status,
upgraded: true,
});
splice(&mut local, &mut upstream).await?;
return Ok(());
}
let headers = response.headers().clone();
let body = response.bytes().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("reading the guest's response body failed: {err}"),
)
})?;
write_response(&mut local, status, &headers, &body).await?;
on_event(ForwardEvent::Forwarded {
status,
upgraded: false,
});
Ok(())
}
#[derive(Clone, Debug)]
struct RequestHead {
method: String,
target: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
async fn read_request_head<S>(local: &mut S) -> Result<Option<RequestHead>, Error>
where
S: AsyncRead + Unpin + ?Sized,
{
use tokio::io::AsyncReadExt as _;
let mut buffer = Vec::new();
let mut byte = [0_u8; 1];
loop {
match local.read(&mut byte).await {
Ok(0) if buffer.is_empty() => return Ok(None),
Ok(0) => {
return Err(Error::new(
ErrorKind::Protocol,
"the local client closed the connection mid-request".to_string(),
));
}
Ok(_) => {
buffer.push(byte[0]);
if buffer.len() > MAX_REQUEST_HEAD_BYTES {
return Err(Error::new(
ErrorKind::InvalidArg,
format!(
"the local client's request head exceeded {MAX_REQUEST_HEAD_BYTES} \
bytes without terminating"
),
));
}
if buffer.ends_with(b"\r\n\r\n") {
break;
}
}
Err(err) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("reading the local client's request failed: {err}"),
));
}
}
}
let text = String::from_utf8(buffer).map_err(|err| {
Error::new(
ErrorKind::Protocol,
format!("the local client's request head is not utf-8: {err}"),
)
})?;
let mut lines = text.split("\r\n");
let request_line = lines.next().unwrap_or_default();
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default().to_string();
let target = parts.next().unwrap_or_default().to_string();
if method.is_empty() || target.is_empty() {
return Err(Error::new(
ErrorKind::Protocol,
format!("the local client sent an unparseable request line {request_line:?}"),
));
}
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
continue;
}
let Some((name, value)) = line.split_once(':') else {
return Err(Error::new(
ErrorKind::Protocol,
format!("the local client sent an unparseable header line {line:?}"),
));
};
headers.push((name.trim().to_string(), value.trim().to_string()));
}
if headers.iter().any(|(name, value)| {
name.eq_ignore_ascii_case("transfer-encoding") && value.contains("chunked")
}) {
return Err(Error::new(
ErrorKind::InvalidArg,
"the local client sent a chunked request body, which this forwarder does not \
re-frame. Send a content-length body, or report the client that needs chunked \
uploads so the forwarder can grow a streaming path."
.to_string(),
));
}
let length: usize = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.parse().ok())
.unwrap_or(0);
let mut body = vec![0_u8; length];
if length > 0 {
local.read_exact(&mut body).await.map_err(|err| {
Error::new(
ErrorKind::Protocol,
format!("the local client announced {length} body bytes and sent fewer: {err}"),
)
})?;
}
Ok(Some(RequestHead {
method,
target,
headers,
body,
}))
}
const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
const RECOMPUTED_RESPONSE_HEADERS: [&str; 2] = ["content-length", "transfer-encoding"];
async fn write_response_head<W>(
local: &mut W,
status: u16,
headers: &reqwest::header::HeaderMap,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + ?Sized,
{
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason_phrase(status));
for (name, value) in headers {
if let Ok(value) = value.to_str() {
head.push_str(&format!("{}: {}\r\n", name.as_str(), value));
}
}
head.push_str("\r\n");
local.write_all(head.as_bytes()).await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not write the response head to the local client: {err}"),
)
})?;
local.flush().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not flush the response head: {err}"),
)
})
}
async fn write_response<W>(
local: &mut W,
status: u16,
headers: &reqwest::header::HeaderMap,
body: &[u8],
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + ?Sized,
{
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason_phrase(status));
for (name, value) in headers {
if RECOMPUTED_RESPONSE_HEADERS
.iter()
.any(|skip| skip.eq_ignore_ascii_case(name.as_str()))
{
continue;
}
if let Ok(value) = value.to_str() {
head.push_str(&format!("{}: {}\r\n", name.as_str(), value));
}
}
head.push_str(&format!("content-length: {}\r\n\r\n", body.len()));
local.write_all(head.as_bytes()).await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not write the response to the local client: {err}"),
)
})?;
local.write_all(body).await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not write the response body to the local client: {err}"),
)
})?;
local.flush().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not flush the response: {err}"),
)
})
}
fn reason_phrase(status: u16) -> &'static str {
match status {
101 => "Switching Protocols",
200 => "OK",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
504 => "Gateway Timeout",
_ => "Status",
}
}
pub async fn write_local_error<W>(stream: &mut W, status: u16, body: &str) -> Result<(), Error>
where
W: AsyncWrite + Unpin + ?Sized,
{
let reason = match status {
403 => "Forbidden",
502 => "Bad Gateway",
504 => "Gateway Timeout",
_ => "Bad Gateway",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\ncontent-type: text/plain; charset=utf-8\r\n\
content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not answer the local client: {err}"),
)
})?;
stream.flush().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not flush the local client's response: {err}"),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use microvms_app::session::proxy::DEFAULT_AGENT_PORT;
use microvms_app::testing::{CountingMinter, TestClock};
fn auth_pair() -> (Arc<ProxyAuth>, Arc<CountingMinter>) {
let minter = Arc::new(CountingMinter::default());
let auth = ProxyAuth::with_refresh_after(
minter.clone(),
DEFAULT_AGENT_PORT,
std::time::Duration::from_secs(30 * 60),
Arc::new(TestClock::default()),
)
.expect("the default refresh interval is below the ceiling");
(Arc::new(auth), minter)
}
async fn upstream(response: &'static str) -> (String, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
let addr = listener.local_addr().expect("bound");
let handle = tokio::spawn(async move {
let (mut socket, _) =
tokio::time::timeout(std::time::Duration::from_secs(10), listener.accept())
.await
.expect("the forwarder reaches the upstream")
.expect("one connection");
let head = read_request_head(&mut socket)
.await
.expect("a readable request")
.expect("a request arrived");
socket
.write_all(response.as_bytes())
.await
.expect("the canned response is written");
socket.flush().await.expect("flushed");
let headers = head
.headers
.iter()
.map(|(name, value)| format!("{}: {value}", name.to_ascii_lowercase()))
.collect::<Vec<_>>()
.join("\n");
format!("{} {}\n{headers}", head.method, head.target)
});
(format!("http://{addr}"), handle)
}
async fn exchange(
request: &str,
endpoint: String,
guest_port: u16,
auth: &Arc<ProxyAuth>,
) -> (String, Vec<ForwardEvent>) {
let (mut client, server) = tokio::io::duplex(64 * 1024);
let spec = ForwardSpec::new(
"127.0.0.1:8080".parse().expect("an addr"),
guest_port,
endpoint,
);
let http = ForwardClient::new().expect("a client");
let auth = auth.clone();
let served = tokio::spawn(async move {
let mut events = Vec::new();
let result =
serve_connection(server, &spec, &auth, &http, |event| events.push(event)).await;
(result, events)
});
client
.write_all(request.as_bytes())
.await
.expect("the request is written");
client.flush().await.expect("flushed");
let (result, events) = served.await.expect("the task joins");
result.expect("the exchange completes");
let mut seen = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut client, &mut seen)
.await
.expect("the client reads the response");
(String::from_utf8_lossy(&seen).to_string(), events)
}
#[tokio::test]
async fn requests_name_the_local_listener_before_minting() {
for host in [
"",
"example.com:8080",
"localhost:8081",
"127.0.0.1",
"localhost:8080@elsewhere.example",
"user@localhost:8080",
"localhost:8080/path",
"localhost:8080\r\nHost: localhost:8080",
] {
let (auth, _) = auth_pair();
let request = if host.is_empty() {
"GET / HTTP/1.1\r\n\r\n".to_string()
} else {
format!("GET / HTTP/1.1\r\nHost: {host}\r\n\r\n")
};
let (response, events) =
exchange(&request, "http://127.0.0.1:1".to_string(), 8080, &auth).await;
assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"{host}: {response}"
);
assert_eq!(auth.mint_count(), 0, "{host}");
assert!(matches!(
events.as_slice(),
[ForwardEvent::Refused { status: 403, .. }]
));
}
}
#[test]
fn host_authorities_match_the_bound_address_and_port() {
let bind = "127.0.0.1:8080".parse().expect("an address");
for host in [
"localhost:8080",
"LOCALHOST:8080",
"127.0.0.1:8080",
"[::1]:8080",
] {
assert!(
local_host_matches(&[("Host".into(), host.into())], bind),
"{host}"
);
}
assert!(local_host_matches(
&[("Host".into(), "localhost".into())],
"127.0.0.1:80".parse().unwrap()
));
for host in ["localhost", "localhost:8081", "localhost:0"] {
assert!(!local_host_matches(&[("Host".into(), host.into())], bind));
}
assert!(!local_host_matches(
&[("Host".into(), "localhost:invalid".into())],
bind
));
assert!(!local_host_matches(
&[("Host".into(), "localhost:65536".into())],
bind
));
assert!(!local_host_matches(
&[("Host".into(), "localhost:65536".into())],
"127.0.0.1:80".parse().unwrap()
));
assert!(!local_host_matches(
&[("Host".into(), "localhost:8080".into())],
"192.0.2.1:8080".parse().unwrap()
));
assert!(local_host_matches(
&[("Host".into(), "192.0.2.1:8080".into())],
"192.0.2.1:8080".parse().unwrap()
));
}
#[tokio::test]
async fn an_ordinary_get_reaches_the_guest_carrying_both_minted_headers() {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello").await;
let (auth, minter) = auth_pair();
let (seen, events) = exchange(
"GET /index.html HTTP/1.1\r\nhost: localhost:8080\r\nuser-agent: probe\r\n\r\n",
endpoint,
8080,
&auth,
)
.await;
let request = upstream_saw.await.expect("the upstream task joins");
assert!(request.starts_with("GET /index.html"), "{request}");
assert!(
request.contains(&format!(
"{}: jwe-0",
PROXY_AUTH_HEADER.to_ascii_lowercase()
)),
"the minted auth header did not reach upstream: {request}"
);
assert!(
request.contains(&format!("{}: 8080", PROXY_PORT_HEADER.to_ascii_lowercase())),
"the port header did not name the guest port: {request}"
);
assert!(request.contains("user-agent: probe"), "{request}");
assert!(seen.starts_with("HTTP/1.1 200 OK\r\n"), "{seen}");
assert!(seen.ends_with("hello"), "{seen}");
assert!(
matches!(
events.as_slice(),
[ForwardEvent::Forwarded {
status: 200,
upgraded: false
}]
),
"{events:?}"
);
let requested = minter.requested();
assert!(
requested.iter().any(|ports| ports.contains(&8080)),
"the mint never asked for port 8080: {requested:?}"
);
}
#[tokio::test]
async fn the_caller_cannot_smuggle_its_own_proxy_headers_upstream() {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok").await;
let (auth, _minter) = auth_pair();
let request = format!(
"GET / HTTP/1.1\r\nhost: localhost:8080\r\n{PROXY_AUTH_HEADER}: forged-jwe\r\n\
{PROXY_PORT_HEADER}: 1\r\n\r\n"
);
let (_seen, _events) = exchange(&request, endpoint, 8080, &auth).await;
let saw = upstream_saw.await.expect("the upstream task joins");
assert!(
!saw.contains("forged-jwe"),
"a caller's forged credential was forwarded: {saw}"
);
assert!(
saw.contains(&format!("{}: 8080", PROXY_PORT_HEADER.to_ascii_lowercase())),
"the minted port header should be the only one: {saw}"
);
assert!(
!saw.contains("host: localhost:8080"),
"the local host header must not override the endpoint's: {saw}"
);
}
#[tokio::test]
async fn a_scope_refusal_is_answered_with_the_sentence_that_names_it() {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n").await;
let (auth, _minter) = auth_pair();
let (seen, events) = exchange(
"GET / HTTP/1.1\r\nhost: localhost:8080\r\n\r\n",
endpoint,
8080,
&auth,
)
.await;
let _ = upstream_saw.await;
assert!(seen.starts_with("HTTP/1.1 403 Forbidden\r\n"), "{seen}");
assert!(seen.contains("scope does not cover"), "{seen}");
match events.as_slice() {
[
ForwardEvent::Refused {
status: 403,
explanation,
},
] => {
assert!(explanation.contains("8080"), "{explanation}");
}
other => panic!("expected one 403 refusal, got {other:?}"),
}
}
#[tokio::test]
async fn a_dead_guest_server_is_reported_as_502_rather_than_as_an_auth_problem() {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\n\r\n").await;
let (auth, _minter) = auth_pair();
let (seen, events) = exchange(
"GET / HTTP/1.1\r\nhost: localhost:8080\r\n\r\n",
endpoint,
5432,
&auth,
)
.await;
let _ = upstream_saw.await;
assert!(seen.contains("nothing listening on port 5432"), "{seen}");
assert!(
matches!(
events.as_slice(),
[ForwardEvent::Refused { status: 502, .. }]
),
"{events:?}"
);
}
#[tokio::test]
async fn the_guests_own_404_is_relayed_and_not_reinterpreted() {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 404 Not Found\r\ncontent-length: 9\r\n\r\nno-route!").await;
let (auth, _minter) = auth_pair();
let (seen, events) = exchange(
"GET /nope HTTP/1.1\r\nhost: localhost:8080\r\n\r\n",
endpoint,
8080,
&auth,
)
.await;
let _ = upstream_saw.await;
assert!(seen.starts_with("HTTP/1.1 404 Not Found\r\n"), "{seen}");
assert!(seen.ends_with("no-route!"), "{seen}");
assert!(
matches!(
events.as_slice(),
[ForwardEvent::Forwarded { status: 404, .. }]
),
"{events:?}"
);
}
#[tokio::test]
async fn a_post_body_is_forwarded_and_the_response_length_is_re_derived() {
let (endpoint, upstream_saw) = upstream(
"HTTP/1.1 200 OK\r\ncontent-length: 3\r\ntransfer-encoding: identity\r\n\r\nack",
)
.await;
let (auth, _minter) = auth_pair();
let (seen, _events) = exchange(
"POST /submit HTTP/1.1\r\nhost: localhost:8080\r\ncontent-length: 7\r\n\r\npayload",
endpoint,
8080,
&auth,
)
.await;
let saw = upstream_saw.await.expect("the upstream task joins");
assert!(saw.starts_with("POST /submit"), "{saw}");
assert!(saw.contains("content-length: 7"), "{saw}");
assert_eq!(
seen.matches("content-length:").count(),
1,
"exactly one framing header should be written: {seen}"
);
assert!(seen.contains("content-length: 3\r\n"), "{seen}");
assert!(
!seen.to_ascii_lowercase().contains("transfer-encoding"),
"the upstream framing header must not be copied: {seen}"
);
assert!(seen.ends_with("ack"), "{seen}");
}
#[tokio::test]
async fn a_101_relays_the_handshake_and_then_stops_parsing() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
let addr = listener.local_addr().expect("bound");
let guest = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("one connection");
let _ = read_request_head(&mut socket)
.await
.expect("a request")
.expect("present");
socket
.write_all(
b"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\n\
connection: upgrade\r\nsec-websocket-protocol: my-app-protocol\r\n\r\n",
)
.await
.expect("the handshake is written");
socket.flush().await.expect("flushed");
let mut frame = [0_u8; 5];
tokio::io::AsyncReadExt::read_exact(&mut socket, &mut frame)
.await
.expect("the client's bytes arrive");
socket
.write_all(b"PONG")
.await
.expect("the answer is written");
socket.flush().await.expect("flushed");
frame.to_vec()
});
let (auth, _minter) = auth_pair();
let (mut client, server) = tokio::io::duplex(64 * 1024);
let spec = ForwardSpec::new(
"127.0.0.1:8080".parse().expect("an addr"),
8080,
format!("http://{addr}"),
);
let http = ForwardClient::new().expect("a client");
let served = tokio::spawn(async move {
let mut events = Vec::new();
let result =
serve_connection(server, &spec, &auth, &http, |event| events.push(event)).await;
(result, events)
});
client
.write_all(
b"GET /socket HTTP/1.1\r\nhost: localhost:8080\r\nupgrade: websocket\r\n\
connection: upgrade\r\nsec-websocket-protocol: my-app-protocol\r\n\r\n",
)
.await
.expect("the handshake request is written");
client.flush().await.expect("flushed");
let mut head = Vec::new();
let mut byte = [0_u8; 1];
while !head.ends_with(b"\r\n\r\n") {
tokio::io::AsyncReadExt::read_exact(&mut client, &mut byte)
.await
.expect("the handshake response arrives");
head.push(byte[0]);
}
let head = String::from_utf8_lossy(&head).to_string();
assert!(
head.starts_with("HTTP/1.1 101 Switching Protocols\r\n"),
"{head}"
);
assert!(
head.to_ascii_lowercase()
.contains("sec-websocket-protocol: my-app-protocol"),
"the guest's negotiated subprotocol must reach the client: {head}"
);
client
.write_all(b"PING!")
.await
.expect("a frame is written");
client.flush().await.expect("flushed");
let mut answer = [0_u8; 4];
tokio::io::AsyncReadExt::read_exact(&mut client, &mut answer)
.await
.expect("the guest's answer arrives");
assert_eq!(&answer, b"PONG");
drop(client);
let guest_saw = guest.await.expect("the guest task joins");
assert_eq!(
&guest_saw, b"PING!",
"the client's bytes reached the guest unframed"
);
let (result, events) = served.await.expect("the task joins");
result.expect("the splice ends cleanly");
assert!(
events.iter().any(|event| matches!(
event,
ForwardEvent::Forwarded {
status: 101,
upgraded: true
}
)),
"{events:?}"
);
}
#[tokio::test]
async fn every_exchange_mints_through_the_cache_rather_than_capturing_headers_once() {
let (auth, minter) = auth_pair();
for _ in 0..2 {
let (endpoint, upstream_saw) =
upstream("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok").await;
let (_seen, _events) = exchange(
"GET / HTTP/1.1\r\nhost: localhost:8080\r\n\r\n",
endpoint,
8080,
&auth,
)
.await;
let saw = upstream_saw.await.expect("the upstream task joins");
assert!(
saw.contains(&PROXY_AUTH_HEADER.to_ascii_lowercase()),
"every hop carries the minted credential: {saw}"
);
}
assert_eq!(
auth.mint_count(),
1,
"the second hop should reuse the cached token rather than re-mint"
);
assert_eq!(minter.requested().len(), 1);
}
#[tokio::test]
async fn a_chunked_request_body_is_refused_by_name_rather_than_truncated() {
let (mut client, mut server) = tokio::io::duplex(4096);
client
.write_all(b"POST /up HTTP/1.1\r\nhost: local\r\ntransfer-encoding: chunked\r\n\r\n")
.await
.expect("written");
client.flush().await.expect("flushed");
let error = read_request_head(&mut server)
.await
.expect_err("chunked is refused");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains("chunked"), "{error}");
}
#[tokio::test]
async fn a_connection_that_closes_before_sending_anything_is_not_an_error() {
let (client, mut server) = tokio::io::duplex(4096);
drop(client);
let head = read_request_head(&mut server)
.await
.expect("an empty connection is fine");
assert!(head.is_none());
}
#[tokio::test]
async fn an_unterminated_request_head_is_capped_rather_than_buffered_without_bound() {
let (mut client, mut server) = tokio::io::duplex(1024 * 1024);
let writer = tokio::spawn(async move {
let filler = vec![b'x'; MAX_REQUEST_HEAD_BYTES + 1024];
let _ = client.write_all(b"GET / HTTP/1.1\r\nx: ").await;
let _ = client.write_all(&filler).await;
let _ = client.flush().await;
});
let error = read_request_head(&mut server)
.await
.expect_err("the cap fires");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains("without terminating"), "{error}");
writer.abort();
}
#[test]
fn only_a_101_counts_as_an_upgrade() {
assert!(is_upgrade(101));
assert!(!is_upgrade(200));
assert!(!is_upgrade(426));
}
#[test]
fn a_bare_endpoint_host_is_read_as_https() {
assert_eq!(
upstream_url("vm-abc.example.aws", "/index.html"),
"https://vm-abc.example.aws/index.html"
);
}
#[test]
fn an_explicit_scheme_is_left_alone() {
assert_eq!(
upstream_url("https://vm-abc.example.aws/", "/a"),
"https://vm-abc.example.aws/a"
);
assert_eq!(
upstream_url("http://127.0.0.1:9000", "/a"),
"http://127.0.0.1:9000/a"
);
}
#[test]
fn a_target_without_a_leading_slash_still_forms_one_path() {
assert_eq!(
upstream_url("vm.example", "index.html"),
"https://vm.example/index.html"
);
}
#[test]
fn the_two_proxy_headers_are_never_copied_from_the_caller() {
assert!(!forwards_request_header("x-aws-proxy-auth"));
assert!(!forwards_request_header("X-AWS-PROXY-AUTH"));
assert!(!forwards_request_header("x-aws-proxy-port"));
assert!(!forwards_request_header("Host"));
assert!(forwards_request_header("user-agent"));
assert!(forwards_request_header("sec-websocket-protocol"));
}
#[test]
fn the_refusal_wording_separates_a_scope_mistake_from_a_dead_server() {
let scope = refusal_explanation(403, 8080).expect("403 explains itself");
assert!(scope.contains("scope does not cover"), "{scope}");
assert!(scope.contains("8080"), "{scope}");
let dead = refusal_explanation(502, 8080).expect("502 explains itself");
assert!(dead.contains("nothing listening"), "{dead}");
assert!(dead.contains("8080"), "{dead}");
assert!(
refusal_explanation(401, 8080).is_some(),
"401 is worth naming"
);
assert!(
refusal_explanation(200, 8080).is_none(),
"a success explains nothing"
);
assert!(
refusal_explanation(404, 8080).is_none(),
"the guest's own 404 is not ours"
);
}
#[tokio::test]
async fn a_bind_collision_names_the_address_and_suggests_another_port() {
let held = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
let addr = held.local_addr().expect("a bound address");
let spec = ForwardSpec::new(addr, 8080, "vm.example");
let error = bind(&spec).await.expect_err("the address is taken");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
assert!(error.to_string().contains(&addr.to_string()), "{error}");
assert!(
error.to_string().contains("different local port"),
"{error}"
);
}
#[tokio::test]
async fn a_local_error_response_is_a_readable_http_message() {
let (mut client, mut server) = tokio::io::duplex(4096);
let body = refusal_explanation(403, 8080).expect("wording");
write_local_error(&mut server, 403, &body)
.await
.expect("the response is written");
drop(server);
let mut got = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut client, &mut got)
.await
.expect("the client reads it");
let text = String::from_utf8(got).expect("utf-8");
assert!(text.starts_with("HTTP/1.1 403 Forbidden\r\n"), "{text}");
assert!(text.contains("content-length: "), "{text}");
assert!(text.ends_with(&body), "{text}");
}
#[tokio::test]
async fn splice_copies_both_directions_and_reports_the_byte_counts() {
let (mut left_near, mut left_far) = tokio::io::duplex(4096);
let (mut right_near, mut right_far) = tokio::io::duplex(4096);
let pump = tokio::spawn(async move { splice(&mut left_far, &mut right_near).await });
left_near.write_all(b"to-guest").await.expect("write");
left_near.shutdown().await.expect("half close");
right_far.write_all(b"from-guest!").await.expect("write");
right_far.shutdown().await.expect("half close");
let mut to_guest = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut right_far, &mut to_guest)
.await
.expect("read");
let mut from_guest = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut left_near, &mut from_guest)
.await
.expect("read");
let (a_to_b, b_to_a) = pump.await.expect("the task joins").expect("a clean splice");
assert_eq!(to_guest, b"to-guest");
assert_eq!(from_guest, b"from-guest!");
assert_eq!(a_to_b, 8);
assert_eq!(b_to_a, 11);
}
}