mirror of
https://github.com/telemt/telemt.git
synced 2026-09-16 15:34:13 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 021ad1fe68 |
@@ -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}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -326,6 +326,7 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
|
||||
const WEB_DEBUG_CONFIG_KEYS: &[&str] = &[
|
||||
"enabled",
|
||||
"sideband",
|
||||
"capture_lifecycle",
|
||||
"capture_headers",
|
||||
"capture_timings",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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});
|
||||
})();
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>Connection</title>
|
||||
</head>
|
||||
<body>
|
||||
__DIAGNOSTIC_RUNTIME__
|
||||
<script nonce="__NONCE__">
|
||||
__RESPONSE_RUNTIME__
|
||||
</script>
|
||||
|
||||
@@ -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});
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -83,6 +83,30 @@ fn stale_generation_cannot_restore_an_old_policy() {
|
||||
assert_eq!(status.policy.as_ref(), ¤t);
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user