use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use futures_util::StreamExt as _;
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::{BodyExt as _, Full, Limited, StreamBody};
use hyper::body::{Bytes, Frame, Incoming};
use hyper::service::service_fn;
use hyper_util::rt::{TokioIo, TokioTimer};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
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())
.no_gzip()
.no_brotli()
.no_zstd()
.no_deflate()
.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>(
local: S,
spec: &ForwardSpec,
auth: Option<&Arc<ProxyAuth>>,
client: &ForwardClient,
mut on_event: F,
) -> Result<(), Error>
where
S: LocalStream,
F: FnMut(ForwardEvent),
{
let (events, mut pending_events) = mpsc::unbounded_channel();
let upgraded = Mutex::new(None);
let answered = AtomicBool::new(false);
let service = service_fn(|request| {
let result = forward_request(request, spec, auth, client, &events, &upgraded);
let answered = &answered;
Box::pin(async move {
let response = result.await;
if response.is_ok() {
answered.store(true, Ordering::Relaxed);
}
response
})
});
let mut builder = hyper::server::conn::http1::Builder::new();
builder
.timer(TokioTimer::new())
.header_read_timeout(HEADER_TIMEOUT)
.max_buf_size(MAX_REQUEST_HEAD_BYTES);
let mut connection = builder.serve_connection(TokioIo::new(local), service);
let result = {
let polling = futures_util::future::poll_fn(|cx| connection.poll_without_shutdown(cx));
tokio::pin!(polling);
loop {
tokio::select! {
result = &mut polling => break result,
Some(event) = pending_events.recv() => on_event(event),
}
}
};
while let Ok(event) = pending_events.try_recv() {
on_event(event);
}
let parts = connection.into_parts();
if let Err(error) = result {
let idle =
error.is_timeout() && answered.load(Ordering::Relaxed) && parts.read_buf.is_empty();
if !idle {
return Err(Error::new(
ErrorKind::Protocol,
format!("the local HTTP connection failed: {error}"),
));
}
}
let upstream = upgraded.lock().expect("upgrade lock poisoned").take();
if let Some(mut upstream) = upstream {
let (read, write) = tokio::io::split(parts.io.into_inner());
let read = tokio::io::AsyncReadExt::chain(std::io::Cursor::new(parts.read_buf), read);
let mut local = tokio::io::join(read, write);
splice(&mut local, &mut upstream).await?;
}
Ok(())
}
const HEADER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const MAX_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
type ForwardBody = UnsyncBoxBody<Bytes, Error>;
fn full_body(body: Bytes) -> ForwardBody {
Full::new(body)
.map_err(|never| match never {})
.boxed_unsync()
}
fn local_response(status: u16, text: String) -> http::Response<ForwardBody> {
let mut response = http::Response::new(full_body(Bytes::from(text)));
*response.status_mut() =
http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::BAD_GATEWAY);
response.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("text/plain; charset=utf-8"),
);
response.headers_mut().insert(
http::header::CONNECTION,
http::HeaderValue::from_static("close"),
);
response
}
fn strip_hop_headers(headers: &mut http::HeaderMap, upgrade: bool) {
let nominated = headers
.get_all(http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.filter_map(|name| http::HeaderName::from_bytes(name.trim().as_bytes()).ok())
.collect::<Vec<_>>();
for name in nominated {
if !upgrade || name != http::header::UPGRADE {
headers.remove(name);
}
}
for name in [
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"te",
"trailer",
"upgrade",
] {
if !upgrade || (name != "connection" && name != "upgrade") {
headers.remove(name);
}
}
if upgrade {
headers.insert(
http::header::CONNECTION,
http::HeaderValue::from_static("upgrade"),
);
}
}
async fn forward_request(
request: http::Request<Incoming>,
spec: &ForwardSpec,
auth: Option<&Arc<ProxyAuth>>,
client: &ForwardClient,
events: &mpsc::UnboundedSender<ForwardEvent>,
upgraded: &Mutex<Option<reqwest::Upgraded>>,
) -> Result<http::Response<ForwardBody>, Error> {
let headers = request
.headers()
.get_all(http::header::HOST)
.iter()
.map(|value| ("host".to_string(), value.to_str().unwrap_or("").to_string()))
.collect::<Vec<_>>();
if !local_host_matches(&headers, spec.bind) {
let explanation = "the request Host must name the local forward and its port".to_string();
let _ = events.send(ForwardEvent::Refused {
status: 403,
explanation: explanation.clone(),
});
return Ok(local_response(403, explanation));
}
if request
.headers()
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|length| length > MAX_REQUEST_BODY_BYTES as u64)
{
return Ok(local_response(
413,
"the local request body exceeds 16 MiB".into(),
));
}
let (mut head, body) = request.into_parts();
let body = match tokio::time::timeout(
HEADER_TIMEOUT,
Limited::new(body, MAX_REQUEST_BODY_BYTES).collect(),
)
.await
{
Ok(Ok(body)) => body.to_bytes(),
Ok(Err(error)) if error.is::<http_body_util::LengthLimitError>() => {
return Ok(local_response(
413,
"the local request body exceeds 16 MiB".into(),
));
}
Ok(Err(_)) => {
return Ok(local_response(
400,
"the local request body is malformed".into(),
));
}
Err(_) => {
return Ok(local_response(
408,
"the local request body timed out".into(),
));
}
};
let target = head
.uri
.path_and_query()
.map_or("/", |target| target.as_str());
let (minted, url) = match auth {
Some(auth) => (
hop_headers(auth, spec.guest_port).await?,
upstream_url(&spec.endpoint, target),
),
None => (
Vec::new(),
direct_url(&spec.endpoint, spec.guest_port, target)?,
),
};
let offered_upgrade = head.headers.contains_key(http::header::UPGRADE);
strip_hop_headers(&mut head.headers, offered_upgrade);
let mut request = client.inner.request(head.method, &url);
for (name, value) in &head.headers {
if forwards_request_header(name.as_str())
&& name != http::header::TRANSFER_ENCODING
&& name != http::header::CONTENT_LENGTH
{
request = request.header(name, value);
}
}
for (name, value) in &minted {
request = request.header(name, value);
}
let response = tokio::time::timeout(spec.exchange_timeout, request.body(body).send())
.await
.map_err(|_| Error::new(ErrorKind::Unexpected, "the guest's response head timed out"))?
.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("forwarding the request to the endpoint failed: {err}"),
)
})?;
let status = response.status();
if let Some(explanation) = refusal_explanation(status.as_u16(), spec.guest_port) {
let _ = events.send(ForwardEvent::Refused {
status: status.as_u16(),
explanation: explanation.clone(),
});
return Ok(local_response(status.as_u16(), explanation));
}
let mut headers = response.headers().clone();
let is_upgrade = is_upgrade(status.as_u16());
strip_hop_headers(&mut headers, is_upgrade);
let event = ForwardEvent::Forwarded {
status: status.as_u16(),
upgraded: is_upgrade,
};
let body = if is_upgrade {
let stream = response.upgrade().await.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the endpoint answered 101 but did not upgrade: {err}"),
)
})?;
*upgraded.lock().expect("upgrade lock poisoned") = Some(stream);
let _ = events.send(event);
full_body(Bytes::new())
} else if response.content_length() == Some(0) {
let _ = events.send(event);
full_body(Bytes::new())
} else {
streaming_body(response, events.clone(), event, spec.exchange_timeout)
};
let mut response = http::Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
}
fn streaming_body(
response: reqwest::Response,
events: mpsc::UnboundedSender<ForwardEvent>,
event: ForwardEvent,
timeout: std::time::Duration,
) -> ForwardBody {
let remaining = response.content_length();
let stream = response.bytes_stream().boxed();
let state = (stream, remaining, Some(event), events, timeout);
let frames = futures_util::stream::unfold(
state,
|(mut stream, mut remaining, mut event, events, timeout)| async move {
match tokio::time::timeout(timeout, stream.next()).await {
Ok(Some(Ok(chunk))) => {
if let Some(left) = remaining.as_mut() {
*left = left.saturating_sub(chunk.len() as u64);
if *left == 0
&& let Some(event) = event.take()
{
let _ = events.send(event);
}
}
Some((
Ok(Frame::data(chunk)),
(stream, remaining, event, events, timeout),
))
}
Ok(Some(Err(error))) => Some((
Err(Error::new(
ErrorKind::Unexpected,
format!("reading the guest's response body failed: {error}"),
)),
(stream, remaining, None, events, timeout),
)),
Err(_) => Some((
Err(Error::new(
ErrorKind::Unexpected,
"reading the guest's response body timed out",
)),
(stream, remaining, None, events, timeout),
)),
Ok(None) => {
if let Some(event) = event {
let _ = events.send(event);
}
None
}
}
},
);
StreamBody::new(frames).boxed_unsync()
}
const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
pub async fn write_local_error<W>(stream: &mut W, status: u16, body: &str) -> Result<(), Error>
where
W: AsyncWrite + Unpin + ?Sized,
{
let reader =
std::io::Cursor::new(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
let io = tokio::io::join(reader, stream);
let service = service_fn(|_| {
std::future::ready(Ok::<_, std::convert::Infallible>(local_response(
status,
body.to_string(),
)))
});
hyper::server::conn::http1::Builder::new()
.half_close(true)
.serve_connection(TokioIo::new(io), service)
.await
.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("could not answer the local client: {err}"),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use microvms_app::session::proxy::DEFAULT_AGENT_PORT;
use microvms_app::testing::{CountingMinter, TestClock};
use tokio::io::AsyncWriteExt as _;
struct RequestHead {
method: String,
target: String,
headers: Vec<(String, String)>,
}
async fn read_request_head<S: AsyncRead + Unpin>(
local: &mut S,
) -> Result<Option<RequestHead>, std::io::Error> {
use tokio::io::AsyncReadExt as _;
let mut raw = Vec::new();
while !raw.ends_with(b"\r\n\r\n") {
let byte = match local.read_u8().await {
Ok(byte) => byte,
Err(err) if raw.is_empty() && err.kind() == std::io::ErrorKind::UnexpectedEof => {
return Ok(None);
}
Err(err) => return Err(err),
};
raw.push(byte);
}
let text = String::from_utf8_lossy(&raw);
let mut lines = text.split("\r\n");
let mut first = lines.next().unwrap().split_whitespace();
let method = first.next().unwrap().to_string();
let target = first.next().unwrap().to_string();
let headers: Vec<(String, String)> = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.to_string(), value.trim().to_string()))
.collect();
let length = 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; length];
local.read_exact(&mut body).await?;
Ok(Some(RequestHead {
method,
target,
headers,
}))
}
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
.replacen("HTTP/1.1\r\n", "HTTP/1.1\r\nconnection: close\r\n", 1)
.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);
}
#[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);
}
#[test]
fn hop_headers_preserve_only_negotiated_upgrades() {
for upgrade in [false, true] {
let mut headers = http::HeaderMap::new();
for (name, value) in [
("connection", "x-private, upgrade"),
("upgrade", "example"),
("x-private", "one hop only"),
("x-end-to-end", "preserved"),
("keep-alive", "timeout=5"),
("proxy-connection", "keep-alive"),
("transfer-encoding", "chunked"),
("te", "trailers"),
("trailer", "x-checksum"),
] {
headers.insert(
http::HeaderName::from_static(name),
http::HeaderValue::from_static(value),
);
}
strip_hop_headers(&mut headers, upgrade);
assert_eq!(headers["x-end-to-end"], "preserved");
for name in [
"x-private",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"te",
"trailer",
] {
assert!(
!headers.contains_key(name),
"{name} crossed a hop (upgrade={upgrade})"
);
}
if upgrade {
assert_eq!(headers["connection"], "upgrade");
assert_eq!(headers["upgrade"], "example");
} else {
assert!(!headers.contains_key("connection"));
assert!(!headers.contains_key("upgrade"));
}
}
}
}