WEB Bridge Sideband

This commit is contained in:
Alexey
2026-09-08 21:29:32 +03:00
parent 4ca7418442
commit 021ad1fe68
23 changed files with 1169 additions and 13 deletions
+153
View File
@@ -0,0 +1,153 @@
use std::net::IpAddr;
use std::sync::Arc;
use hyper::header;
use hyper::{Method, Request, StatusCode};
use super::body::{CollectBodyError, CollectedBody, RequestBody, collect_body};
use super::decoy::serve_decoy;
use super::response::{carrier_empty, service_unavailable};
use super::{HttpResponse, request_trace};
use crate::config::WebRuntimeVhost;
use crate::web::manager::{BridgeDiagnosticEvent, TokenHash, WebProcessRuntime};
use crate::web::trace::{TraceLifecycleEvent, TraceRoute};
const DIAGNOSTIC_BODY_LIMIT: usize = 64;
/// Handles one bounded generated-bridge diagnostic report.
pub(super) async fn handle(
request: Request<RequestBody>,
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
token_hash: TokenHash,
client_ip: IpAddr,
) -> HttpResponse {
if request.method() != Method::POST || !json_content_type(&request) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some((trace_session_id, profile, body_timeout)) =
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
else {
return serve_decoy(request, vhost, true, &runtime).await;
};
if let Some(trace) = request_trace(&request) {
trace.set_route(TraceRoute::Diagnostic);
trace.bind_profile(&profile, trace_session_id);
}
let CollectedBody {
request,
body,
_body_budget,
} = match collect_body(
request,
&runtime,
body_timeout,
DIAGNOSTIC_BODY_LIMIT,
false,
)
.await
{
Ok(result) => result,
Err(CollectBodyError::Limit) => return service_unavailable(),
Err(CollectBodyError::Invalid(request)) => {
return serve_decoy(request, vhost, true, &runtime).await;
}
};
let Some(event) = parse_event(&body) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
match runtime.claim_bridge_diagnostic(token_hash, &vhost.host, event) {
Ok(first) => {
if first {
runtime.trace().record_profile_lifecycle(
client_ip,
Some(trace_session_id),
&profile,
TraceLifecycleEvent::BridgeDiagnostic,
None,
Some(event.as_str()),
);
}
carrier_empty(StatusCode::NO_CONTENT)
}
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
}
}
fn json_content_type<B>(request: &Request<B>) -> bool {
let mut values = request.headers().get_all(header::CONTENT_TYPE).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/json"))
}
fn parse_event(body: &[u8]) -> Option<BridgeDiagnosticEvent> {
match body {
br#"{"v":1,"event":"runtime_started"}"# => Some(BridgeDiagnosticEvent::RuntimeStarted),
br#"{"v":1,"event":"status_posted"}"# => Some(BridgeDiagnosticEvent::StatusPosted),
br#"{"v":1,"event":"hello_received"}"# => Some(BridgeDiagnosticEvent::HelloReceived),
br#"{"v":1,"event":"boundary_timeout"}"# => Some(BridgeDiagnosticEvent::BoundaryTimeout),
br#"{"v":1,"event":"hello_timeout"}"# => Some(BridgeDiagnosticEvent::HelloTimeout),
br#"{"v":1,"event":"client_close_before_hello"}"# => {
Some(BridgeDiagnosticEvent::ClientCloseBeforeHello)
}
br#"{"v":1,"event":"document_unloaded_before_hello"}"# => {
Some(BridgeDiagnosticEvent::DocumentUnloadedBeforeHello)
}
br#"{"v":1,"event":"runtime_error_before_hello"}"# => {
Some(BridgeDiagnosticEvent::RuntimeErrorBeforeHello)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_parser_accepts_only_canonical_v1_documents() {
for (body, event) in [
(
br#"{"v":1,"event":"runtime_started"}"#.as_slice(),
BridgeDiagnosticEvent::RuntimeStarted,
),
(
br#"{"v":1,"event":"status_posted"}"#.as_slice(),
BridgeDiagnosticEvent::StatusPosted,
),
(
br#"{"v":1,"event":"hello_received"}"#.as_slice(),
BridgeDiagnosticEvent::HelloReceived,
),
(
br#"{"v":1,"event":"boundary_timeout"}"#.as_slice(),
BridgeDiagnosticEvent::BoundaryTimeout,
),
(
br#"{"v":1,"event":"hello_timeout"}"#.as_slice(),
BridgeDiagnosticEvent::HelloTimeout,
),
(
br#"{"v":1,"event":"client_close_before_hello"}"#.as_slice(),
BridgeDiagnosticEvent::ClientCloseBeforeHello,
),
(
br#"{"v":1,"event":"document_unloaded_before_hello"}"#.as_slice(),
BridgeDiagnosticEvent::DocumentUnloadedBeforeHello,
),
(
br#"{"v":1,"event":"runtime_error_before_hello"}"#.as_slice(),
BridgeDiagnosticEvent::RuntimeErrorBeforeHello,
),
] {
assert!(body.len() <= DIAGNOSTIC_BODY_LIMIT);
assert_eq!(parse_event(body), Some(event));
}
assert_eq!(parse_event(br#"{"event":"hello_timeout","v":1}"#), None);
assert_eq!(
parse_event(br#"{"v":1,"event":"hello_timeout","detail":"x"}"#),
None
);
}
}
+555
View File
@@ -0,0 +1,555 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use base64::Engine as _;
use tokio::net::TcpListener;
use super::{request, response_header, runtime_config, split_response};
use crate::config::{WebCarrier, WebDebugBodyCapture};
use crate::maestro::generation::test_runtime_generation;
use crate::web::frame::{self, FrameType};
use crate::web::manager::WebProcessRuntime;
use crate::web::trace::{TraceLifecycleEvent, TraceRecordKind, TraceRoute};
fn diagnostic_request(
method: &str,
path: &str,
host: &str,
token: Option<&str>,
content_type: Option<&str>,
extra_headers: &str,
body: &[u8],
) -> Vec<u8> {
let authorization = token
.map(|token| format!("Authorization: Bearer {token}\r\n"))
.unwrap_or_default();
let content_type = content_type
.map(|content_type| format!("Content-Type: {content_type}\r\n"))
.unwrap_or_default();
let mut request = format!(
"{method} {path} HTTP/1.1\r\nHost: {host}\r\nX-Forwarded-For: 192.0.2.10\r\n{authorization}{content_type}{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
request.extend_from_slice(body);
request
}
fn bootstrap_from_response(response: &[u8]) -> String {
let (_, body) = split_response(response);
std::str::from_utf8(body)
.unwrap()
.split_once("bootstrap=\"")
.and_then(|(_, suffix)| suffix.split_once('"'))
.map(|(token, _)| token)
.unwrap()
.to_string()
}
async fn issue_bootstrap(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
capability: [u8; 32],
) -> String {
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
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();
bootstrap_from_response(&request(listener, runtime, root).await)
}
#[tokio::test]
async fn bridge_diagnostic_does_not_consume_the_bootstrap() {
let capability = [31u8; 32];
let mut config = runtime_config(capability, WebCarrier::Websocket);
config.web.debug.enabled = true;
config.web.debug.sideband = true;
config.web.debug.body_capture = WebDebugBodyCapture::Full;
let generation = test_runtime_generation(1, config);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(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 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;
assert!(
std::str::from_utf8(split_response(&root_response).1)
.unwrap()
.contains("/api/v1/diagnostic")
);
let bootstrap = bootstrap_from_response(&root_response);
let body = br#"{"v":1,"event":"runtime_started"}"#;
let diagnostic = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
body,
);
let duplicate = diagnostic.clone();
let diagnostic_response = request(&listener, &runtime, diagnostic).await;
assert!(diagnostic_response.starts_with(b"HTTP/1.1 204"));
let (diagnostic_headers, diagnostic_body) = split_response(&diagnostic_response);
assert_eq!(
response_header(diagnostic_headers, "cache-control"),
"no-store"
);
assert!(diagnostic_body.is_empty());
let duplicate_response = request(&listener, &runtime, duplicate).await;
assert!(duplicate_response.starts_with(b"HTTP/1.1 204"));
let hello = frame::encode(FrameType::Hello, 0, &[1]);
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();
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
assert!(create_response.starts_with(b"HTTP/1.1 200"));
let hello_report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"hello_received"}"#,
);
let hello_report_response = request(&listener, &runtime, hello_report).await;
assert!(hello_report_response.starts_with(b"HTTP/1.1 204"));
let lifecycle = runtime.trace().snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Lifecycle(event)
if matches!(
event.event,
TraceLifecycleEvent::BridgeIssued | TraceLifecycleEvent::BridgeDiagnostic
)
)
});
let bridge_session_id = lifecycle
.iter()
.find_map(|record| match &record.record.kind {
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeIssued =>
{
record.record.identity.session_id
}
_ => None,
})
.unwrap();
let diagnostics = lifecycle
.iter()
.filter_map(|record| match &record.record.kind {
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeDiagnostic =>
{
Some((record.record.identity.session_id, event.reason))
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.contains(&(Some(bridge_session_id), Some("runtime_started"))));
assert!(diagnostics.contains(&(Some(bridge_session_id), Some("hello_received"))));
let diagnostic_http = runtime.trace().snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Http(http) if http.route == TraceRoute::Diagnostic
)
});
assert_eq!(diagnostic_http.len(), 3);
let TraceRecordKind::Http(http) = &diagnostic_http[0].record.kind else {
panic!("expected diagnostic HTTP trace");
};
assert!(
http.request_headers
.iter()
.any(|header| { header.name == "authorization" && header.value.is_none() })
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn malformed_bridge_diagnostics_follow_the_sanitized_decoy_path() {
let capability = [33u8; 32];
let mut config = runtime_config(capability, WebCarrier::Websocket);
config.web.debug.enabled = true;
config.web.debug.sideband = true;
let generation = test_runtime_generation(1, config);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&listener, &runtime, capability).await;
let canonical = br#"{"v":1,"event":"runtime_started"}"#;
let oversized = [b'x'; 65];
let wrong_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let cases = vec![
diagnostic_request(
"GET",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic?debug=1",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"Cookie: value=1\r\n",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
None,
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json; charset=utf-8"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"Content-Type: application/json\r\n",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
None,
Some("application/json"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(wrong_token),
Some("application/json"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"other.example.com",
Some(&bootstrap),
Some("application/json"),
"",
canonical,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
b"",
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
&oversized,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"event":"runtime_started","v":1}"#,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"runtime_started","detail":"x"}"#,
),
diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"unknown"}"#,
),
];
for request_bytes in cases {
let response = request(&listener, &runtime, request_bytes).await;
assert!(response.starts_with(b"HTTP/1.1 404"));
}
assert!(
runtime
.trace()
.snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeDiagnostic
)
})
.is_empty()
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn expired_bridge_bootstrap_cannot_report_diagnostics() {
let capability = [38u8; 32];
let mut config = runtime_config(capability, WebCarrier::Https);
config.web.debug.enabled = true;
config.web.debug.sideband = true;
config.web.timeouts.bootstrap_lifetime_secs = 0;
let generation = test_runtime_generation(1, config);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&listener, &runtime, capability).await;
let report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"runtime_started"}"#,
);
let response = request(&listener, &runtime, report).await;
assert!(response.starts_with(b"HTTP/1.1 404"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn diagnostic_body_pressure_does_not_claim_the_event() {
let capability = [39u8; 32];
let mut config = runtime_config(capability, WebCarrier::Websocket);
config.web.debug.enabled = true;
config.web.debug.sideband = true;
config.web.limits.max_body_readers = 1;
let generation = test_runtime_generation(1, config);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&listener, &runtime, capability).await;
let report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"runtime_started"}"#,
);
let retry = report.clone();
let held = runtime.try_body_budget(1).unwrap();
let saturated = request(&listener, &runtime, report).await;
assert!(saturated.starts_with(b"HTTP/1.1 503"));
assert_eq!(
response_header(split_response(&saturated).0, "retry-after"),
"1"
);
drop(held);
let accepted = request(&listener, &runtime, retry).await;
assert!(accepted.starts_with(b"HTTP/1.1 204"));
assert_eq!(
runtime
.trace()
.snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeDiagnostic
)
})
.len(),
1
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn bridge_diagnostic_sideband_is_carrier_independent() {
for (marker, carrier) in [
(34, WebCarrier::Https),
(35, WebCarrier::HttpsLanes),
(36, WebCarrier::Websocket),
(37, WebCarrier::WebsocketLanes),
] {
let capability = [marker; 32];
let mut config = runtime_config(capability, carrier);
config.web.debug.enabled = true;
config.web.debug.sideband = true;
let generation = test_runtime_generation(1, config);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&listener, &runtime, capability).await;
let report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"status_posted"}"#,
);
let response = request(&listener, &runtime, report).await;
assert!(response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
}
#[tokio::test]
async fn disabled_bridge_diagnostic_is_acknowledged_without_recording() {
let capability = [32u8; 32];
let mut config = runtime_config(capability, WebCarrier::Https);
config.web.debug.enabled = true;
config.web.limits.max_bootstraps_per_ip = 2;
let generation = test_runtime_generation(1, config);
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 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;
assert!(
!std::str::from_utf8(split_response(&root_response).1)
.unwrap()
.contains("/api/v1/diagnostic")
);
let bootstrap = bootstrap_from_response(&root_response);
let mut enabled_config = runtime_config(capability, WebCarrier::Https);
enabled_config.web.debug.enabled = true;
enabled_config.web.debug.sideband = true;
let enabled_generation = test_runtime_generation(2, enabled_config);
active_runtime.store(Arc::clone(&enabled_generation));
let report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"runtime_started"}"#,
);
let response = request(&listener, &runtime, report).await;
assert!(response.starts_with(b"HTTP/1.1 204"));
assert!(
runtime
.trace()
.snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeDiagnostic
)
})
.is_empty()
);
let current_gate_bootstrap = issue_bootstrap(&listener, &runtime, capability).await;
let mut disabled_config = runtime_config(capability, WebCarrier::Https);
disabled_config.web.debug.enabled = true;
let disabled_generation = test_runtime_generation(3, disabled_config);
active_runtime.store(Arc::clone(&disabled_generation));
let current_gate_report = diagnostic_request(
"POST",
"/api/v1/diagnostic",
"proxy.example.com",
Some(&current_gate_bootstrap),
Some("application/json"),
"",
br#"{"v":1,"event":"runtime_started"}"#,
);
let response = request(&listener, &runtime, current_gate_report).await;
assert!(response.starts_with(b"HTTP/1.1 204"));
let diagnostics = runtime.trace().snapshot_matching(|record| {
matches!(
&record.kind,
TraceRecordKind::Lifecycle(event)
if event.event == TraceLifecycleEvent::BridgeDiagnostic
)
});
assert!(diagnostics.is_empty());
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
enabled_generation.stop_sessions().await;
enabled_generation.stop_background_tasks().await;
disabled_generation.stop_sessions().await;
disabled_generation.stop_background_tasks().await;
}
+3
View File
@@ -28,6 +28,9 @@ mod negotiation_tests;
// Client failure diagnostics remain separate from negotiation state scenarios.
#[path = "carrier_diagnostic_tests.rs"]
mod carrier_diagnostic_tests;
// Bridge sideband diagnostics remain separate from carrier negotiation reports.
#[path = "diagnostic_tests.rs"]
mod diagnostic_tests;
// Reload-stability tests for session-owned timeout policy.
#[path = "session_policy_tests.rs"]
mod session_policy_tests;