Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
Co-Authored-By: John Preston <17900494+john-preston@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-23 03:12:11 +03:00
parent 8dbd24b11b
commit 1029703c2c
58 changed files with 7460 additions and 2646 deletions
+69
View File
@@ -0,0 +1,69 @@
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;
use bytes::Bytes;
use hyper::body::{Body, Frame, SizeHint};
use parking_lot::Mutex;
use super::{BoxError, HttpBody};
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
pub(super) struct RequestActivity {
last_activity: Arc<Mutex<Instant>>,
}
impl RequestActivity {
/// Starts activity accounting for one HTTP request.
pub(super) fn begin(last_activity: Arc<Mutex<Instant>>) -> Self {
*last_activity.lock() = Instant::now();
Self { last_activity }
}
}
impl Drop for RequestActivity {
fn drop(&mut self) {
*self.last_activity.lock() = Instant::now();
}
}
/// Response body wrapper that refreshes activity while downstream data progresses.
pub(super) struct ActivityBody {
inner: HttpBody,
activity: RequestActivity,
}
impl ActivityBody {
/// Binds one response body to its request activity guard.
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
Self {
inner,
activity,
}
}
}
impl Body for ActivityBody {
type Data = Bytes;
type Error = BoxError;
fn poll_frame(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let result = Pin::new(&mut self.inner).poll_frame(context);
if result.is_ready() {
*self.activity.last_activity.lock() = Instant::now();
}
result
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Limited};
use hyper::body::{Body as _, Incoming};
use hyper::Request;
use crate::web::manager::WebProcessRuntime;
/// Collected carrier request retaining its process-wide body reservation.
pub(super) struct CollectedBody {
/// Request head reconstructed without the consumed network body.
pub(super) request: Request<Empty<Bytes>>,
/// Fully collected bounded carrier payload.
pub(super) body: Bytes,
/// Byte-budget reservation held through request processing.
pub(super) _body_budget: tokio::sync::OwnedSemaphorePermit,
}
// Keep rejected requests inline to avoid attacker-controlled allocations on invalid bodies.
#[allow(clippy::large_enum_variant)]
/// Body collection failure with sanitized request context when decoy routing is safe.
pub(super) enum CollectBodyError {
/// The body shape, size, or deadline failed after retaining the request head.
Invalid(Request<Empty<Bytes>>),
/// Process-wide body reader or byte capacity is temporarily exhausted.
Limit,
}
/// Collects one bounded carrier body under reader, byte, and deadline ownership.
pub(super) async fn collect_body(
request: Request<Incoming>,
runtime: &WebProcessRuntime,
limit: usize,
allow_empty: bool,
) -> Result<CollectedBody, CollectBodyError> {
let exceeds_limit = request.body().size_hint().lower() > limit as u64
|| request
.body()
.size_hint()
.upper()
.is_some_and(|upper| upper > limit as u64);
let (parts, body) = request.into_parts();
if exceeds_limit {
return Err(CollectBodyError::Invalid(Request::from_parts(
parts,
Empty::new(),
)));
}
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
return Err(CollectBodyError::Limit);
};
let body_timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.body_secs,
);
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
Ok(Ok(body)) => body.to_bytes(),
_ => {
return Err(CollectBodyError::Invalid(Request::from_parts(
parts,
Empty::new(),
)));
}
};
drop(reader_budget);
let request = Request::from_parts(parts, Empty::new());
if !allow_empty && body.is_empty() {
return Err(CollectBodyError::Invalid(request));
}
Ok(CollectedBody {
request,
body,
_body_budget: body_budget,
})
}
+284
View File
@@ -0,0 +1,284 @@
use std::error::Error;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::{Method, Request, StatusCode, Uri};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
use super::{
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
insert_header,
};
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
use crate::web::manager::WebProcessRuntime;
/// Serves the configured ordinary site after optionally removing carrier material.
pub(super) async fn serve_decoy<B>(
mut request: Request<B>,
vhost: Arc<WebRuntimeVhost>,
sanitize_transport: bool,
runtime: &WebProcessRuntime,
) -> HttpResponse
where
B: hyper::body::Body<Data = Bytes> + Send + 'static,
B::Error: Error + Send + Sync + 'static,
{
if sanitize_transport {
sanitize_transport_request(&mut request);
}
let (parts, body) = request.into_parts();
let body = if sanitize_transport {
Empty::<Bytes>::new()
.map_err(|never| -> BoxError { match never {} })
.boxed_unsync()
} else {
body.map_err(|error| -> BoxError { Box::new(error) })
.boxed_unsync()
};
let request = Request::from_parts(parts, body);
match &vhost.decoy {
WebRuntimeDecoy::StaticDirectory(site) => serve_static(request, site),
WebRuntimeDecoy::HttpUpstream { addr, authority } => {
proxy_to_upstream(
request,
*addr,
authority,
Duration::from_secs(vhost.decoy_header_secs),
runtime,
)
.await
}
}
}
fn serve_static<B>(request: Request<B>, site: &crate::config::WebStaticSite) -> HttpResponse {
if !matches!(*request.method(), Method::GET | Method::HEAD) {
return static_entry(request, site, None, StatusCode::NOT_FOUND);
}
let path = request.uri().path();
let resolved = resolve_static_path(path, site);
let status = if resolved.is_some() {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
};
static_entry(request, site, resolved, status)
}
fn static_entry<B>(
request: Request<B>,
site: &crate::config::WebStaticSite,
route: Option<&str>,
status: StatusCode,
) -> HttpResponse {
let fallback = format!("/{}", site.index);
let not_found = site.assets.contains_key("/404.html").then_some("/404.html");
let route = route.or(not_found).unwrap_or(&fallback);
let Some(asset) = site.assets.get(route) else {
return generic_not_found();
};
let not_modified = status == StatusCode::OK
&& request
.headers()
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
== Some(asset.etag.as_str());
let response_body = if request.method() == Method::HEAD || not_modified {
Bytes::new()
} else {
asset.body.clone()
};
let mut response = full_response(
if not_modified {
StatusCode::NOT_MODIFIED
} else {
status
},
response_body,
);
insert_header(&mut response, header::CONTENT_TYPE, asset.content_type);
insert_header(&mut response, header::ETAG, &asset.etag);
insert_header(
&mut response,
header::CONTENT_LENGTH,
&asset.body.len().to_string(),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
if status.is_client_error() || request.uri().query().is_some() {
HeaderValue::from_static("no-store")
} else {
HeaderValue::from_static("public, max-age=300")
},
);
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'; style-src 'self'; img-src 'self'; worker-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"),
);
response.headers_mut().insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
response
}
fn resolve_static_path<'a>(
path: &str,
site: &'a crate::config::WebStaticSite,
) -> Option<&'a str> {
if !path.starts_with('/')
|| path.contains('\\')
|| path.contains("//")
|| path.split('/').any(|part| matches!(part, "." | ".."))
{
return None;
}
let root;
let route = if path == "/" {
root = format!("/{}", site.index);
root.as_str()
} else {
path
};
if site.assets.contains_key(route) {
return site.assets.get_key_value(route).map(|(key, _)| key.as_str());
}
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
return Some("/favicon.svg");
}
if !route.rsplit('/').next().unwrap_or_default().contains('.') {
let html = format!("{route}.html");
return site
.assets
.get_key_value(&html)
.map(|(key, _)| key.as_str());
}
None
}
async fn proxy_to_upstream(
mut request: Request<HttpBody>,
addr: SocketAddr,
authority: &str,
header_timeout: Duration,
runtime: &WebProcessRuntime,
) -> HttpResponse {
remove_hop_by_hop(request.headers_mut());
if let Ok(host) = HeaderValue::from_str(authority) {
request.headers_mut().insert(header::HOST, host);
}
let path_and_query = request
.uri()
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let Ok(uri) = path_and_query.parse::<Uri>() else {
return bad_gateway();
};
*request.uri_mut() = uri;
let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => stream,
_ => return bad_gateway(),
};
let max_header_bytes = runtime
.active_generation()
.config()
.web
.limits
.max_header_bytes;
let mut builder = hyper::client::conn::http1::Builder::new();
builder.max_buf_size(max_header_bytes);
let (mut sender, connection) = match tokio::time::timeout(
header_timeout,
builder.handshake(TokioIo::new(stream)),
)
.await
{
Ok(Ok(parts)) => parts,
_ => return bad_gateway(),
};
runtime.spawn_auxiliary(async move {
let _ = connection.await;
});
let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await
{
Ok(Ok(response)) => response,
_ => return bad_gateway(),
};
remove_hop_by_hop(response.headers_mut());
response.map(|body| {
body.map_err(|error| -> BoxError { Box::new(error) })
.boxed_unsync()
})
}
fn sanitize_transport_request<B>(request: &mut Request<B>) {
for name in [
header::AUTHORIZATION,
header::CONTENT_LENGTH,
header::CONTENT_TYPE,
header::UPGRADE,
HeaderName::from_static("sec-websocket-key"),
HeaderName::from_static("sec-websocket-protocol"),
HeaderName::from_static("sec-websocket-version"),
HeaderName::from_static("x-down-cursor"),
HeaderName::from_static("x-lane-id"),
HeaderName::from_static("x-up-seq"),
] {
request.headers_mut().remove(name);
}
request
.headers_mut()
.insert(header::CONNECTION, HeaderValue::from_static("close"));
}
fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) {
let nominated = headers
.get_all(header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.filter_map(|value| HeaderName::from_bytes(value.trim().as_bytes()).ok())
.collect::<Vec<_>>();
for name in nominated {
headers.remove(name);
}
for name in [
header::CONNECTION,
header::PROXY_AUTHENTICATE,
header::PROXY_AUTHORIZATION,
header::TE,
header::TRAILER,
header::TRANSFER_ENCODING,
header::UPGRADE,
HeaderName::from_static("keep-alive"),
HeaderName::from_static("proxy-connection"),
] {
headers.remove(name);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_resolver_rejects_rewritten_paths() {
let site = crate::config::WebStaticSite {
assets: std::collections::BTreeMap::new(),
index: "index.html".to_string(),
};
assert!(resolve_static_path("/../index.html", &site).is_none());
assert!(resolve_static_path("//index.html", &site).is_none());
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use base64::Engine as _;
use hyper::header;
use hyper::Request;
use ipnetwork::IpNetwork;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::config::{
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
};
use crate::web::manager::TokenHash;
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
let values = request.headers().get_all(header::HOST);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() {
return None;
}
let authority = value.parse::<hyper::http::uri::Authority>().ok()?;
if authority.port_u16().is_some_and(|port| port != 443) {
return None;
}
let host = value.strip_suffix(":443").unwrap_or(value);
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase())
{
return None;
}
Some(host)
}
/// Accepts one canonical forwarded client address from an explicitly trusted peer.
pub(super) fn client_ip<B>(
request: &Request<B>,
peer: SocketAddr,
source: WebClientIpSource,
trusted_proxy_cidrs: &[IpNetwork],
) -> Option<IpAddr> {
if !trusted_proxy_cidrs
.iter()
.any(|network| network.contains(peer.ip()))
{
return None;
}
let header_name = match source {
WebClientIpSource::XForwardedFor => "x-forwarded-for",
};
let values = request.headers().get_all(header_name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some()
|| value.is_empty()
|| value.trim() != value
|| value.contains(',')
{
return None;
}
let ip = value.parse::<IpAddr>().ok()?;
(ip.to_string() == value).then_some(ip)
}
/// Decodes an exact canonical bridge query without allocating credential strings.
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
let mut candidate = [0u8; 32];
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
return (candidate, false);
};
if value.len() != 43 {
return (candidate, false);
}
let mut decoded = [0u8; 32];
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode_slice(value, &mut decoded)
else {
return (candidate, false);
};
let mut canonical = [0u8; 43];
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode_slice(decoded, &mut canonical)
else {
return (candidate, false);
};
if decoded_len != decoded.len()
|| encoded_len != canonical.len()
|| !bool::from(canonical.ct_eq(value.as_bytes()))
{
return (candidate, false);
}
candidate = decoded;
(candidate, true)
}
/// Matches a capability in constant time across every profile of one virtual host.
pub(super) fn match_profile(
vhost: &WebRuntimeVhost,
candidate: &[u8; 32],
) -> Option<Arc<WebRuntimeProfile>> {
let mut matched = None;
for profile in &vhost.profiles {
if bool::from(profile.capability.ct_eq(candidate)) {
matched = Some(Arc::clone(profile));
}
}
matched
}
/// Validates and hashes one canonical bearer credential for map lookup.
pub(super) fn bearer_token_hash<B>(request: &Request<B>) -> Option<TokenHash> {
let values = request.headers().get_all(header::AUTHORIZATION);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1
{
return None;
}
let token = value.strip_prefix("Bearer ")?;
if token.len() != 43 {
return None;
}
let mut decoded = [0u8; 32];
let decoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode_slice(token, &mut decoded)
.ok()?;
let mut canonical = [0u8; 43];
let encoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode_slice(decoded, &mut canonical)
.ok()?;
(decoded_len == decoded.len()
&& encoded_len == canonical.len()
&& bool::from(canonical.ct_eq(token.as_bytes())))
.then(|| Sha256::digest(decoded).into())
}
/// Checks the exact carrier media type without accepting duplicate headers.
pub(super) fn binary_content_type<B>(request: &Request<B>) -> bool {
let values = request.headers().get_all(header::CONTENT_TYPE);
let mut values = values.iter();
let value = values.next().and_then(|value| value.to_str().ok());
values.next().is_none()
&& value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream"))
}
/// Parses one canonical unsigned decimal carrier sequence header.
pub(super) fn canonical_u64_header<B>(
request: &Request<B>,
name: &'static str,
) -> Option<u64> {
let values = request.headers().get_all(name);
let mut values = values.iter();
let value = values.next()?.to_str().ok()?;
if values.next().is_some()
|| value.is_empty()
|| value.starts_with('+')
|| (value.len() > 1 && value.starts_with('0'))
{
return None;
}
let parsed = value.parse::<u64>().ok()?;
(parsed.to_string() == value).then_some(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_bridge_query_rejects_aliases() {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
}
#[test]
fn host_and_forwarded_identity_require_canonical_single_values() {
let request = Request::builder()
.header(header::HOST, "proxy.example.com:443")
.header("x-forwarded-for", "192.0.2.10")
.body(())
.unwrap();
assert_eq!(
canonical_request_host(&request),
Some("proxy.example.com")
);
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
assert_eq!(
client_ip(
&request,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
),
Some("192.0.2.10".parse().unwrap())
);
let uppercase = Request::builder()
.header(header::HOST, "Proxy.Example.com")
.body(())
.unwrap();
assert!(canonical_request_host(&uppercase).is_none());
let appended = Request::builder()
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
.body(())
.unwrap();
assert!(
client_ip(
&appended,
"127.0.0.1:40000".parse().unwrap(),
WebClientIpSource::XForwardedFor,
&trusted,
)
.is_none()
);
}
#[test]
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
let request = Request::builder()
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header("x-up-seq", "17")
.body(())
.unwrap();
assert_eq!(
bearer_token_hash(&request),
Some(Sha256::digest([1u8; 32]).into())
);
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
let leading_zero = Request::builder()
.header("x-up-seq", "017")
.body(())
.unwrap();
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
}
}
+206
View File
@@ -0,0 +1,206 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use base64::Engine as _;
use bytes::Bytes;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use super::serve_connection;
use crate::config::{
ProxyConfig, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
};
use crate::maestro::generation::test_runtime_generation;
use crate::web::frame::{self, FrameType};
use crate::web::manager::WebProcessRuntime;
fn runtime_config(capability: [u8; 32]) -> ProxyConfig {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
capability,
max_sessions: 4,
max_streams: 16,
max_streams_per_session: 4,
});
let mut assets = BTreeMap::new();
assets.insert(
"/index.html".to_string(),
WebStaticAsset {
body: Bytes::from_static(b"<!doctype html><title>decoy</title>"),
content_type: "text/html; charset=utf-8",
etag: "\"test\"".to_string(),
},
);
let site = Arc::new(WebStaticSite {
assets,
index: "index.html".to_string(),
});
let vhost = Arc::new(WebRuntimeVhost {
host: "proxy.example.com".to_string(),
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
decoy_header_secs: 1,
profiles: vec![Arc::clone(&profile)],
});
let mut vhosts = BTreeMap::new();
vhosts.insert("proxy.example.com".to_string(), vhost);
vhosts.insert(
"other.example.com".to_string(),
Arc::new(WebRuntimeVhost {
host: "other.example.com".to_string(),
decoy: WebRuntimeDecoy::StaticDirectory(site),
decoy_header_secs: 1,
profiles: Vec::new(),
}),
);
let mut config = ProxyConfig::default();
config.web.enabled = true;
config.web.limits.max_bootstraps_per_ip = 1;
config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts,
profiles: vec![profile],
}));
config
}
async fn request(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
request: Vec<u8>,
) -> Vec<u8> {
let addr = listener.local_addr().unwrap();
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
let (server, peer) = accepted.unwrap();
let mut client = client.unwrap();
let permit = runtime.try_http_connection().unwrap();
let task = tokio::spawn(serve_connection(
server,
peer,
WebClientIpSource::XForwardedFor,
Arc::from(["127.0.0.1/32".parse().unwrap()]),
Arc::clone(runtime),
CancellationToken::new(),
permit,
));
client.write_all(&request).await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
task.await.unwrap();
response
}
fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
let separator = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.unwrap();
(&response[..separator], &response[separator + 4..])
}
fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
std::str::from_utf8(headers)
.unwrap()
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim()))
.unwrap()
}
#[tokio::test]
async fn https_carrier_bootstraps_and_closes_one_session() {
let capability = [7u8; 32];
let generation = test_runtime_generation(1, runtime_config(capability));
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let wrong_family = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let wrong_family_response = request(&listener, &runtime, wrong_family).await;
let (_, wrong_family_body) = split_response(&wrong_family_response);
assert!(!wrong_family_body
.windows(11)
.any(|value| value == b"bootstrap='"));
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (root_headers, root_body) = split_response(&root_response);
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap='")
.and_then(|(_, suffix)| suffix.split_once('\''))
.map(|(token, _)| token)
.unwrap();
assert_eq!(bootstrap.len(), 43);
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut wrong_host = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: other.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
wrong_host.extend_from_slice(&hello);
let wrong_host_response = request(&listener, &runtime, wrong_host).await;
assert!(wrong_host_response.starts_with(b"HTTP/1.1 404"));
let mut create = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
let create_retry = create.clone();
create.extend_from_slice(&hello);
let mut create_retry = create_retry;
create_retry.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
let (create_headers, create_body) = split_response(&create_response);
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https");
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
let session = response_header(create_headers, "x-session-token");
assert_eq!(session.len(), 43);
let replacement = test_runtime_generation(2, runtime_config(capability));
active_runtime.store(Arc::clone(&replacement));
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let retry_response = request(&listener, &runtime, create_retry).await;
let (retry_headers, retry_body) = split_response(&retry_response);
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(retry_headers, "x-session-token"), session);
assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[]));
let next_root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let next_root_response = request(&listener, &runtime, next_root).await;
let (_, next_root_body) = split_response(&next_root_response);
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let close_retry = close.clone();
let close_response = request(&listener, &runtime, close).await;
assert!(close_response.starts_with(b"HTTP/1.1 204"));
let close_retry_response = request(&listener, &runtime, close_retry).await;
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
replacement.stop_sessions().await;
replacement.stop_background_tasks().await;
}