Compare commits

...

1 Commits

Author SHA1 Message Date
Alexey 021ad1fe68 WEB Bridge Sideband 2026-09-08 21:29:32 +03:00
23 changed files with 1169 additions and 13 deletions
+12 -2
View File
@@ -107,14 +107,24 @@ async fn read_managed_config_exposes_web_without_runtime_or_access_secrets() {
#[tokio::test]
async fn patch_web_debug_is_hot_and_limits_are_process_deferred() {
let (path, _directory) = temp_config("[web]\nenabled = false\n");
let active = ProxyConfig::load(&path).unwrap();
let debug_patch: Json = serde_json::json!({
"web": {"debug": {"enabled": true, "capture_headers": false}}
"web": {"debug": {
"enabled": true,
"sideband": true,
"capture_headers": false
}}
});
let debug = apply_patch_to_path(&path, &debug_patch, None)
let mut debug = apply_patch_to_path(&path, &debug_patch, None)
.await
.unwrap();
let desired = ProxyConfig::load(&path).unwrap();
reconcile_runtime_effect(&mut debug, &active, &desired).unwrap();
assert!(!debug.restart_required);
assert!(debug.runtime_reload_required);
assert!(!debug.process_restart_required);
assert!(debug.changed.iter().any(|section| section == "web"));
assert!(desired.web.debug.sideband);
let limits_patch: Json = serde_json::json!({
"web": {"limits": {"max_http_connections": 2049}}
+5
View File
@@ -67,6 +67,11 @@ pub(super) async fn render(
push_filter_form(&mut html, &query);
html.push_str("<section><h2>Store</h2><table><tbody>");
summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled));
summary_row(
&mut html,
"sideband",
yes_no(status.policy.bridge_diagnostics_enabled()),
);
summary_row(&mut html, "body capture", body_mode(&status.policy));
summary_row(&mut html, "window seconds", &query.window_secs.to_string());
summary_row(
+2
View File
@@ -22,6 +22,7 @@ fn html_escaping_covers_active_markup_characters() {
async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
let policy = WebDebugConfig {
enabled: true,
sideband: true,
..Default::default()
};
let limits = crate::config::WebLimitsConfig {
@@ -58,6 +59,7 @@ async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("session_created"));
assert!(body.contains("<th>sideband</th><td>yes</td>"));
assert!(body.contains("0123456789abcdef"));
assert!(body.contains("192.0.2.40"));
}
+2
View File
@@ -144,11 +144,13 @@ fn web_debug_policy_is_hot_while_debug_capacity_is_process_owned() {
let old = sample_config();
let mut new = old.clone();
new.web.debug.enabled = true;
new.web.debug.sideband = true;
new.web.debug.default_window_secs = 60;
new.web.limits.debug_records_capacity += 1;
let applied = overlay_hot_fields(&old, &new);
assert!(applied.web.debug.enabled);
assert!(applied.web.debug.sideband);
assert_eq!(applied.web.debug.default_window_secs, 60);
assert_eq!(
applied.web.limits.debug_records_capacity,
+1
View File
@@ -326,6 +326,7 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
const WEB_DEBUG_CONFIG_KEYS: &[&str] = &[
"enabled",
"sideband",
"capture_lifecycle",
"capture_headers",
"capture_timings",
+25 -1
View File
@@ -301,17 +301,31 @@ fn web_carrier_learning_capacity_must_remain_nonzero() {
#[test]
fn web_debug_table_uses_debug_name_and_bounded_defaults() {
assert!(!crate::config::WebDebugConfig::default().sideband);
let mut ineffective = crate::config::WebDebugConfig {
enabled: true,
sideband: true,
..Default::default()
};
ineffective.capture_lifecycle = false;
assert!(!ineffective.bridge_diagnostics_enabled());
let configured = WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.debug]\nenabled = true\nbody_capture = \"prefix\"\nbody_prefix_bytes = 2048\ndefault_window_secs = 180\nmax_window_secs = 900\n\n[[web.vhosts]]",
"[web.debug]\nenabled = true\nsideband = true\nbody_capture = \"prefix\"\nbody_prefix_bytes = 2048\ndefault_window_secs = 180\nmax_window_secs = 900\n\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&configured);
assert!(config.web.debug.enabled);
assert!(config.web.debug.sideband);
assert!(config.web.debug.bridge_diagnostics_enabled());
assert_eq!(config.web.debug.body_capture, WebDebugBodyCapture::Prefix);
assert_eq!(config.web.debug.body_prefix_bytes, 2048);
assert_eq!(config.web.debug.default_window_secs, 180);
assert_eq!(config.web.debug.max_window_secs, 900);
let strict = format!("[general]\nconfig_strict = true\n{configured}");
assert!(load_config_from_temp_toml(&strict).web.debug.sideband);
let old_name = format!(
"[general]\nconfig_strict = true\n{}",
WEB_CONFIG.replace(
@@ -321,6 +335,16 @@ fn web_debug_table_uses_debug_name_and_bounded_defaults() {
);
let error = load_config_error_from_temp_toml(&old_name);
assert!(error.contains("web.trace"));
let old_parameter = format!(
"[general]\nconfig_strict = true\n{}",
WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.debug]\nbridge_diagnostics = true\n\n[[web.vhosts]]",
)
);
let error = load_config_error_from_temp_toml(&old_parameter);
assert!(error.contains("web.debug.bridge_diagnostics"));
}
#[test]
+11
View File
@@ -23,6 +23,9 @@ pub struct WebDebugConfig {
/// Enables process-owned WEB debug collection.
#[serde(default)]
pub enabled: bool,
/// Enables generated-bridge diagnostic reports over the HTTPS sideband.
#[serde(default)]
pub sideband: bool,
/// Records typed bridge, session, stream, handshake, and relay events.
#[serde(default = "default_true")]
pub capture_lifecycle: bool,
@@ -56,6 +59,7 @@ impl Default for WebDebugConfig {
fn default() -> Self {
Self {
enabled: false,
sideband: false,
capture_lifecycle: true,
capture_headers: true,
capture_timings: true,
@@ -69,6 +73,13 @@ impl Default for WebDebugConfig {
}
}
impl WebDebugConfig {
/// Returns whether newly issued bridges may report diagnostic lifecycle events.
pub(crate) const fn bridge_diagnostics_enabled(&self) -> bool {
self.enabled && self.sideband && self.capture_lifecycle
}
}
fn default_true() -> bool {
true
}
+76
View File
@@ -32,17 +32,92 @@ pub(crate) fn render(
websocket_open_secs: u64,
reconnect_grace_secs: u64,
carrier_probe_coalesce_ms: u64,
bridge_diagnostics_enabled: bool,
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
rng.fill(&mut nonce);
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
let diagnostic_script = if bridge_diagnostics_enabled {
format!("<script nonce=\"__NONCE__\">\n{DIAGNOSTIC_RUNTIME}\n</script>\n")
} else {
String::new()
};
let diagnostic_hook = |method: &str| {
bridge_diagnostics_enabled
.then(|| {
format!("if(clientDiagnostics)try{{clientDiagnostics.{method}()}}catch(error){{}}")
})
.unwrap_or_default()
};
let diagnostic_runtime_started = if bridge_diagnostics_enabled {
format!("{}\n", diagnostic_hook("runtimeStarted"))
} else {
String::new()
};
let body = DOCUMENT
.replace("__DIAGNOSTIC_RUNTIME__\n", &diagnostic_script)
.replace("__RESPONSE_RUNTIME__", RESPONSE_RUNTIME)
.replace("__REQUEST_RUNTIME__", REQUEST_RUNTIME)
.replace("__BUFFER_RUNTIME__", BUFFER_RUNTIME)
.replace("__RECOVERY_RUNTIME__", RECOVERY_RUNTIME)
.replace("__RUNTIME__", RUNTIME)
.replace(
"__DIAGNOSTIC_BINDING__;\n",
if bridge_diagnostics_enabled {
"const clientDiagnostics=globalThis.TelemtBridgeDiagnostics;\n"
} else {
""
},
)
.replace(
"__DIAGNOSTIC_RUNTIME_STARTED__;\n",
&diagnostic_runtime_started,
)
.replace(
"__DIAGNOSTIC_BOUNDARY_ACTIVATED__;",
&diagnostic_hook("boundaryActivated"),
)
.replace(
"__STATUS_FUNCTION__",
if bridge_diagnostics_enabled {
"state=>{if(port&&!closed){port.postMessage({t:'status',state});if(clientDiagnostics)try{clientDiagnostics.statusPosted()}catch(error){}}}"
} else {
"state=>{if(port&&!closed)port.postMessage({t:'status',state})}"
},
)
.replace(
"__DIAGNOSTIC_HELLO_RECEIVED__;",
&diagnostic_hook("helloReceived"),
)
.replace(
"__HELLO_TIMEOUT_CALLBACK__",
if bridge_diagnostics_enabled {
"()=>{if(clientDiagnostics)try{clientDiagnostics.helloTimeout()}catch(error){}fail('timeout')}"
} else {
"()=>fail('timeout')"
},
)
.replace(
"__DIAGNOSTIC_CLIENT_CLOSE__;",
&diagnostic_hook("clientCloseBeforeHello"),
)
.replace(
"__PAGEHIDE_CALLBACK__",
if bridge_diagnostics_enabled {
"()=>{if(clientDiagnostics)try{clientDiagnostics.documentUnloadedBeforeHello()}catch(error){}fail('navigation')}"
} else {
"()=>fail('navigation')"
},
)
.replace(
"__DIAGNOSTIC_BOOTSTRAP_REPLACED__;",
if bridge_diagnostics_enabled {
"if(clientDiagnostics)try{clientDiagnostics.setBootstrap(bootstrap)}catch(error){}"
} else {
""
},
)
.replace("__NONCE__", &nonce)
.replace("__HOST__", host)
.replace("__BOOTSTRAP__", bootstrap)
@@ -88,6 +163,7 @@ pub(crate) fn render(
}
const DOCUMENT: &str = include_str!("bridge/document.html");
const DIAGNOSTIC_RUNTIME: &str = include_str!("bridge/diagnostic.js");
const RESPONSE_RUNTIME: &str = include_str!("bridge/response.js");
const REQUEST_RUNTIME: &str = include_str!("bridge/request.js");
const BUFFER_RUNTIME: &str = include_str!("bridge/buffers.js");
+36
View File
@@ -0,0 +1,36 @@
(()=>{'use strict';
let bootstrap="__BOOTSTRAP__",hello=false,emitted=0,boundaryTimer=null;
const relayOrigin='https://__HOST__',requestMs=__BRIDGE_REQUEST_SECS__*1000;
function eventBit(event){
if(event==='runtime_started')return 1;if(event==='status_posted')return 2;if(event==='hello_received')return 4;if(event==='boundary_timeout')return 8;
if(event==='hello_timeout')return 16;if(event==='client_close_before_hello')return 32;if(event==='document_unloaded_before_hello')return 64;
return event==='runtime_error_before_hello'?128:0;
}
function discard(response){try{if(response.body)response.body.cancel().catch(()=>{})}catch(error){}}
function report(event){
if(hello&&event!=='hello_received')return;const bit=eventBit(event);if(!bit||(emitted&bit))return;emitted|=bit;
let timer=null;
try{
const controller=new AbortController(),body=JSON.stringify({v:1,event});timer=setTimeout(()=>controller.abort(),requestMs);
fetch(relayOrigin+'/api/v1/diagnostic',{method:'POST',body,signal:controller.signal,keepalive:true,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',headers:{Authorization:'Bearer '+bootstrap,'Content-Type':'application/json'}})
.then(discard,()=>{}).then(()=>clearTimeout(timer),()=>clearTimeout(timer));
}catch(error){if(timer)clearTimeout(timer)}
}
const boundaryWall=Date.now()+requestMs,boundaryMonotonic=performance.now()+requestMs;
function waitBoundary(){
boundaryTimer=null;if(hello||(emitted&2))return;const remaining=Math.min(boundaryWall-Date.now(),boundaryMonotonic-performance.now());
if(remaining>0){boundaryTimer=setTimeout(waitBoundary,remaining);return}report('boundary_timeout');
}
function clearBoundary(){try{if(boundaryTimer)clearTimeout(boundaryTimer)}catch(error){}boundaryTimer=null}
function runtimeStarted(){report('runtime_started');try{boundaryTimer=setTimeout(waitBoundary,Math.max(1,Math.min(boundaryWall-Date.now(),boundaryMonotonic-performance.now())))}catch(error){}}
function boundaryActivated(){clearBoundary()}
function statusPosted(){clearBoundary();report('status_posted')}
function helloReceived(){clearBoundary();hello=true;report('hello_received')}
function helloTimeout(){report('hello_timeout')}
function clientCloseBeforeHello(){report('client_close_before_hello')}
function documentUnloadedBeforeHello(){report('document_unloaded_before_hello')}
function setBootstrap(value){if(/^[A-Za-z0-9_-]{43}$/.test(value))bootstrap=value}
addEventListener('error',()=>report('runtime_error_before_hello'));
addEventListener('unhandledrejection',()=>report('runtime_error_before_hello'));
globalThis.TelemtBridgeDiagnostics=Object.freeze({runtimeStarted,boundaryActivated,statusPosted,helloReceived,helloTimeout,clientCloseBeforeHello,documentUnloadedBeforeHello,setBootstrap});
})();
+1
View File
@@ -6,6 +6,7 @@
<title>Connection</title>
</head>
<body>
__DIAGNOSTIC_RUNTIME__
<script nonce="__NONCE__">
__RESPONSE_RUNTIME__
</script>
+9 -7
View File
@@ -1,6 +1,8 @@
(()=>{'use strict';
let bootstrap="__BOOTSTRAP__";
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
__DIAGNOSTIC_BINDING__;
__DIAGNOSTIC_RUNTIME_STARTED__;
const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime');
const requestSupport=globalThis.TelemtBridgeRequest;if(!requestSupport)throw new Error('missing request runtime');
const bufferSupport=globalThis.TelemtBridgeBuffers;if(!bufferSupport)throw new Error('missing buffer runtime');
@@ -23,7 +25,7 @@ const pending=[],upPending=[],recoveryPending=[],lanes=new Map(),closedLanes=new
const canonicalFailures=['timeout','network','upgrade','http','protocol'];
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback;
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
const status=__STATUS_FUNCTION__;
const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
const requestClient=requestSupport.create({
origin:()=>relayOrigin,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
@@ -62,7 +64,7 @@ function retireCarrier(policy){
}
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;releasePending(pending,null);releasePending(recoveryPending,null);
for(const id of retireAllStreams())if(port){const frame=closeFrame(id);port.postMessage(frame,[frame])}
bootstrap=policy.bootstrap;batchLimit=policy.limits.carrier_batch_bytes;queueLimit=policy.limits.pending_bytes_per_session;
bootstrap=policy.bootstrap;__DIAGNOSTIC_BOOTSTRAP_REPLACED__;batchLimit=policy.limits.carrier_batch_bytes;queueLimit=policy.limits.pending_bytes_per_session;
queueItemLimit=policy.limits.pending_items_per_session;maxStreams=policy.limits.max_streams_per_session;
laneQueueLimit=Math.min(queueLimit,8388608);laneItemLimit=Math.min(queueItemLimit,1024);
longPollMs=policy.timeouts.long_poll_secs*1000;bridgeRequestMs=policy.timeouts.bridge_request_secs*1000;
@@ -487,20 +489,20 @@ function close(notifyServer){
buffers.assertEmpty();
}
function activatePort(nextPort){
initialized=true;port=nextPort;
initialized=true;port=nextPort;__DIAGNOSTIC_BOUNDARY_ACTIVATED__;
port.onmessage=message=>{
observeResumeTrigger();
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
if(!createStarted){__DIAGNOSTIC_HELLO_RECEIVED__;createStarted=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
else{
let data;try{data=acceptNativeFrames(message.data)}catch(error){fail(error&&error.telemtReason==='capacity'?'capacity':'protocol');return}if(!data)return;
if(recoveryController.active()&&!recoveryReplaced){if(!reserve(data,null)){fail('capacity');return}recoveryPending.push(data)}
else if(!carrierCommitted){if(!reserve(data,null)){fail('capacity');return}pending.push(data);maybeStartCandidate()}
else queueCarrier(data);
}
}else if(message.data&&message.data.t==='close'){status('failed');close(true)}
}else if(message.data&&message.data.t==='close'){__DIAGNOSTIC_CLIENT_CLOSE__;status('failed');close(true)}
};
port.start();status('connecting');helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs);
port.start();status('connecting');helloTimer=setTimeout(__HELLO_TIMEOUT_CALLBACK__,bridgeRequestMs);
}
recoveryController=recoverySupport.create({
budgetMs:()=>bridgeRecoveryMs,requestMs:()=>bridgeRequestMs,url:()=>relayOrigin+recoveryPath,token:()=>cleanupToken||sessionToken,
@@ -539,5 +541,5 @@ function discoverAndroid(){
discoverAndroid();
addEventListener('online',observeResumeTrigger);
if(globalThis.document&&typeof globalThis.document.addEventListener==='function')globalThis.document.addEventListener('visibilitychange',()=>{if(globalThis.document.visibilityState==='visible')observeResumeTrigger()});
addEventListener('pagehide',()=>fail('navigation'),{once:true});
addEventListener('pagehide',__PAGEHIDE_CALLBACK__,{once:true});
})();
+110
View File
@@ -18,6 +18,30 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
15,
120,
0,
false,
&SecureRandom::new(),
)
}
fn render_diagnostic_page(bootstrap: &str) -> BridgePage {
render(
"proxy.example.com",
bootstrap,
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
1024,
true,
4,
[3, 5, 8, 12],
25,
10,
90,
15,
15,
120,
0,
true,
&SecureRandom::new(),
)
}
@@ -77,6 +101,7 @@ fn rendered_page_embeds_the_configured_bridge_timing_policy() {
11,
119,
4,
false,
&SecureRandom::new(),
);
@@ -128,6 +153,7 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
15,
120,
0,
false,
&SecureRandom::new(),
);
assert!(page.body.contains(
@@ -190,3 +216,87 @@ fn rendered_page_preserves_exact_v1_status_control_envelope() {
);
assert!(!page.body.contains("port.postMessage({t:'status',state,"));
}
#[test]
fn bridge_diagnostic_sideband_is_absent_by_default() {
let page = render_page("IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII", 4);
assert!(page.body.contains("<body>\n<script nonce=\""));
assert!(page.body.contains(
"carrierCapabilities='https,https-lanes,websocket,websocket-lanes';\nconst responseBody="
));
assert!(!page.body.contains("/api/v1/diagnostic"));
assert!(!page.body.contains("TelemtBridgeDiagnostics"));
for event in [
"runtime_started",
"status_posted",
"hello_received",
"boundary_timeout",
"hello_timeout",
"client_close_before_hello",
"document_unloaded_before_hello",
"runtime_error_before_hello",
] {
assert!(!page.body.contains(event));
}
}
#[test]
fn enabled_bridge_diagnostics_use_the_https_sideband_only() {
let page = render_diagnostic_page("JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ");
assert!(!page.body.contains("__"));
assert!(page.body.contains("fetch(relayOrigin+'/api/v1/diagnostic'"));
assert!(page.body.contains("JSON.stringify({v:1,event})"));
assert!(page.body.contains("'Content-Type':'application/json'"));
assert!(page.body.contains("keepalive:true"));
for event in [
"runtime_started",
"status_posted",
"hello_received",
"boundary_timeout",
"hello_timeout",
"client_close_before_hello",
"document_unloaded_before_hello",
"runtime_error_before_hello",
] {
assert!(page.body.contains(event));
}
assert_eq!(page.body.matches("/api/v1/diagnostic").count(), 1);
}
#[test]
fn bridge_diagnostic_hooks_preserve_native_and_recovery_contracts() {
let page = render_diagnostic_page("KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK");
let status_report = page.body.find("clientDiagnostics.statusPosted()").unwrap();
let native_status = page
.body
.find("port.postMessage({t:'status',state})")
.unwrap();
let hello_report = page.body.find("clientDiagnostics.helloReceived()").unwrap();
let create_started = page.body.find("createStarted=true;if(helloTimer)").unwrap();
let boundary_initialized = page.body.find("initialized=true;port=nextPort;").unwrap();
let boundary_activated = page
.body
.find("clientDiagnostics.boundaryActivated()")
.unwrap();
let port_handler = page.body.find("port.onmessage=message=>").unwrap();
let bootstrap_replaced = page.body.find("bootstrap=policy.bootstrap;").unwrap();
let diagnostic_rebound = page
.body
.find("clientDiagnostics.setBootstrap(bootstrap)")
.unwrap();
let limits_replaced = page
.body
.find("batchLimit=policy.limits.carrier_batch_bytes")
.unwrap();
assert!(native_status < status_report);
assert!(hello_report < create_started);
assert!(boundary_initialized < boundary_activated);
assert!(boundary_activated < port_handler);
assert!(bootstrap_replaced < diagnostic_rebound);
assert!(diagnostic_rebound < limits_replaced);
assert!(page.body.contains("clientDiagnostics.helloTimeout()"));
assert!(!page.body.contains("port.postMessage({t:'status',state,"));
}
+12 -1
View File
@@ -30,6 +30,8 @@ mod body;
mod capability;
// Decoy routing and upstream proxying are isolated from carrier authentication.
mod decoy;
// Authenticated generated-bridge diagnostics remain outside carrier framing.
mod diagnostic;
// Downlink long-poll handling remains isolated from request routing.
mod down;
// Canonical request parsing rejects ambiguous credentials before routing.
@@ -69,7 +71,12 @@ type BoxError = Box<dyn Error + Send + Sync>;
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
type HttpResponse = Response<HttpBody>;
const TRANSPORT_PATHS: [&str; 3] = ["/api/v1/session", "/api/v1/up", "/api/v1/down"];
const TRANSPORT_PATHS: [&str; 4] = [
"/api/v1/session",
"/api/v1/up",
"/api/v1/down",
"/api/v1/diagnostic",
];
const WEBSOCKET_PATH: &str = "/api/v1/ws";
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
@@ -377,6 +384,7 @@ async fn handle_root(
config.web.timeouts.websocket_open_secs,
config.web.timeouts.reconnect_grace_secs,
config.web.timeouts.carrier_probe_coalesce_ms,
config.web.debug.bridge_diagnostics_enabled(),
&generation.rng,
);
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
@@ -436,6 +444,9 @@ async fn handle_api(
"/api/v1/session" => handle_session(request, runtime, vhost, token_hash, client_ip).await,
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
"/api/v1/diagnostic" => {
diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await
}
_ => serve_decoy(request, vhost, true, &runtime).await,
}
}
+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;
+3
View File
@@ -24,6 +24,9 @@ mod negotiation;
mod learning;
// Bootstrap credentials and idempotent session creation are isolated from queue accounting.
mod credentials;
// Authenticated bridge diagnostics remain isolated from session and carrier state.
mod diagnostic;
pub(crate) use diagnostic::BridgeDiagnosticEvent;
// First-session admission and bounded carrier replacement share one state machine.
mod session_creation;
// Session admission remains separate from stream tuple ownership.
+3
View File
@@ -147,6 +147,7 @@ impl WebProcessRuntime {
return Err(ManagerError::Limit);
};
let trace_session_id = self.trace.next_session_id();
let bridge_diagnostics_enabled = config.web.debug.bridge_diagnostics_enabled();
let (user_agent, user_agent_id) = bounded_user_agent(user_agent);
state.bootstraps.insert(
hash,
@@ -157,6 +158,8 @@ impl WebProcessRuntime {
profile,
timeouts: config.web.timeouts.clone(),
trace_session_id,
bridge_diagnostics_enabled,
bridge_diagnostic_events: 0,
user_agent,
user_agent_id,
body_digest: [0; TOKEN_BYTES],
+87
View File
@@ -0,0 +1,87 @@
use std::time::Instant;
use super::{ManagerError, TokenHash, WebProcessRuntime};
/// Closed generated-bridge diagnostic vocabulary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BridgeDiagnosticEvent {
/// The generated bridge runtime started executing.
RuntimeStarted,
/// The initial status control object was submitted to the native boundary.
StatusPosted,
/// The bridge received the first binary HELLO frame from the native client.
HelloReceived,
/// No supported native boundary appeared before the bridge request deadline.
BoundaryTimeout,
/// The native boundary did not provide HELLO before the bridge request deadline.
HelloTimeout,
/// The native boundary explicitly closed before providing HELLO.
ClientCloseBeforeHello,
/// The bridge document unloaded before receiving HELLO.
DocumentUnloadedBeforeHello,
/// The bridge runtime raised an error before receiving HELLO.
RuntimeErrorBeforeHello,
}
impl BridgeDiagnosticEvent {
/// Returns the fixed bootstrap-owned deduplication slot.
const fn bit(self) -> u16 {
match self {
Self::RuntimeStarted => 1 << 0,
Self::StatusPosted => 1 << 1,
Self::HelloReceived => 1 << 2,
Self::BoundaryTimeout => 1 << 3,
Self::HelloTimeout => 1 << 4,
Self::ClientCloseBeforeHello => 1 << 5,
Self::DocumentUnloadedBeforeHello => 1 << 6,
Self::RuntimeErrorBeforeHello => 1 << 7,
}
}
/// Returns the stable trace label.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::RuntimeStarted => "runtime_started",
Self::StatusPosted => "status_posted",
Self::HelloReceived => "hello_received",
Self::BoundaryTimeout => "boundary_timeout",
Self::HelloTimeout => "hello_timeout",
Self::ClientCloseBeforeHello => "client_close_before_hello",
Self::DocumentUnloadedBeforeHello => "document_unloaded_before_hello",
Self::RuntimeErrorBeforeHello => "runtime_error_before_hello",
}
}
}
impl WebProcessRuntime {
/// Claims one authenticated diagnostic event without changing credential lifetime or state.
pub(crate) fn claim_bridge_diagnostic(
&self,
hash: TokenHash,
host: &str,
event: BridgeDiagnosticEvent,
) -> Result<bool, ManagerError> {
let active = self
.active_generation()
.config()
.web
.debug
.bridge_diagnostics_enabled();
let now = Instant::now();
let mut state = self.state.lock();
let entry = state
.bootstraps
.get_mut(&hash)
.filter(|entry| entry.profile.host == host && now <= entry.expires_at)
.ok_or(ManagerError::Authentication)?;
if !active || !entry.bridge_diagnostics_enabled {
return Ok(false);
}
let bit = event.bit();
if entry.bridge_diagnostic_events & bit != 0 {
return Ok(false);
}
entry.bridge_diagnostic_events |= bit;
Ok(true)
}
}
+4
View File
@@ -46,6 +46,10 @@ pub(super) struct Bootstrap {
pub(super) timeouts: WebTimeoutsConfig,
/// Process-unique non-secret identifier shared by bootstrap and session traces.
pub(super) trace_session_id: u64,
/// Whether this bridge was issued with the diagnostic sideband enabled.
pub(super) bridge_diagnostics_enabled: bool,
/// Fixed event slots already claimed by this bootstrap chain.
pub(super) bridge_diagnostic_events: u16,
/// Bounded display form of the issuing User-Agent.
pub(super) user_agent: Option<Arc<str>>,
/// Opaque non-secret identifier used for exact User-Agent filtering.
+29 -2
View File
@@ -9,6 +9,7 @@ const USER_AGENT_MAX_BYTES: usize = 512;
const MIN_REDACTION_BYTES: usize = 8;
const MAX_REDACTION_BYTES: usize = 512;
const MAX_HEADER_REDACTIONS: usize = 16;
const DIAGNOSTIC_BODY_CAPTURE_MAX_BYTES: usize = 64;
/// Calculates a conservative request metadata and credential lease.
pub(super) fn request_dynamic_bytes<B>(
@@ -126,7 +127,7 @@ pub(super) fn capture_limit(
route: TraceRoute,
max_carrier_body_bytes: usize,
) -> Option<usize> {
match policy.body_capture {
let limit = match policy.body_capture {
WebDebugBodyCapture::Off | WebDebugBodyCapture::Metadata => None,
WebDebugBodyCapture::Prefix => Some(if decoy_route(route) {
policy.decoy_body_prefix_bytes
@@ -138,7 +139,14 @@ pub(super) fn capture_limit(
} else {
max_carrier_body_bytes
}),
}
};
limit.map(|limit| {
if route == TraceRoute::Diagnostic {
limit.min(DIAGNOSTIC_BODY_CAPTURE_MAX_BYTES)
} else {
limit
}
})
}
/// Returns a closed display label for one parsed frame type.
@@ -284,6 +292,25 @@ mod tests {
assert_eq!(capture_limit(&policy, TraceRoute::Uplink, 4096), Some(4096));
}
#[test]
fn diagnostic_body_capture_never_reserves_the_carrier_ceiling() {
let full = WebDebugConfig {
body_capture: WebDebugBodyCapture::Full,
..Default::default()
};
assert_eq!(capture_limit(&full, TraceRoute::Diagnostic, 4096), Some(64));
let prefix = WebDebugConfig {
body_capture: WebDebugBodyCapture::Prefix,
body_prefix_bytes: 32,
..Default::default()
};
assert_eq!(
capture_limit(&prefix, TraceRoute::Diagnostic, 4096),
Some(32)
);
}
#[test]
fn scrub_removes_complete_and_prefix_truncated_credentials() {
let secret = Zeroizing::new(b"credential-value".to_vec());
+24
View File
@@ -83,6 +83,30 @@ fn stale_generation_cannot_restore_an_old_policy() {
assert_eq!(status.policy.as_ref(), &current);
}
#[test]
fn bridge_diagnostic_policy_toggle_preserves_existing_records_and_epoch() {
let store = store(4, 8 * BASE_RECORD_RESERVATION);
store.record_lifecycle(
None,
Some("192.0.2.25".parse().unwrap()),
TraceIdentity::default(),
TraceLifecycleEvent::BridgeIssued,
None,
None,
);
let before = store.status();
let mut policy = before.policy.as_ref().clone();
policy.sideband = true;
store.apply_policy(2, &policy);
let after = store.status();
assert!(after.policy.sideband);
assert_eq!(after.policy_generation, 2);
assert_eq!(after.epoch, before.epoch);
assert_eq!(after.records, before.records);
}
#[test]
fn explicit_clear_fences_inflight_commits_and_preserves_snapshot_leases() {
let store = store(4, 8 * BASE_RECORD_RESERVATION);
+6
View File
@@ -17,6 +17,8 @@ pub(crate) enum TraceRoute {
Downlink,
/// WebSocket upgrade handshake.
Websocket,
/// Authenticated generated-bridge diagnostic report.
Diagnostic,
}
impl TraceRoute {
@@ -30,6 +32,7 @@ impl TraceRoute {
Self::Uplink => "uplink",
Self::Downlink => "downlink",
Self::Websocket => "websocket",
Self::Diagnostic => "diagnostic",
}
}
}
@@ -226,6 +229,8 @@ pub(crate) struct TraceWebSocketRecord {
pub(crate) enum TraceLifecycleEvent {
/// A bridge bootstrap was issued.
BridgeIssued,
/// A generated bridge reported one fixed diagnostic event.
BridgeDiagnostic,
/// Bootstrap or bridge admission was rejected.
BootstrapRejected,
/// A session request was classified for legacy or automatic negotiation.
@@ -275,6 +280,7 @@ impl TraceLifecycleEvent {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::BridgeIssued => "bridge_issued",
Self::BridgeDiagnostic => "bridge_diagnostic",
Self::BootstrapRejected => "bootstrap_rejected",
Self::CarrierClassified => "carrier_classified",
Self::CarrierSelected => "carrier_selected",