Bounded Debugging + Websocket Carriers + Carriers Negotiation

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-26 17:00:20 +03:00
parent 43cd84aaa5
commit 923c79796a
52 changed files with 3450 additions and 980 deletions
+7 -20
View File
@@ -18,7 +18,7 @@ mod details;
// Query parsing and matching remain independent from bounded HTML rendering. // Query parsing and matching remain independent from bounded HTML rendering.
mod query; mod query;
use details::{push_body, push_frames, push_headers}; use details::{push_body, push_frames, push_headers, push_lifecycle};
use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches}; use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches};
struct GroupSummary { struct GroupSummary {
@@ -56,11 +56,11 @@ pub(super) async fn render(
); );
}; };
let now_millis = crate::web::trace::store_epoch_millis(); let now_millis = crate::web::trace::store_epoch_millis();
let since_millis = query let since_millis = if query.record.is_none() {
.record now_millis.saturating_sub(query.window_secs.saturating_mul(1000))
.is_none() } else {
.then(|| now_millis.saturating_sub(query.window_secs.saturating_mul(1000))) 0
.unwrap_or(0); };
let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis)); let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis));
let status = store.status(); let status = store.status();
let mut html = String::with_capacity(MAX_PAGE_BYTES); let mut html = String::with_capacity(MAX_PAGE_BYTES);
@@ -367,20 +367,7 @@ fn push_record(html: &mut String, record: &TraceRecord) {
push_body(html, "message body", message.body.as_ref()); push_body(html, "message body", message.body.as_ref());
push_frames(html, &message.frames); push_frames(html, &message.frames);
} }
TraceRecordKind::Lifecycle(event) => { TraceRecordKind::Lifecycle(event) => push_lifecycle(html, event),
html.push_str("<pre>event: ");
html.push_str(event.event.as_str());
html.push_str("\nstream: ");
html.push_str(
&event
.stream_id
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
);
html.push_str("\nreason: ");
html.push_str(event.reason.unwrap_or("-"));
html.push_str("</pre>");
}
} }
html.push_str("</details></td></tr>"); html.push_str("</details></td></tr>");
} }
+34
View File
@@ -84,3 +84,37 @@ pub(super) fn push_body(
} }
html.push_str("</pre>"); html.push_str("</pre>");
} }
pub(super) fn push_lifecycle(
html: &mut String,
event: &crate::web::trace::TraceLifecycleRecord,
) {
html.push_str("<pre>event: ");
html.push_str(event.event.as_str());
html.push_str("\nstream: ");
html.push_str(
&event
.stream_id
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
);
html.push_str("\nreason: ");
html.push_str(event.reason.unwrap_or("-"));
if let Some(carrier) = &event.carrier {
html.push_str("\nclient class: ");
html.push_str(carrier.client_class);
html.push_str("\ncarrier: ");
html.push_str(carrier.carrier.as_str());
html.push_str("\nattempt: ");
html.push_str(&carrier.attempt.to_string());
html.push_str("\nscores: https=");
html.push_str(&carrier.scores[0].to_string());
html.push_str(" https-lanes=");
html.push_str(&carrier.scores[1].to_string());
html.push_str(" websocket=");
html.push_str(&carrier.scores[2].to_string());
html.push_str(" websocket-lanes=");
html.push_str(&carrier.scores[3].to_string());
}
html.push_str("</pre>");
}
+10 -6
View File
@@ -20,11 +20,15 @@ fn html_escaping_covers_active_markup_characters() {
#[tokio::test] #[tokio::test]
async fn renderer_filters_groups_and_sets_control_plane_security_headers() { async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
let mut policy = WebDebugConfig::default(); let policy = WebDebugConfig {
policy.enabled = true; enabled: true,
let mut limits = crate::config::WebLimitsConfig::default(); ..Default::default()
limits.debug_records_capacity = 8; };
limits.debug_bytes_global = 16 * 1024; let limits = crate::config::WebLimitsConfig {
debug_records_capacity: 8,
debug_bytes_global: 16 * 1024,
..Default::default()
};
let store = WebTraceStore::new(policy.clone(), &limits); let store = WebTraceStore::new(policy.clone(), &limits);
store.record_lifecycle( store.record_lifecycle(
None, None,
@@ -79,7 +83,7 @@ async fn render_permits_remain_owned_by_inflight_response_bodies() {
#[test] #[test]
fn page_truncation_preserves_utf8_boundary_and_cap() { fn page_truncation_preserves_utf8_boundary_and_cap() {
let mut html = "я".repeat(MAX_PAGE_BYTES); let mut html = "\u{044f}".repeat(MAX_PAGE_BYTES);
truncate_page(&mut html); truncate_page(&mut html);
assert!(html.len() <= MAX_PAGE_BYTES); assert!(html.len() <= MAX_PAGE_BYTES);
assert!(html.ends_with("[page output truncated]")); assert!(html.ends_with("[page output truncated]"));
+1
View File
@@ -16,6 +16,7 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately | //! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately | //! | `access` | All user/quota fields | Effective immediately |
//! | `web` | Carrier, timing, and debug policy | Applied to newly issued sessions |
//! Fields that require re-binding sockets (`server.listeners`, legacy //! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. SYN limiter rules are process-owned and are //! applied; a warning is emitted. SYN limiter rules are process-owned and are
+40
View File
@@ -30,6 +30,23 @@ fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option<u1
std::fs::write(path, config).unwrap(); std::fs::write(path, config).unwrap();
} }
fn write_web_reload_config(path: &Path, carriers: &str, carrier_learning: bool) {
let config = format!(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[web]
carriers = {carriers}
carrier_learning = {carrier_learning}
"#,
);
std::fs::write(path, config).unwrap();
}
fn temp_config_path(prefix: &str) -> PathBuf { fn temp_config_path(prefix: &str) -> PathBuf {
let nonce = std::time::SystemTime::now() let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -264,6 +281,29 @@ fn reload_keeps_hot_apply_when_non_hot_fields_change() {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
fn reload_publishes_web_negotiation_policy_outside_hot_field_reporting() {
let path = temp_config_path("telemt_web_negotiation_reload");
write_web_reload_config(&path, "false", true);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_web_reload_config(&path, "[\"websocket\", \"https\"]", false);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
let applied = config_tx.borrow().clone();
assert!(applied.web.carrier_negotiation_enabled());
assert!(!applied.web.carrier_learning);
let _ = std::fs::remove_file(path);
}
#[test] #[test]
fn classify_sni_change_requires_restart() { fn classify_sni_change_requires_restart() {
// censorship.* is not in overlay_hot_fields -> restart. // censorship.* is not in overlay_hot_fields -> restart.
+1 -1
View File
@@ -164,7 +164,7 @@ pub(super) fn reload_config(
let old_hot = HotFields::from_config(&old_cfg); let old_hot = HotFields::from_config(&old_cfg);
let applied_hot = HotFields::from_config(&applied_cfg); let applied_hot = HotFields::from_config(&applied_cfg);
let non_hot_changed = !config_equal(&applied_cfg, &new_cfg); let non_hot_changed = !config_equal(&applied_cfg, &new_cfg);
let hot_changed = old_hot != applied_hot; let hot_changed = !config_equal(&old_cfg, &applied_cfg);
if non_hot_changed { if non_hot_changed {
warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed); warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed);
+8
View File
@@ -27,6 +27,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let mut static_files = 0usize; let mut static_files = 0usize;
let mut static_bytes = 0usize; let mut static_bytes = 0usize;
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for vhost in &config.web.vhosts { for vhost in &config.web.vhosts {
let decoy = build_decoy( let decoy = build_decoy(
vhost, vhost,
@@ -63,6 +64,13 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
user: profile.user.clone(), user: profile.user.clone(),
secret_mode: profile.secret_mode, secret_mode: profile.secret_mode,
carrier: config.web.carrier, carrier: config.web.carrier,
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
carrier_learning: config.web.carrier_learning,
carriers: Arc::clone(&carrier_candidates),
carrier_negotiation_deadlines_secs: config
.web
.timeouts
.carrier_negotiation_deadlines_secs,
capability, capability,
key_fingerprint, key_fingerprint,
max_sessions: profile max_sessions: profile
+11 -1
View File
@@ -260,7 +260,14 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
]; ];
const WEB_CONFIG_KEYS: &[&str] = &[ const WEB_CONFIG_KEYS: &[&str] = &[
"enabled", "carrier", "debug", "limits", "timeouts", "vhosts", "enabled",
"carrier",
"carriers",
"carrier_learning",
"debug",
"limits",
"timeouts",
"vhosts",
]; ];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
@@ -275,6 +282,7 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"websocket_admission_watermark_pct", "websocket_admission_watermark_pct",
"websocket_eviction_watermark_pct", "websocket_eviction_watermark_pct",
"websocket_http_connection_reserve", "websocket_http_connection_reserve",
"max_carrier_learning_entries",
"max_body_readers", "max_body_readers",
"max_body_bytes_global", "max_body_bytes_global",
"max_sessions_global", "max_sessions_global",
@@ -328,6 +336,8 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"websocket_write_secs", "websocket_write_secs",
"websocket_backpressure_secs", "websocket_backpressure_secs",
"websocket_eviction_secs", "websocket_eviction_secs",
"carrier_negotiation_deadlines_secs",
"carrier_learning_secs",
"bootstrap_lifetime_secs", "bootstrap_lifetime_secs",
"reconnect_grace_secs", "reconnect_grace_secs",
"http_idle_secs", "http_idle_secs",
+15 -39
View File
@@ -6,6 +6,10 @@ use super::*;
mod debug; mod debug;
// Memory-envelope arithmetic remains isolated from protocol validation. // Memory-envelope arithmetic remains isolated from protocol validation.
mod memory; mod memory;
// Carrier ordering, cumulative deadlines, and fallback identity are validated together.
mod negotiation;
// Request and lifecycle timeout relationships are validated together.
mod timeouts;
// WebSocket transport policy is validated independently from HTTP body policy. // WebSocket transport policy is validated independently from HTTP body policy.
mod websocket; mod websocket;
@@ -67,11 +71,14 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
validate_limits(&config.web.limits)?; validate_limits(&config.web.limits)?;
debug::validate(&config.web.debug, &config.web.limits)?; debug::validate(&config.web.debug, &config.web.limits)?;
if config.web.carrier == WebCarrier::HttpsLanes && config.web.limits.max_http_handlers < 2 { let carriers = negotiation::validate(&config.web)?;
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2"); if carriers.contains(&WebCarrier::HttpsLanes) && config.web.limits.max_http_handlers < 2 {
return config_error(
"WEB https-lanes candidates require web.limits.max_http_handlers >= 2",
);
} }
validate_timeouts(&config.web.timeouts)?; timeouts::validate(&config.web.timeouts)?;
websocket::validate(config.web.carrier, &config.web.limits, &config.web.timeouts)?; websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
validate_vhosts(config)?; validate_vhosts(config)?;
Ok(()) Ok(())
} }
@@ -155,6 +162,10 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
let positive = [ let positive = [
("max_http_connections", limits.max_http_connections), ("max_http_connections", limits.max_http_connections),
("max_http_handlers", limits.max_http_handlers), ("max_http_handlers", limits.max_http_handlers),
(
"max_carrier_learning_entries",
limits.max_carrier_learning_entries,
),
("max_body_readers", limits.max_body_readers), ("max_body_readers", limits.max_body_readers),
("max_body_bytes_global", limits.max_body_bytes_global), ("max_body_bytes_global", limits.max_body_bytes_global),
("max_sessions_global", limits.max_sessions_global), ("max_sessions_global", limits.max_sessions_global),
@@ -324,41 +335,6 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
Ok(()) Ok(())
} }
fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> {
let values = [
("header_secs", timeouts.header_secs),
("body_secs", timeouts.body_secs),
("stream_handshake_secs", timeouts.stream_handshake_secs),
("long_poll_secs", timeouts.long_poll_secs),
("websocket_write_secs", timeouts.websocket_write_secs),
(
"websocket_backpressure_secs",
timeouts.websocket_backpressure_secs,
),
("websocket_eviction_secs", timeouts.websocket_eviction_secs),
("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs),
("reconnect_grace_secs", timeouts.reconnect_grace_secs),
("http_idle_secs", timeouts.http_idle_secs),
("shutdown_secs", timeouts.shutdown_secs),
("decoy_header_secs", timeouts.decoy_header_secs),
];
if let Some((field, _)) = values
.into_iter()
.find(|(_, value)| !(1..=3600).contains(value))
{
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
}
let request_deadline = timeouts
.header_secs
.max(timeouts.body_secs)
.max(timeouts.long_poll_secs)
.max(timeouts.decoy_header_secs);
if request_deadline >= timeouts.http_idle_secs {
return config_error("web.timeouts request deadlines must be lower than http_idle_secs");
}
Ok(())
}
fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits; let limits = &config.web.limits;
if config.web.vhosts.len() > limits.max_vhosts { if config.web.vhosts.len() > limits.max_vhosts {
+11
View File
@@ -3,9 +3,13 @@ use super::*;
const WEB_DEBUG_RENDERERS: usize = 2; const WEB_DEBUG_RENDERERS: usize = 2;
const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024; const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024;
const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024; const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024;
const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 256;
/// Validates process-wide body, header, queue, static, and debug reservations. /// Validates process-wide body, header, queue, static, and debug reservations.
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
if limits.max_carrier_learning_entries == 0 {
return config_error("web.limits.max_carrier_learning_entries must be > 0");
}
let body_reservation = limits let body_reservation = limits
.max_body_readers .max_body_readers
.checked_mul(limits.max_body_bytes) .checked_mul(limits.max_body_bytes)
@@ -47,6 +51,12 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
.and_then(|scratch| value.checked_add(scratch)) .and_then(|scratch| value.checked_add(scratch))
}) })
.ok_or_else(|| ProxyError::Config("web.debug reservations overflowed usize".to_string()))?; .ok_or_else(|| ProxyError::Config("web.debug reservations overflowed usize".to_string()))?;
let carrier_learning_reservation = limits
.max_carrier_learning_entries
.checked_mul(WEB_CARRIER_LEARNING_ENTRY_BYTES)
.ok_or_else(|| {
ProxyError::Config("web.carrier learning reservation overflowed usize".to_string())
})?;
let reserved = limits let reserved = limits
.pending_bytes_global .pending_bytes_global
.checked_add(limits.max_body_bytes_global) .checked_add(limits.max_body_bytes_global)
@@ -54,6 +64,7 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
.and_then(|value| value.checked_add(debug_ring_index)) .and_then(|value| value.checked_add(debug_ring_index))
.and_then(|value| value.checked_add(status_pages)) .and_then(|value| value.checked_add(status_pages))
.and_then(|value| value.checked_add(debug_reservation)) .and_then(|value| value.checked_add(debug_reservation))
.and_then(|value| value.checked_add(carrier_learning_reservation))
.and_then(|value| value.checked_add(http_header_reservation)) .and_then(|value| value.checked_add(http_header_reservation))
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?; .ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
if reserved > limits.memory_envelope_bytes if reserved > limits.memory_envelope_bytes
+108
View File
@@ -0,0 +1,108 @@
use std::collections::HashSet;
use super::*;
/// Validates bounded carrier selection and learning policy.
pub(super) fn validate(config: &WebConfig) -> Result<Vec<WebCarrier>> {
if let Some(carriers) = config.carriers.enabled() {
if carriers.is_empty() {
return config_error("web.carriers must contain at least one carrier");
}
let mut unique = HashSet::with_capacity(carriers.len());
if carriers.iter().any(|carrier| !unique.insert(*carrier)) {
return config_error("web.carriers must not contain duplicate carriers");
}
}
let candidates = config.carrier_candidates();
if candidates.len() > WebCarrier::ALL.len() {
return config_error(
"web.carriers and the web.carrier fallback must contain at most four carriers",
);
}
let deadlines = config.timeouts.carrier_negotiation_deadlines_secs;
if deadlines[0] == 0 || deadlines.windows(2).any(|pair| pair[0] >= pair[1]) {
return config_error(
"web.timeouts.carrier_negotiation_deadlines_secs must be non-zero and strictly increasing",
);
}
if deadlines[3] > config.timeouts.bootstrap_lifetime_secs {
return config_error(
"web.timeouts carrier negotiation deadline must not exceed bootstrap_lifetime_secs",
);
}
Ok(candidates)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_is_appended_once() {
let config = WebConfig {
carrier: WebCarrier::Https,
carriers: WebCarriers::Enabled(vec![WebCarrier::Websocket, WebCarrier::Https]),
..Default::default()
};
assert_eq!(
validate(&config).unwrap(),
vec![WebCarrier::Websocket, WebCarrier::Https]
);
}
#[test]
fn duplicate_carriers_are_rejected() {
let config = WebConfig {
carriers: WebCarriers::Enabled(vec![WebCarrier::Websocket, WebCarrier::Websocket]),
..Default::default()
};
assert!(validate(&config).is_err());
}
#[test]
fn missing_or_false_carriers_disable_negotiation() {
#[derive(serde::Deserialize)]
struct Wrapper {
value: WebCarriers,
}
let config = WebConfig::default();
assert!(!config.carrier_negotiation_enabled());
assert_eq!(validate(&config).unwrap(), [WebCarrier::Https]);
let disabled: Wrapper = toml::from_str("value = false").unwrap();
assert_eq!(disabled.value, WebCarriers::Disabled);
assert!(toml::from_str::<Wrapper>("value = true").is_err());
}
#[test]
fn fallback_cannot_expand_the_candidate_set_beyond_four() {
let mut config = WebConfig {
carrier: WebCarrier::Https,
carriers: WebCarriers::Enabled(vec![
WebCarrier::HttpsLanes,
WebCarrier::Websocket,
WebCarrier::WebsocketLanes,
WebCarrier::Https,
]),
..Default::default()
};
assert_eq!(validate(&config).unwrap().len(), 4);
config.carriers = WebCarriers::Enabled(vec![
WebCarrier::HttpsLanes,
WebCarrier::Websocket,
WebCarrier::WebsocketLanes,
]);
assert_eq!(validate(&config).unwrap().len(), 4);
}
#[test]
fn deadlines_are_cumulative_and_bounded_by_bootstrap_lifetime() {
let mut config = WebConfig::default();
config.timeouts.carrier_negotiation_deadlines_secs = [3, 3, 8, 12];
assert!(validate(&config).is_err());
config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 121];
assert!(validate(&config).is_err());
}
}
+40
View File
@@ -0,0 +1,40 @@
use super::*;
/// Validates WEB request, learning, and lifecycle timeouts.
pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
let values = [
("header_secs", timeouts.header_secs),
("body_secs", timeouts.body_secs),
("stream_handshake_secs", timeouts.stream_handshake_secs),
("long_poll_secs", timeouts.long_poll_secs),
("websocket_write_secs", timeouts.websocket_write_secs),
(
"websocket_backpressure_secs",
timeouts.websocket_backpressure_secs,
),
("websocket_eviction_secs", timeouts.websocket_eviction_secs),
("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs),
("reconnect_grace_secs", timeouts.reconnect_grace_secs),
("http_idle_secs", timeouts.http_idle_secs),
("shutdown_secs", timeouts.shutdown_secs),
("decoy_header_secs", timeouts.decoy_header_secs),
];
if let Some((field, _)) = values
.into_iter()
.find(|(_, value)| !(1..=3600).contains(value))
{
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
}
if timeouts.carrier_learning_secs == 0 {
return config_error("web.timeouts.carrier_learning_secs must be > 0");
}
let request_deadline = timeouts
.header_secs
.max(timeouts.body_secs)
.max(timeouts.long_poll_secs)
.max(timeouts.decoy_header_secs);
if request_deadline >= timeouts.http_idle_secs {
return config_error("web.timeouts request deadlines must be lower than http_idle_secs");
}
Ok(())
}
+2 -2
View File
@@ -7,7 +7,7 @@ const WEBSOCKET_FRAME_OVERHEAD_BYTES: usize = 14;
/// Validates WebSocket admission, memory, and deadline invariants. /// Validates WebSocket admission, memory, and deadline invariants.
pub(super) fn validate( pub(super) fn validate(
carrier: WebCarrier, carriers: &[WebCarrier],
limits: &WebLimitsConfig, limits: &WebLimitsConfig,
timeouts: &WebTimeoutsConfig, timeouts: &WebTimeoutsConfig,
) -> Result<()> { ) -> Result<()> {
@@ -27,7 +27,7 @@ pub(super) fn validate(
"web.timeouts.websocket_eviction_secs must not exceed websocket_write_secs", "web.timeouts.websocket_eviction_secs must not exceed websocket_write_secs",
); );
} }
if !carrier.uses_websocket() { if !carriers.iter().any(|carrier| carrier.uses_websocket()) {
return Ok(()); return Ok(());
} }
if limits.carrier_batch_bytes > MAX_WEBSOCKET_BATCH_BYTES { if limits.carrier_batch_bytes > MAX_WEBSOCKET_BATCH_BYTES {
+82 -1
View File
@@ -49,6 +49,87 @@ fn web_config_builds_canonical_runtime_snapshot() {
assert_eq!(vhost.profiles[0].max_streams_per_session, 16); assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
assert_eq!(vhost.profiles[0].key_fingerprint.len(), 16); assert_eq!(vhost.profiles[0].key_fingerprint.len(), 16);
assert_ne!(vhost.profiles[0].key_fingerprint, "0001020304050607"); assert_ne!(vhost.profiles[0].key_fingerprint, "0001020304050607");
assert!(!vhost.profiles[0].carrier_negotiation_enabled);
assert_eq!(vhost.profiles[0].carriers.as_ref(), [WebCarrier::HttpsLanes]);
}
#[test]
fn web_carriers_missing_or_false_disable_negotiation() {
let missing = load_config_from_temp_toml(WEB_CONFIG);
assert!(!missing.web.carrier_negotiation_enabled());
let disabled = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\ncarriers = false",
);
let disabled = load_config_from_temp_toml(&disabled);
assert!(!disabled.web.carrier_negotiation_enabled());
assert_eq!(
disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(),
[WebCarrier::HttpsLanes]
);
}
#[test]
fn web_carrier_array_enables_ordered_negotiation_and_appends_fallback() {
let configured = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\ncarriers = [\"websocket\", \"https\"]\ncarrier_learning = false",
);
let config = load_config_from_temp_toml(&configured);
assert!(config.web.carrier_negotiation_enabled());
assert!(!config.web.carrier_learning);
let profile = &config.web.runtime.unwrap().profiles[0];
assert_eq!(
profile.carriers.as_ref(),
[
WebCarrier::Websocket,
WebCarrier::Https,
WebCarrier::HttpsLanes
]
);
assert!(!profile.carrier_learning);
}
#[test]
fn web_carriers_reject_true_empty_and_duplicates() {
for value in [
"true",
"[]",
"[\"https\", \"https\"]",
] {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
&format!("carrier = \"https-lanes\"\ncarriers = {value}"),
);
assert!(load_config_error_from_temp_toml(&invalid).contains("web.carriers"));
}
}
#[test]
fn web_carrier_deadlines_and_learning_window_are_configurable() {
let configured = WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.timeouts]\ncarrier_negotiation_deadlines_secs = [1, 2, 4, 9]\ncarrier_learning_secs = 30\n\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&configured);
assert_eq!(
config.web.timeouts.carrier_negotiation_deadlines_secs,
[1, 2, 4, 9]
);
assert_eq!(config.web.timeouts.carrier_learning_secs, 30);
}
#[test]
fn web_carrier_learning_capacity_must_remain_nonzero() {
let invalid = WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.limits]\nmax_carrier_learning_entries = 0\n\n[[web.vhosts]]",
);
assert!(
load_config_error_from_temp_toml(&invalid)
.contains("web.limits.max_carrier_learning_entries")
);
} }
#[test] #[test]
@@ -106,7 +187,7 @@ fn https_lanes_requires_separate_poll_and_control_handler_capacity() {
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_handlers = 1\nmax_body_readers = 1", "carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_handlers = 1\nmax_body_readers = 1",
); );
let error = load_config_error_from_temp_toml(&invalid); let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web.carrier=https-lanes requires")); assert!(error.contains("WEB https-lanes candidates require"));
} }
#[test] #[test]
+6 -2
View File
@@ -24,6 +24,8 @@ mod network;
mod policies; mod policies;
mod server; mod server;
mod web; mod web;
// WEB carrier tokens and fixed-slot policy helpers remain independent from bulky config types.
mod web_carrier;
// WEB debug capture policy is reusable by config reload and process storage. // WEB debug capture policy is reusable by config reload and process storage.
mod web_debug; mod web_debug;
@@ -51,9 +53,11 @@ pub use server::{
}; };
#[allow(unused_imports)] #[allow(unused_imports)]
pub use web::{ pub use web::{
WebCarrier, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode, WebTimeoutsConfig,
WebTimeoutsConfig, WebVhostConfig, WebVhostConfig,
}; };
#[allow(unused_imports)]
pub use web_carrier::{WebCarrier, WebCarriers};
pub(crate) use web::{ pub(crate) use web::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite, WebStaticSite,
+70 -135
View File
@@ -6,8 +6,13 @@ use std::sync::Arc;
use bytes::Bytes; use bytes::Bytes;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::web_carrier::{WebCarrier, WebCarriers};
use super::web_debug::WebDebugConfig; use super::web_debug::WebDebugConfig;
// Serialized WEB defaults remain separate from the runtime data model.
mod defaults;
use defaults::*;
/// Client-facing secret representation used to derive a WEB capability. /// Client-facing secret representation used to derive a WEB capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -18,48 +23,6 @@ pub enum WebSecretMode {
Dd, Dd,
} }
/// Carrier selected for newly issued WEB bridge sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
Https,
/// Give every logical stream independent HTTPS sequencing and polling state.
HttpsLanes,
/// Multiplex all logical streams over one ordered WebSocket.
Websocket,
/// Give every logical stream an independently owned WebSocket lane.
WebsocketLanes,
}
impl WebCarrier {
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
Self::Websocket => "websocket",
Self::WebsocketLanes => "websocket-lanes",
}
}
/// Returns whether one carrier owns independent state per logical stream.
pub(crate) const fn uses_lanes(self) -> bool {
matches!(self, Self::HttpsLanes | Self::WebsocketLanes)
}
/// Returns whether carrier messages use RFC 6455 instead of HTTP bodies.
pub(crate) const fn uses_websocket(self) -> bool {
matches!(self, Self::Websocket | Self::WebsocketLanes)
}
/// Returns whether all logical streams share one carrier state machine.
pub(crate) const fn is_multiplexed(self) -> bool {
matches!(self, Self::Https | Self::Websocket)
}
}
/// One access user explicitly exposed through a WEB virtual host. /// One access user explicitly exposed through a WEB virtual host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebProfileConfig { pub struct WebProfileConfig {
@@ -147,6 +110,9 @@ pub struct WebLimitsConfig {
/// Accepted HTTP connections that WebSocket upgrades must leave available. /// Accepted HTTP connections that WebSocket upgrades must leave available.
#[serde(default = "default_web_websocket_http_connection_reserve")] #[serde(default = "default_web_websocket_http_connection_reserve")]
pub websocket_http_connection_reserve: usize, pub websocket_http_connection_reserve: usize,
/// Process-wide bounded carrier-learning evidence entry ceiling.
#[serde(default = "default_web_max_carrier_learning_entries")]
pub max_carrier_learning_entries: usize,
/// Process-wide concurrently collected request body ceiling. /// Process-wide concurrently collected request body ceiling.
#[serde(default = "default_web_max_body_readers")] #[serde(default = "default_web_max_body_readers")]
pub max_body_readers: usize, pub max_body_readers: usize,
@@ -253,6 +219,7 @@ impl Default for WebLimitsConfig {
websocket_admission_watermark_pct: default_web_websocket_admission_watermark_pct(), websocket_admission_watermark_pct: default_web_websocket_admission_watermark_pct(),
websocket_eviction_watermark_pct: default_web_websocket_eviction_watermark_pct(), websocket_eviction_watermark_pct: default_web_websocket_eviction_watermark_pct(),
websocket_http_connection_reserve: default_web_websocket_http_connection_reserve(), websocket_http_connection_reserve: default_web_websocket_http_connection_reserve(),
max_carrier_learning_entries: default_web_max_carrier_learning_entries(),
max_body_readers: default_web_max_body_readers(), max_body_readers: default_web_max_body_readers(),
max_body_bytes_global: default_web_max_body_bytes_global(), max_body_bytes_global: default_web_max_body_bytes_global(),
max_sessions_global: default_web_max_sessions_global(), max_sessions_global: default_web_max_sessions_global(),
@@ -311,6 +278,12 @@ pub struct WebTimeoutsConfig {
/// Maximum graceful close wait for an evicted WebSocket. /// Maximum graceful close wait for an evicted WebSocket.
#[serde(default = "default_web_websocket_eviction_secs")] #[serde(default = "default_web_websocket_eviction_secs")]
pub websocket_eviction_secs: u64, pub websocket_eviction_secs: u64,
/// Cumulative carrier-attempt deadlines for up to four unique candidates.
#[serde(default = "default_web_carrier_negotiation_deadlines_secs")]
pub carrier_negotiation_deadlines_secs: [u64; 4],
/// Fixed process-local carrier-learning evidence lifetime.
#[serde(default = "default_web_carrier_learning_secs")]
pub carrier_learning_secs: u64,
/// Lifetime of an unused bootstrap credential and closed-token replay marker. /// Lifetime of an unused bootstrap credential and closed-token replay marker.
#[serde(default = "default_web_bootstrap_lifetime_secs")] #[serde(default = "default_web_bootstrap_lifetime_secs")]
pub bootstrap_lifetime_secs: u64, pub bootstrap_lifetime_secs: u64,
@@ -338,6 +311,9 @@ impl Default for WebTimeoutsConfig {
websocket_write_secs: default_web_websocket_write_secs(), websocket_write_secs: default_web_websocket_write_secs(),
websocket_backpressure_secs: default_web_websocket_backpressure_secs(), websocket_backpressure_secs: default_web_websocket_backpressure_secs(),
websocket_eviction_secs: default_web_websocket_eviction_secs(), websocket_eviction_secs: default_web_websocket_eviction_secs(),
carrier_negotiation_deadlines_secs:
default_web_carrier_negotiation_deadlines_secs(),
carrier_learning_secs: default_web_carrier_learning_secs(),
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(), bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
reconnect_grace_secs: default_web_reconnect_grace_secs(), reconnect_grace_secs: default_web_reconnect_grace_secs(),
http_idle_secs: default_web_http_idle_secs(), http_idle_secs: default_web_http_idle_secs(),
@@ -348,14 +324,20 @@ impl Default for WebTimeoutsConfig {
} }
/// WEB ingress, carrier, fallback, and lifecycle configuration. /// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig { pub struct WebConfig {
/// Enables issuance of new WEB bridge and session credentials. /// Enables issuance of new WEB bridge and session credentials.
#[serde(default)] #[serde(default)]
pub enabled: bool, pub enabled: bool,
/// Carrier selected for newly issued WEB bridge sessions. /// Sole carrier when negotiation is disabled and final fallback when enabled.
#[serde(default)] #[serde(default)]
pub carrier: WebCarrier, pub carrier: WebCarrier,
/// Ordered carriers considered by server-side negotiation before the fallback carrier.
#[serde(default)]
pub carriers: WebCarriers,
/// Enables bounded process-local carrier learning for automatic sessions.
#[serde(default = "default_web_carrier_learning")]
pub carrier_learning: bool,
/// Hard process and protocol limits. /// Hard process and protocol limits.
#[serde(default)] #[serde(default)]
pub limits: WebLimitsConfig, pub limits: WebLimitsConfig,
@@ -373,6 +355,41 @@ pub struct WebConfig {
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>, pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
} }
impl WebConfig {
/// Returns the configured negotiation order with the fallback appended once.
pub(crate) fn carrier_candidates(&self) -> Vec<WebCarrier> {
let Some(configured) = self.carriers.enabled() else {
return vec![self.carrier];
};
let mut candidates = configured.to_vec();
if !candidates.contains(&self.carrier) {
candidates.push(self.carrier);
}
candidates
}
/// Returns whether the explicit candidate list enables auto-negotiation.
pub(crate) fn carrier_negotiation_enabled(&self) -> bool {
self.carriers.enabled().is_some()
}
}
impl Default for WebConfig {
fn default() -> Self {
Self {
enabled: false,
carrier: WebCarrier::default(),
carriers: WebCarriers::default(),
carrier_learning: default_web_carrier_learning(),
limits: WebLimitsConfig::default(),
debug: WebDebugConfig::default(),
timeouts: WebTimeoutsConfig::default(),
vhosts: Vec::new(),
runtime: None,
}
}
}
/// Precomputed WEB configuration consumed by listener hot paths. /// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct WebRuntimeConfig { pub(crate) struct WebRuntimeConfig {
@@ -406,8 +423,16 @@ pub(crate) struct WebRuntimeProfile {
pub(crate) user: String, pub(crate) user: String,
/// Client secret representation and inner protocol policy. /// Client secret representation and inner protocol policy.
pub(crate) secret_mode: WebSecretMode, pub(crate) secret_mode: WebSecretMode,
/// Carrier frozen into bridge and session state at issuance time. /// Sole carrier or final fallback frozen into the issued bridge policy.
pub(crate) carrier: WebCarrier, pub(crate) carrier: WebCarrier,
/// Whether an explicit carrier list enabled automatic negotiation.
pub(crate) carrier_negotiation_enabled: bool,
/// Whether automatic outcomes consult and update process-local evidence.
pub(crate) carrier_learning: bool,
/// Ordered negotiation candidates including the fallback carrier exactly once.
pub(crate) carriers: Arc<[WebCarrier]>,
/// Cumulative carrier-attempt deadlines frozen when the bridge is issued.
pub(crate) carrier_negotiation_deadlines_secs: [u64; 4],
/// HMAC-derived bridge capability. /// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32], pub(crate) capability: [u8; 32],
/// Non-secret domain-separated client-secret fingerprint for debugging. /// Non-secret domain-separated client-secret fingerprint for debugging.
@@ -446,93 +471,3 @@ pub(crate) struct WebStaticAsset {
/// Strong SHA-256 entity tag. /// Strong SHA-256 entity tag.
pub(crate) etag: String, pub(crate) etag: String,
} }
fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
fn $name() -> u32 {
$value
}
};
}
macro_rules! u8_default {
($name:ident, $value:expr) => {
fn $name() -> u8 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frames_per_body, 4096);
usize_default!(default_web_max_http_connections, 1024);
usize_default!(default_web_max_http_handlers, 512);
usize_default!(default_web_websocket_bytes_global, 256 * 1024 * 1024);
u8_default!(default_web_websocket_admission_watermark_pct, 75);
u8_default!(default_web_websocket_eviction_watermark_pct, 90);
usize_default!(default_web_websocket_http_connection_reserve, 64);
usize_default!(default_web_max_body_readers, 32);
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_max_sessions_global, 128);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_debug_records_capacity, 65_536);
usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
u64_default!(default_web_header_timeout_secs, 10);
u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_long_poll_timeout_secs, 25);
u64_default!(default_web_websocket_write_secs, 30);
u64_default!(default_web_websocket_backpressure_secs, 30);
u64_default!(default_web_websocket_eviction_secs, 1);
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);
+97
View File
@@ -0,0 +1,97 @@
pub(super) fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u32 {
$value
}
};
}
macro_rules! u8_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u8 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frames_per_body, 4096);
usize_default!(default_web_max_http_connections, 1024);
usize_default!(default_web_max_http_handlers, 512);
usize_default!(default_web_websocket_bytes_global, 256 * 1024 * 1024);
u8_default!(default_web_websocket_admission_watermark_pct, 75);
u8_default!(default_web_websocket_eviction_watermark_pct, 90);
usize_default!(default_web_websocket_http_connection_reserve, 64);
usize_default!(default_web_max_carrier_learning_entries, 4096);
usize_default!(default_web_max_body_readers, 32);
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_max_sessions_global, 128);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_debug_records_capacity, 65_536);
usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
u64_default!(default_web_header_timeout_secs, 10);
u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_long_poll_timeout_secs, 25);
u64_default!(default_web_websocket_write_secs, 30);
u64_default!(default_web_websocket_backpressure_secs, 30);
u64_default!(default_web_websocket_eviction_secs, 1);
pub(super) fn default_web_carrier_negotiation_deadlines_secs() -> [u64; 4] {
[3, 5, 8, 12]
}
u64_default!(default_web_carrier_learning_secs, 600);
pub(super) fn default_web_carrier_learning() -> bool {
true
}
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);
+115
View File
@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
/// Carrier selected for one newly issued WEB relay session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
Https,
/// Give every logical stream independent HTTPS sequencing and polling state.
HttpsLanes,
/// Multiplex all logical streams over one ordered WebSocket.
Websocket,
/// Give every logical stream an independently owned WebSocket lane.
WebsocketLanes,
}
impl WebCarrier {
/// Every carrier supported by the WEB v1 bridge.
pub(crate) const ALL: [Self; 4] = [
Self::Https,
Self::HttpsLanes,
Self::Websocket,
Self::WebsocketLanes,
];
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
Self::Websocket => "websocket",
Self::WebsocketLanes => "websocket-lanes",
}
}
/// Returns the stable fixed-slot index used by bounded learning state.
pub(crate) const fn index(self) -> usize {
match self {
Self::Https => 0,
Self::HttpsLanes => 1,
Self::Websocket => 2,
Self::WebsocketLanes => 3,
}
}
/// Returns whether one carrier owns independent state per logical stream.
pub(crate) const fn uses_lanes(self) -> bool {
matches!(self, Self::HttpsLanes | Self::WebsocketLanes)
}
/// Returns whether carrier messages use RFC 6455 instead of HTTP bodies.
pub(crate) const fn uses_websocket(self) -> bool {
matches!(self, Self::Websocket | Self::WebsocketLanes)
}
/// Returns whether all logical streams share one carrier state machine.
pub(crate) const fn is_multiplexed(self) -> bool {
matches!(self, Self::Https | Self::Websocket)
}
}
/// Optional ordered carrier list that enables server-side auto-negotiation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum WebCarriers {
/// Auto-negotiation is disabled and only `web.carrier` is used.
#[default]
Disabled,
/// Auto-negotiation uses this ordered candidate list before the fallback.
Enabled(Vec<WebCarrier>),
}
impl WebCarriers {
/// Returns the explicit candidate list when negotiation is enabled.
pub fn enabled(&self) -> Option<&[WebCarrier]> {
match self {
Self::Disabled => None,
Self::Enabled(carriers) => Some(carriers),
}
}
}
impl Serialize for WebCarriers {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Disabled => false.serialize(serializer),
Self::Enabled(carriers) => carriers.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for WebCarriers {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Flag(bool),
List(Vec<WebCarrier>),
}
match Repr::deserialize(deserializer)? {
Repr::Flag(false) => Ok(Self::Disabled),
Repr::Flag(true) => Err(serde::de::Error::custom(
"web.carriers accepts false or a non-empty carrier array",
)),
Repr::List(carriers) => Ok(Self::Enabled(carriers)),
}
}
}
+143 -136
View File
@@ -1,6 +1,5 @@
use base64::Engine as _; use base64::Engine as _;
use crate::config::WebCarrier;
use crate::crypto::SecureRandom; use crate::crypto::SecureRandom;
/// Browser security policy for the transient Telegram Desktop bridge page. /// Browser security policy for the transient Telegram Desktop bridge page.
@@ -14,14 +13,17 @@ pub(crate) struct BridgePage {
pub(crate) content_security_policy: String, pub(crate) content_security_policy: String,
} }
/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce. /// Renders the bounded WEB carrier-negotiation bridge with a fresh CSP nonce.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render( pub(crate) fn render(
host: &str, host: &str,
bootstrap: &str, bootstrap: &str,
batch_limit: usize, batch_limit: usize,
queue_limit: usize, queue_limit: usize,
queue_items: usize, queue_items: usize,
carrier: WebCarrier, negotiation_enabled: bool,
candidate_count: usize,
carrier_deadlines: [u64; 4],
rng: &SecureRandom, rng: &SecureRandom,
) -> BridgePage { ) -> BridgePage {
let mut nonce = [0u8; 18]; let mut nonce = [0u8; 18];
@@ -34,7 +36,19 @@ pub(crate) fn render(
.replace("__BATCH_LIMIT__", &batch_limit.to_string()) .replace("__BATCH_LIMIT__", &batch_limit.to_string())
.replace("__QUEUE_LIMIT__", &queue_limit.to_string()) .replace("__QUEUE_LIMIT__", &queue_limit.to_string())
.replace("__QUEUE_ITEMS__", &queue_items.to_string()) .replace("__QUEUE_ITEMS__", &queue_items.to_string())
.replace("__CARRIER__", carrier.as_str()); .replace(
"__NEGOTIATION_ENABLED__",
if negotiation_enabled { "true" } else { "false" },
)
.replace("__CANDIDATE_COUNT__", &candidate_count.to_string())
.replace(
"__CARRIER_DEADLINES__",
&carrier_deadlines
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(","),
);
BridgePage { BridgePage {
body, body,
content_security_policy: format!( content_security_policy: format!(
@@ -54,13 +68,18 @@ const DOCUMENT: &str = r##"<!doctype html>
<script nonce="__NONCE__"> <script nonce="__NONCE__">
(()=>{ (()=>{
'use strict'; 'use strict';
const relayOrigin='https://__HOST__',bootstrap="__BOOTSTRAP__",carrier='__CARRIER__'; const bootstrap="__BOOTSTRAP__";
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
const effectiveDeadlines=candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3]);
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__; const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096; const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||''; const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
history.replaceState(null,'',location.pathname); history.replaceState(null,'',location.pathname);
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false; let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false,carrier='';
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null; let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
let negotiationStartedAt=0,carrierTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false;
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[]; const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})}; const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds)); const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
@@ -69,6 +88,7 @@ const options=(method,token,body,headers,signal,keepalive)=>({
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer', method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{}) headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
}); });
const attemptHeaders=()=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(carrierAttempt)},carrierFailure?{'X-Carrier-Failure':carrierFailure}:{}):{};
function reserve(data,lane){ function reserve(data,lane){
let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount; let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount;
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false; if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false;
@@ -99,6 +119,27 @@ function splitFrames(value){
} }
if(!result.length)throw new Error('empty frame batch');return result; if(!result.length)throw new Error('empty frame batch');return result;
} }
function findProbe(){
for(let index=0;index<pending.length;index++){
const value=pending[index],view=new DataView(value);let offset=0,frames=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8||frames++>=4096)throw new Error('invalid frame batch');
const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
const size=view.getUint32(offset+4),end=offset+8+size;
if((type===2&&!size)||size>1048576||end>value.byteLength)throw new Error('invalid frame');
if(type===1||type===2)return {source:value,index,start:offset,end,id,data:value.slice(offset,end)};
offset=end;
}
}
return null;
}
function consumeProbe(probe){
if(pending[probe.index]!==probe.source)throw new Error('stale carrier probe');
const before=probe.source.slice(0,probe.start),after=probe.source.slice(probe.end),remaining=before.byteLength+after.byteLength;
if(!remaining){pending.splice(probe.index,1);release(probe.end-probe.start,1,null);return}
const merged=new Uint8Array(remaining);merged.set(new Uint8Array(before),0);merged.set(new Uint8Array(after),before.byteLength);
pending[probe.index]=merged.buffer;release(probe.end-probe.start,0,null);
}
function joinPending(values,lane){ function joinPending(values,lane){
let total=0,count=0,frames=0; let total=0,count=0,frames=0;
while(count<values.length){ while(count<values.length){
@@ -145,21 +186,77 @@ async function request(path,makeOptions){
} }
} }
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)} function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
async function createSession(first){ function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
function armCarrierDeadline(epoch){
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
if(carrierTimer)clearTimeout(carrierTimer);
const remaining=negotiationStartedAt+effectiveDeadlines[carrierAttempt-1]*1000-Date.now();
carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
}
function resetCandidate(){
if(pollController)pollController.abort();pollController=null;
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upPending.length=0;upSequence=1;downCursor='0';upRunning=false;
sessionToken='';carrier='';candidateRunning=false;
}
function advanceCarrier(reason,epoch){
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
if(attemptController)attemptController.abort();attemptController=null;
resetCandidate();
if(carrierAttempt>=candidateCount||Date.now()>=negotiationStartedAt+effectiveDeadlines[effectiveDeadlines.length-1]*1000){switching=false;fail();return}
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
}
function maybeStartCandidate(){
let probe;try{probe=findProbe()}catch(error){fail();return}
if(!probe||closed||carrierCommitted)return;
if(negotiationEnabled&&!negotiationStartedAt){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}
if(!sessionToken||candidateRunning)return;
candidateRunning=true;const epoch=attemptEpoch;
if(carrier==='https')probeHttp(probe,null,epoch);
else if(carrier==='https-lanes')probeHttp(probe,probe.id,epoch);
else if(carrier==='websocket')openCandidateSocket(probe,null,epoch);
else if(carrier==='websocket-lanes')openCandidateSocket(probe,probe.id,epoch);
else advanceCarrier('protocol',epoch);
}
async function createSession(epoch){
attemptController=new AbortController();
try{ try{
status('connecting'); status('connecting');
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first)); const response=await request('/api/v1/session',()=>options('POST',bootstrap,helloFrame,attemptHeaders(),attemptController.signal));
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session creation rejected'); if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0'; if(response.status!==200){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('http',epoch);else fail();return}
if(!sessionToken)throw new Error('missing session token'); const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
if(closed){deleteSession();return} if(!knownCarrier(selected)||(negotiationEnabled?echo!==String(carrierAttempt):echo!=='')){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
const welcome=await response.arrayBuffer(); const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
port.postMessage(welcome,[welcome]);status('connected'); if(!token||cursor!=='0'){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
if(carrier==='https-lanes')ensureLane(0); const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return;
if(carrier==='websocket')openSocket(); carrier=selected;sessionToken=token;downCursor=cursor;
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)} if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
if(carrier==='https')poll();else if(carrier==='https-lanes')pollLane(lanes.get(0)); maybeStartCandidate();
}catch(error){fail()} }catch(error){if(closed||epoch!==attemptEpoch)return;if(negotiationStartedAt)advanceCarrier('network',epoch);else fail()}
}
async function probeHttp(probe,laneID,epoch){
try{
const headers={'X-Up-Seq':'1'};if(laneID!==null)headers['X-Lane-ID']=String(laneID);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,probe.data,headers,attemptController.signal));
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return}
if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return}
if(laneID===null)upSequence=2;else ensureLane(laneID).sequence=2;
commitCarrier(probe,epoch);
}catch(error){if(!closed&&epoch===attemptEpoch)advanceCarrier('network',epoch)}
}
function commitCarrier(probe,epoch){
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
try{consumeProbe(probe)}catch(error){fail();return}
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
status('connected');
if(carrier==='https')poll();
else if(carrier==='https-lanes'){const control=ensureLane(0);pollLane(control);const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
} }
function queueCarrier(data){ function queueCarrier(data){
try{ try{
@@ -181,15 +278,30 @@ async function runUp(){
}catch(error){fail()} }catch(error){fail()}
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()} finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
} }
function openSocket(){ function openCandidateSocket(probe,laneID,epoch){
if(socket||closed)return;socket=new WebSocket(socketURL(),'tproxy-v1.'+sessionToken);socket.binaryType='arraybuffer'; const protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+sessionToken:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+sessionToken+'.'+String(laneID);
socket.onopen=()=>{if(closed)return;socketReady=true;status('connected');runSocketUp()}; const next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';let opened=false,lane=null;
socket.onmessage=event=>{ if(laneID===null)socket=next;else{lane=ensureLane(laneID);lane.socket=next}
if(closed||!(event.data instanceof ArrayBuffer)){fail();return} next.onopen=()=>{
try{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}catch(error){fail();return} if(closed||epoch!==attemptEpoch)return;opened=true;
if(lane){lane.ready=true}else socketReady=true;
try{next.send(probe.data);if(!negotiationEnabled)commitCarrier(probe,epoch)}catch(error){advanceCarrier('upgrade',epoch)}
};
next.onmessage=event=>{
if(closed||epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
if(!carrierCommitted){if(event.data.byteLength!==0){advanceCarrier('protocol',epoch);return}commitCarrier(probe,epoch);return}
try{
if(lane){const values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame');if(values.some(value=>value.type===3))lane.remoteClosed=true}
else{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}
}catch(error){if(lane)finishLane(lane,true);else fail();return}
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected'); port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
}; };
socket.onerror=()=>{};socket.onclose=()=>{socketReady=false;if(!closed)fail()}; next.onerror=()=>{};
next.onclose=()=>{
if(epoch!==attemptEpoch||closed)return;
if(!carrierCommitted){advanceCarrier(opened?'network':'upgrade',epoch);return}
if(lane){lane.ready=false;lane.socket=null;finishLane(lane,true)}else{socketReady=false;fail()}
};
} }
function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()} function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()}
async function waitSocket(next,size,limit){ async function waitSocket(next,size,limit){
@@ -306,7 +418,7 @@ function deleteSession(){
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{}); if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
} }
function close(notifyServer){ function close(notifyServer){
if(closed)return;closed=true;if(pollController)pollController.abort(); if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);if(attemptController)attemptController.abort();if(pollController)pollController.abort();
if(socket)socket.close();for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()} if(socket)socket.close();for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
if(notifyServer)deleteSession();pending.length=0;upPending.length=0; if(notifyServer)deleteSession();pending.length=0;upPending.length=0;
for(const lane of lanes.values())lane.pending.length=0;lanes.clear();queuedBytes=0;queuedItems=0;if(port)port.close(); for(const lane of lanes.values())lane.pending.length=0;lanes.clear();queuedBytes=0;queuedItems=0;if(port)port.close();
@@ -315,8 +427,8 @@ function activatePort(nextPort){
initialized=true;port=nextPort; initialized=true;port=nextPort;
port.onmessage=message=>{ port.onmessage=message=>{
if(message.data instanceof ArrayBuffer){ if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;createSession(message.data)} if(!createStarted){createStarted=true;helloFrame=message.data;createSession(attemptEpoch)}
else if(!sessionToken){if(!reserve(message.data,null)){fail();return}pending.push(message.data)} else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
else queueCarrier(message.data); else queueCarrier(message.data);
}else if(message.data&&message.data.t==='close')close(true); }else if(message.data&&message.data.t==='close')close(true);
}; };
@@ -348,111 +460,6 @@ addEventListener('pagehide',()=>close(true),{once:true});
</html> </html>
"##; "##;
// Rendered wire-contract tests remain separate from the embedded document.
#[cfg(test)] #[cfg(test)]
mod tests { mod tests;
use super::*;
#[test]
fn rendered_page_contains_no_template_markers_or_capability() {
let page = render(
"proxy.example.com",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::HttpsLanes,
&SecureRandom::new(),
);
assert!(!page.body.contains("__"));
assert!(!page.body.contains("bridge="));
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("carrier='https-lanes'"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page.body.contains("const when=Date.parse(header)"));
assert!(page.body.contains("},{once:false});"));
assert!(
page.body
.contains("if(!sessionToken)throw new Error('missing session token')")
);
assert!(!page.body.contains("welcomeBytes"));
assert!(
page.body
.contains("for(const value of splitFrames(data))if(value.id!==lane.id)")
);
assert!(
page.body
.contains("let frames;try{frames=splitFrames(value)}catch(error){fail();return}")
);
assert!(
page.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*")
);
}
#[test]
fn rendered_page_is_parseable_by_ios_native_carrier() {
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let page = render(
"proxy.example.com",
bootstrap,
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::Https,
&SecureRandom::new(),
);
let accepted_shapes = [
format!("const bootstrap=\"{bootstrap}\""),
format!("const bootstrap='{bootstrap}'"),
format!("bootstrap=\"{bootstrap}\""),
];
assert!(
accepted_shapes
.iter()
.any(|shape| page.body.contains(shape)),
"the iOS native carrier cannot parse a comma-declared single-quoted bootstrap"
);
}
#[test]
fn rendered_page_advertises_exact_websocket_carriers() {
let websocket = render(
"proxy.example.com",
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::Websocket,
&SecureRandom::new(),
);
assert!(websocket.body.contains("carrier='websocket'"));
assert!(
websocket
.body
.contains("new WebSocket(socketURL(),'tproxy-v1.'+sessionToken)")
);
assert!(
websocket
.content_security_policy
.contains("connect-src 'self' wss://proxy.example.com")
);
let lanes = render(
"proxy.example.com",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::WebsocketLanes,
&SecureRandom::new(),
);
assert!(lanes.body.contains("carrier='websocket-lanes'"));
assert!(
lanes
.body
.contains("'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id)")
);
assert!(!lanes.body.contains("__"));
}
}
+76
View File
@@ -0,0 +1,76 @@
use super::*;
fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
render(
"proxy.example.com",
bootstrap,
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
true,
candidate_count,
[3, 5, 8, 12],
&SecureRandom::new(),
)
}
#[test]
fn rendered_page_contains_bounded_negotiation_contract() {
let page = render_page(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
4,
);
assert!(!page.body.contains("__"));
assert!(!page.body.contains("bridge="));
assert!(page.body.contains("X-Carrier-Capabilities"));
assert!(page.body.contains("X-Carrier-Attempt"));
assert!(page.body.contains("candidateCount=4"));
assert!(page.body.contains("candidateDeadlines=[3,5,8,12]"));
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page.body.contains("tproxy-auto-v1."));
assert!(page.body.contains("tproxy-auto-lane-v1."));
assert!(
page.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*")
);
}
#[test]
fn rendered_page_preserves_the_ios_bootstrap_literal() {
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let page = render_page(bootstrap, 2);
assert!(page.body.contains(&format!("const bootstrap=\"{bootstrap}\"")));
}
#[test]
fn effective_deadline_formula_uses_the_final_checkpoint() {
let page = render_page(
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
3,
);
assert!(page.body.contains(
"candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3])"
));
}
#[test]
fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
let page = render(
"proxy.example.com",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
false,
1,
[3, 5, 8, 12],
&SecureRandom::new(),
);
assert!(page.body.contains(
"if(negotiationEnabled&&!negotiationStartedAt)"
));
assert!(page.body.contains(
"negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.'"
));
}
+24 -18
View File
@@ -50,7 +50,7 @@ use decoy::serve_decoy;
use down::handle_down; use down::handle_down;
use request::{ use request::{
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host, bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
canonical_u64_header, client_ip, compatible_cookie_header, match_profile, canonical_u64_header, carrier_request, client_ip, compatible_cookie_header, match_profile,
}; };
use response::{ use response::{
bad_gateway, carrier_empty, carrier_headers, carrier_lane, full_response, generic_not_found, bad_gateway, carrier_empty, carrier_headers, carrier_lane, full_response, generic_not_found,
@@ -224,7 +224,6 @@ async fn handle_root(
trace.set_route(TraceRoute::Bridge); trace.set_route(TraceRoute::Bridge);
trace.set_effective_ip(client_ip); trace.set_effective_ip(client_ip);
} }
let carrier = profile.carrier;
let bootstrap = match runtime.issue_bootstrap(Arc::clone(&profile), client_ip) { let bootstrap = match runtime.issue_bootstrap(Arc::clone(&profile), client_ip) {
Ok(bootstrap) => bootstrap, Ok(bootstrap) => bootstrap,
Err(error) => { Err(error) => {
@@ -234,7 +233,7 @@ async fn handle_root(
&profile, &profile,
TraceLifecycleEvent::BootstrapRejected, TraceLifecycleEvent::BootstrapRejected,
None, None,
Some(manager_error_reason(error)), Some(error.as_str()),
); );
strip_query(&mut request); strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
@@ -251,7 +250,9 @@ async fn handle_root(
generation.config().web.limits.carrier_batch_bytes, generation.config().web.limits.carrier_batch_bytes,
generation.config().web.limits.pending_bytes_per_session, generation.config().web.limits.pending_bytes_per_session,
generation.config().web.limits.pending_items_per_session, generation.config().web.limits.pending_items_per_session,
carrier, profile.carrier_negotiation_enabled,
profile.carriers.len(),
profile.carrier_negotiation_deadlines_secs,
&generation.rng, &generation.rng,
); );
let mut response = full_response(StatusCode::OK, Bytes::from(page.body)); let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
@@ -354,6 +355,9 @@ async fn handle_session(
if request.method() != Method::POST || !binary_content_type(&request) { if request.method() != Method::POST || !binary_content_type(&request) {
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
} }
let Some(carrier_request) = carrier_request(&request, &vhost.host) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some((trace_session_id, profile)) = let Some((trace_session_id, profile)) =
runtime.bootstrap_trace_identity(token_hash, &vhost.host) runtime.bootstrap_trace_identity(token_hash, &vhost.host)
else { else {
@@ -381,7 +385,13 @@ async fn handle_session(
&runtime.active_generation().config().web.limits, &runtime.active_generation().config().web.limits,
); );
} }
match runtime.create_session(token_hash, &vhost.host, client_ip, &body) { match runtime.create_session(
token_hash,
&vhost.host,
client_ip,
&body,
carrier_request,
) {
Ok(result) => { Ok(result) => {
let welcome = frame::encode(FrameType::Welcome, 0, &[]); let welcome = frame::encode(FrameType::Welcome, 0, &[]);
if let Some(trace) = request_trace(&request) { if let Some(trace) = request_trace(&request) {
@@ -407,6 +417,13 @@ async fn handle_session(
HeaderName::from_static("x-down-cursor"), HeaderName::from_static("x-down-cursor"),
HeaderValue::from_static("0"), HeaderValue::from_static("0"),
); );
if let Some(attempt) = result.attempt {
insert_header(
&mut response,
HeaderName::from_static("x-carrier-attempt"),
&attempt.to_string(),
);
}
response response
} }
Err( Err(
@@ -418,7 +435,7 @@ async fn handle_session(
&profile, &profile,
TraceLifecycleEvent::SessionRejected, TraceLifecycleEvent::SessionRejected,
None, None,
Some(manager_error_reason(error)), Some(error.as_str()),
); );
service_unavailable() service_unavailable()
} }
@@ -429,7 +446,7 @@ async fn handle_session(
&profile, &profile,
TraceLifecycleEvent::SessionRejected, TraceLifecycleEvent::SessionRejected,
None, None,
Some(manager_error_reason(error)), Some(error.as_str()),
); );
serve_decoy(request, vhost, true, &runtime).await serve_decoy(request, vhost, true, &runtime).await
} }
@@ -524,14 +541,3 @@ fn set_trace_route<B>(request: &Request<B>, route: TraceRoute) {
trace.set_route(route); trace.set_route(route);
} }
} }
fn manager_error_reason(error: ManagerError) -> &'static str {
match error {
ManagerError::Authentication => "authentication",
ManagerError::Backpressure => "backpressure",
ManagerError::Limit => "limit",
ManagerError::Protocol => "protocol",
ManagerError::Concurrent => "concurrent",
ManagerError::Closed => "closed",
}
}
+99
View File
@@ -0,0 +1,99 @@
use super::*;
#[tokio::test]
async fn windows_restricted_webview_empty_cookie_preserves_the_carrier_flow() {
for (index, carrier) in [WebCarrier::Https, WebCarrier::HttpsLanes]
.into_iter()
.enumerate()
{
let capability = [12 + index as u8; 32];
let mut config = runtime_config(capability, carrier);
config.web.timeouts.long_poll_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 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\nCookie:\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (root_headers, root_body) = split_response(&root_response);
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap=\"")
.and_then(|(_, suffix)| suffix.split_once('"'))
.map(|(token, _)| token)
.unwrap();
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let create = |cookie: &str| {
let mut request = 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\n{cookie}Content-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
request.extend_from_slice(&hello);
request
};
let nonempty_cookie =
request(&listener, &runtime, create("Cookie: state=unexpected\r\n")).await;
assert!(!nonempty_cookie.starts_with(b"HTTP/1.1 200"));
let duplicate_cookie = request(
&listener,
&runtime,
create("Cookie:\r\nCookie: state=unexpected\r\n"),
)
.await;
assert!(!duplicate_cookie.starts_with(b"HTTP/1.1 200"));
let create_response = request(&listener, &runtime, create("Cookie:\r\n")).await;
let (create_headers, create_body) = split_response(&create_response);
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(
response_header(create_headers, "x-carrier-mode"),
carrier.as_str()
);
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
let session = response_header(create_headers, "x-session-token").to_string();
let lane = if carrier == WebCarrier::HttpsLanes {
"X-Lane-ID: 0\r\n"
} else {
""
};
let pong = frame::encode(FrameType::Pong, 0, &[]);
let mut uplink = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nCookie:\r\nX-Up-Seq: 1\r\n{lane}Content-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
uplink.extend_from_slice(&pong);
let uplink_response = request(&listener, &runtime, uplink).await;
let (uplink_headers, _) = split_response(&uplink_response);
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
let downlink = format!(
"POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nX-Down-Cursor: 0\r\n{lane}Content-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let downlink_response = request(&listener, &runtime, downlink).await;
let (downlink_headers, _) = split_response(&downlink_response);
assert!(downlink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(downlink_headers, "x-down-cursor"), "0");
let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let close_response = request(&listener, &runtime, close).await;
assert!(close_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
}
+176
View File
@@ -0,0 +1,176 @@
use super::*;
use sha2::{Digest, Sha256};
const CAPABILITIES: &str = "https,https-lanes,websocket,websocket-lanes";
fn issue_bootstrap(runtime: &Arc<WebProcessRuntime>, client_ip: &str) -> String {
let profile = runtime
.active_generation()
.config()
.web
.runtime
.as_ref()
.unwrap()
.profiles[0]
.clone();
runtime
.issue_bootstrap(profile, client_ip.parse().unwrap())
.unwrap()
.token
}
fn create_request(
bootstrap: &str,
hello: &[u8],
attempt: Option<u8>,
failure: Option<&str>,
) -> Vec<u8> {
let negotiation = attempt.map_or_else(String::new, |attempt| {
let failure = failure
.map(|failure| format!("X-Carrier-Failure: {failure}\r\n"))
.unwrap_or_default();
format!(
"X-Carrier-Capabilities: {CAPABILITIES}\r\nX-Carrier-Attempt: {attempt}\r\n{failure}"
)
});
let mut request = 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\n{negotiation}Content-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
request.extend_from_slice(hello);
request
}
fn optional_response_header<'a>(headers: &'a [u8], name: &str) -> Option<&'a str> {
std::str::from_utf8(headers)
.unwrap()
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim()))
}
fn token_hash(token: &str) -> crate::web::manager::TokenHash {
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token)
.unwrap();
Sha256::digest(raw).into()
}
#[tokio::test]
async fn absent_carriers_reject_negotiation_and_preserve_legacy_creation() {
let capability = [41; 32];
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&runtime, "192.0.2.10");
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let rejected = request(
&listener,
&runtime,
create_request(&bootstrap, &hello, Some(1), None),
)
.await;
let (rejected_headers, _) = split_response(&rejected);
assert!(optional_response_header(rejected_headers, "x-session-token").is_none());
let legacy = request(
&listener,
&runtime,
create_request(&bootstrap, &hello, None, None),
)
.await;
let (legacy_headers, _) = split_response(&legacy);
assert!(legacy_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(legacy_headers, "x-carrier-mode"), "https");
assert!(optional_response_header(legacy_headers, "x-carrier-attempt").is_none());
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn negotiation_replays_replaces_and_freezes_after_carrier_commit() {
let capability = [42; 32];
let config = negotiation_runtime_config(
capability,
WebCarrier::Websocket,
false,
Arc::from([
WebCarrier::Https,
WebCarrier::HttpsLanes,
WebCarrier::Websocket,
]),
);
let generation = test_runtime_generation(1, config);
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&runtime, "192.0.2.10");
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let first_request = create_request(&bootstrap, &hello, Some(1), None);
let first = request(&listener, &runtime, first_request.clone()).await;
let (first_headers, _) = split_response(&first);
assert_eq!(response_header(first_headers, "x-carrier-mode"), "https");
assert_eq!(response_header(first_headers, "x-carrier-attempt"), "1");
let first_token = response_header(first_headers, "x-session-token").to_string();
let replay = request(&listener, &runtime, first_request).await;
let (replay_headers, _) = split_response(&replay);
assert_eq!(response_header(replay_headers, "x-session-token"), first_token);
let second_request = create_request(&bootstrap, &hello, Some(2), Some("timeout"));
let second = request(&listener, &runtime, second_request.clone()).await;
let (second_headers, _) = split_response(&second);
assert_eq!(
response_header(second_headers, "x-carrier-mode"),
"https-lanes"
);
assert_eq!(response_header(second_headers, "x-carrier-attempt"), "2");
let second_token = response_header(second_headers, "x-session-token").to_string();
assert_ne!(first_token, second_token);
assert!(
runtime
.get_session(token_hash(&first_token), "proxy.example.com")
.is_err()
);
let second_replay = request(&listener, &runtime, second_request).await;
let (second_replay_headers, _) = split_response(&second_replay);
assert_eq!(
response_header(second_replay_headers, "x-session-token"),
second_token
);
let open = frame::encode(FrameType::Open, 7, &[]);
let mut uplink = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {second_token}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nX-Lane-ID: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
open.len()
)
.into_bytes();
uplink.extend_from_slice(&open);
let committed = request(&listener, &runtime, uplink).await;
assert!(committed.starts_with(b"HTTP/1.1 204"));
let third = request(
&listener,
&runtime,
create_request(&bootstrap, &hello, Some(3), Some("http")),
)
.await;
let (third_headers, _) = split_response(&third);
assert!(optional_response_header(third_headers, "x-session-token").is_none());
assert!(
runtime
.get_session(token_hash(&second_token), "proxy.example.com")
.unwrap()
.is_carrier_committed()
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+196 -1
View File
@@ -9,7 +9,11 @@ use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost}; use crate::config::{WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost};
use crate::web::manager::TokenHash; use crate::web::manager::{
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, TokenHash,
};
const USER_AGENT_CONTEXT: &[u8] = b"telemt-web-carrier-user-agent-v1\0";
/// Parses one lowercase canonical Host value restricted to the public HTTPS port. /// Parses one lowercase canonical Host value restricted to the public HTTPS port.
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> { pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
@@ -167,6 +171,124 @@ pub(super) fn canonical_u64_header<B>(request: &Request<B>, name: &'static str)
(parsed.to_string() == value).then_some(parsed) (parsed.to_string() == value).then_some(parsed)
} }
/// Parses strict bridge negotiation metadata without trusting it for authentication.
pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<CarrierRequest> {
let user_agent_hash = normalized_user_agent_hash(request)?;
let capabilities = single_header(request, "x-carrier-capabilities");
let attempt = optional_canonical_u8_header(request, "x-carrier-attempt")?;
let failure = optional_failure_header(request)?;
match (capabilities, attempt) {
(None, None) if failure.is_none() => Some(CarrierRequest::legacy(user_agent_hash)),
(Some(capabilities), Some(attempt)) => {
let capabilities = parse_capabilities(capabilities)?;
if (attempt == 1) != failure.is_none() {
return None;
}
Some(CarrierRequest::automatic(
CarrierClientClass::Bridge,
capabilities,
attempt,
failure,
user_agent_hash,
))
}
(None, Some(attempt)) if strict_browser_hint(request, host) => {
if (attempt == 1) != failure.is_none() {
return None;
}
Some(CarrierRequest::automatic(
CarrierClientClass::BrowserHint,
CarrierCapabilities::all(),
attempt,
failure,
user_agent_hash,
))
}
_ => None,
}
}
fn parse_capabilities(value: &str) -> Option<CarrierCapabilities> {
let mut bits = 0u8;
let mut previous = None;
for token in value.split(',') {
let index = match token {
"https" => 0,
"https-lanes" => 1,
"websocket" => 2,
"websocket-lanes" => 3,
_ => return None,
};
if previous.is_some_and(|previous| index <= previous) {
return None;
}
previous = Some(index);
bits |= 1 << index;
}
CarrierCapabilities::from_bits(bits)
}
fn strict_browser_hint<B>(request: &Request<B>, host: &str) -> bool {
single_header(request, header::ORIGIN)
.is_some_and(|value| value == format!("https://{host}"))
&& single_header(request, "sec-fetch-site") == Some("same-origin")
&& single_header(request, "sec-fetch-mode") == Some("cors")
&& single_header(request, "sec-fetch-dest") == Some("empty")
}
fn optional_canonical_u8_header<B>(
request: &Request<B>,
name: &'static str,
) -> Option<Option<u8>> {
if !request.headers().contains_key(name) {
return Some(None);
}
canonical_u64_header(request, name)
.and_then(|value| u8::try_from(value).ok())
.filter(|value| (1..=4).contains(value))
.map(Some)
}
fn optional_failure_header<B>(request: &Request<B>) -> Option<Option<CarrierFailure>> {
if !request.headers().contains_key("x-carrier-failure") {
return Some(None);
}
single_header(request, "x-carrier-failure")
.and_then(CarrierFailure::parse)
.map(Some)
}
fn normalized_user_agent_hash<B>(request: &Request<B>) -> Option<[u8; 32]> {
let user_agent = match single_header(request, header::USER_AGENT) {
Some(value) => value.as_bytes(),
None if !request.headers().contains_key(header::USER_AGENT) => &[],
None => return None,
};
let mut digest = Sha256::new();
digest.update(USER_AGENT_CONTEXT);
let mut emitted = false;
let mut pending_whitespace = false;
for &byte in user_agent {
if byte.is_ascii_whitespace() {
pending_whitespace = emitted;
} else {
if pending_whitespace {
digest.update([b' ']);
}
digest.update([byte.to_ascii_lowercase()]);
emitted = true;
pending_whitespace = false;
}
}
Some(digest.finalize().into())
}
fn single_header<B>(request: &Request<B>, name: impl header::AsHeaderName) -> Option<&str> {
let mut values = request.headers().get_all(name).iter();
let value = values.next()?.to_str().ok()?;
values.next().is_none().then_some(value)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -319,4 +441,77 @@ mod tests {
.append(header::COOKIE, "state=unexpected".parse().unwrap()); .append(header::COOKIE, "state=unexpected".parse().unwrap());
assert!(!compatible_cookie_header(&duplicate_mixed)); assert!(!compatible_cookie_header(&duplicate_mixed));
} }
#[test]
fn carrier_metadata_is_canonical_and_legacy_safe() {
let automatic = Request::builder()
.header(
"x-carrier-capabilities",
"https,https-lanes,websocket,websocket-lanes",
)
.header("x-carrier-attempt", "2")
.header("x-carrier-failure", "timeout")
.header(header::USER_AGENT, "Example Browser")
.body(())
.unwrap();
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
assert!(parsed.is_automatic());
assert_eq!(parsed.attempt(), Some(2));
assert_eq!(parsed.failure(), Some(CarrierFailure::Timeout));
let missing_failure = Request::builder()
.header(
"x-carrier-capabilities",
"https,https-lanes,websocket,websocket-lanes",
)
.header("x-carrier-attempt", "2")
.body(())
.unwrap();
assert!(carrier_request(&missing_failure, "proxy.example.com").is_none());
let legacy = Request::builder()
.header(header::USER_AGENT, "Native")
.body(())
.unwrap();
assert!(!carrier_request(&legacy, "proxy.example.com")
.unwrap()
.is_automatic());
let reordered = Request::builder()
.header("x-carrier-capabilities", "websocket,https")
.header("x-carrier-attempt", "1")
.body(())
.unwrap();
assert!(carrier_request(&reordered, "proxy.example.com").is_none());
}
#[test]
fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
let request = Request::builder()
.header("x-carrier-attempt", "1")
.header(header::ORIGIN, "https://proxy.example.com")
.header("sec-fetch-site", "same-origin")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-dest", "empty")
.body(())
.unwrap();
let parsed = carrier_request(&request, "proxy.example.com").unwrap();
assert_eq!(parsed.class(), CarrierClientClass::BrowserHint);
}
#[test]
fn user_agent_learning_key_is_case_and_whitespace_normalized() {
let first = Request::builder()
.header(header::USER_AGENT, " Example\t Browser ")
.body(())
.unwrap();
let second = Request::builder()
.header(header::USER_AGENT, "example browser")
.body(())
.unwrap();
assert_eq!(
normalized_user_agent_hash(&first),
normalized_user_agent_hash(&second)
);
}
} }
+40 -104
View File
@@ -10,20 +10,48 @@ use tokio_util::sync::CancellationToken;
use super::serve_connection; use super::serve_connection;
use crate::config::{ use crate::config::{
ProxyConfig, WebCarrier, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy, ProxyConfig, WebCarrier, WebCarriers, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite, WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
}; };
use crate::maestro::generation::test_runtime_generation; use crate::maestro::generation::test_runtime_generation;
use crate::web::frame::{self, FrameType}; use crate::web::frame::{self, FrameType};
use crate::web::manager::WebProcessRuntime; use crate::web::manager::WebProcessRuntime;
#[path = "legacy_tests.rs"]
mod legacy_tests;
#[path = "negotiation_tests.rs"]
mod negotiation_tests;
pub(super) fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig { pub(super) fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
runtime_config_with_carriers(capability, carrier, false, true, Arc::from([carrier]))
}
pub(super) fn negotiation_runtime_config(
capability: [u8; 32],
carrier: WebCarrier,
carrier_learning: bool,
carriers: Arc<[WebCarrier]>,
) -> ProxyConfig {
runtime_config_with_carriers(capability, carrier, true, carrier_learning, carriers)
}
fn runtime_config_with_carriers(
capability: [u8; 32],
carrier: WebCarrier,
carrier_negotiation_enabled: bool,
carrier_learning: bool,
carriers: Arc<[WebCarrier]>,
) -> ProxyConfig {
let profile = Arc::new(WebRuntimeProfile { let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier, carrier,
carrier_negotiation_enabled,
carrier_learning,
carriers: Arc::clone(&carriers),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability, capability,
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 4, max_sessions: 4,
@@ -63,6 +91,12 @@ pub(super) fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> Proxy
let mut config = ProxyConfig::default(); let mut config = ProxyConfig::default();
config.web.enabled = true; config.web.enabled = true;
config.web.carrier = carrier; config.web.carrier = carrier;
config.web.carriers = if carrier_negotiation_enabled {
WebCarriers::Enabled(carriers.to_vec())
} else {
WebCarriers::Disabled
};
config.web.carrier_learning = carrier_learning;
config.web.limits.max_bootstraps_per_ip = 1; config.web.limits.max_bootstraps_per_ip = 1;
config.web.timeouts.shutdown_secs = 1; config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
@@ -106,7 +140,7 @@ pub(super) fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
(&response[..separator], &response[separator + 4..]) (&response[..separator], &response[separator + 4..])
} }
fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str { pub(super) fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
std::str::from_utf8(headers) std::str::from_utf8(headers)
.unwrap() .unwrap()
.lines() .lines()
@@ -187,11 +221,9 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
.windows(11) .windows(11)
.any(|value| value == b"bootstrap=\"") .any(|value| value == b"bootstrap=\"")
); );
assert!( assert!(next_root_body
next_root_body .windows(b"const negotiationEnabled=false".len())
.windows(21) .any(|value| value == b"const negotiationEnabled=false"));
.any(|value| value == b"carrier='https-lanes'")
);
let close = format!( let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" "DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
@@ -375,7 +407,7 @@ async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
let root_response = request(&listener, &runtime, root).await; let root_response = request(&listener, &runtime, root).await;
let (_, root_body) = split_response(&root_response); let (_, root_body) = split_response(&root_response);
let root_body = std::str::from_utf8(root_body).unwrap(); let root_body = std::str::from_utf8(root_body).unwrap();
assert!(root_body.contains("carrier='https-lanes'")); assert!(root_body.contains("const negotiationEnabled=false"));
let bootstrap = root_body let bootstrap = root_body
.split_once("bootstrap=\"") .split_once("bootstrap=\"")
.and_then(|(_, suffix)| suffix.split_once('"')) .and_then(|(_, suffix)| suffix.split_once('"'))
@@ -437,99 +469,3 @@ async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
generation.stop_sessions().await; generation.stop_sessions().await;
generation.stop_background_tasks().await; generation.stop_background_tasks().await;
} }
#[tokio::test]
async fn windows_restricted_webview_empty_cookie_preserves_the_carrier_flow() {
for (index, carrier) in [WebCarrier::Https, WebCarrier::HttpsLanes]
.into_iter()
.enumerate()
{
let capability = [12 + index as u8; 32];
let mut config = runtime_config(capability, carrier);
config.web.timeouts.long_poll_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 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\nCookie:\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (root_headers, root_body) = split_response(&root_response);
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
let root_body = std::str::from_utf8(root_body).unwrap();
let bootstrap = root_body
.split_once("bootstrap=\"")
.and_then(|(_, suffix)| suffix.split_once('"'))
.map(|(token, _)| token)
.unwrap();
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let create = |cookie: &str| {
let mut request = 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\n{cookie}Content-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
request.extend_from_slice(&hello);
request
};
let nonempty_cookie =
request(&listener, &runtime, create("Cookie: state=unexpected\r\n")).await;
assert!(!nonempty_cookie.starts_with(b"HTTP/1.1 200"));
let duplicate_cookie = request(
&listener,
&runtime,
create("Cookie:\r\nCookie: state=unexpected\r\n"),
)
.await;
assert!(!duplicate_cookie.starts_with(b"HTTP/1.1 200"));
let create_response = request(&listener, &runtime, create("Cookie:\r\n")).await;
let (create_headers, create_body) = split_response(&create_response);
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(
response_header(create_headers, "x-carrier-mode"),
carrier.as_str()
);
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
let session = response_header(create_headers, "x-session-token").to_string();
let lane = (carrier == WebCarrier::HttpsLanes)
.then_some("X-Lane-ID: 0\r\n")
.unwrap_or_default();
let pong = frame::encode(FrameType::Pong, 0, &[]);
let mut uplink = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nCookie:\r\nX-Up-Seq: 1\r\n{lane}Content-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
uplink.extend_from_slice(&pong);
let uplink_response = request(&listener, &runtime, uplink).await;
let (uplink_headers, _) = split_response(&uplink_response);
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
let downlink = format!(
"POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nX-Down-Cursor: 0\r\n{lane}Content-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let downlink_response = request(&listener, &runtime, downlink).await;
let (downlink_headers, _) = split_response(&downlink_response);
assert!(downlink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(downlink_headers, "x-down-cursor"), "0");
let close = format!(
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let close_response = request(&listener, &runtime, close).await;
assert!(close_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
}
+32 -18
View File
@@ -200,7 +200,6 @@ impl AsyncRead for ConnectionIo {
Poll::Ready(Ok(())) => { Poll::Ready(Ok(())) => {
let filled = limited.filled().len(); let filled = limited.filled().len();
boundary.observe(limited.filled()); boundary.observe(limited.filled());
drop(limited);
buffer.advance(filled); buffer.advance(filled);
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} }
@@ -244,6 +243,7 @@ struct ParsedUpgrade {
protocol: String, protocol: String,
accept: String, accept: String,
carrier: ParsedCarrier, carrier: ParsedCarrier,
acknowledge_commit: bool,
} }
pub(super) async fn handle( pub(super) async fn handle(
@@ -325,6 +325,7 @@ pub(super) async fn handle(
connection, connection,
lane_reservation.take(), lane_reservation.take(),
trace_context, trace_context,
parsed.acknowledge_commit,
) )
.await; .await;
}); });
@@ -376,21 +377,18 @@ fn parse_upgrade<B>(request: &Request<B>) -> Option<ParsedUpgrade> {
{ {
return None; return None;
} }
let (token, carrier) = if let Some(token) = protocol.strip_prefix("tproxy-v1.") { let (token, carrier, acknowledge_commit) = if let Some(token) =
(token, ParsedCarrier::Multiplex) protocol.strip_prefix("tproxy-auto-v1.")
{
(token, ParsedCarrier::Multiplex, true)
} else if let Some(lane) = protocol.strip_prefix("tproxy-auto-lane-v1.") {
let (token, lane_id) = parse_lane_protocol(lane)?;
(token, ParsedCarrier::Lane(lane_id), true)
} else if let Some(token) = protocol.strip_prefix("tproxy-v1.") {
(token, ParsedCarrier::Multiplex, false)
} else if let Some(lane) = protocol.strip_prefix("tproxy-lane-v1.") { } else if let Some(lane) = protocol.strip_prefix("tproxy-lane-v1.") {
let (token, lane_id) = lane.split_once('.')?; let (token, lane_id) = parse_lane_protocol(lane)?;
if lane_id.is_empty() (token, ParsedCarrier::Lane(lane_id), false)
|| lane_id.starts_with('+')
|| (lane_id.len() > 1 && lane_id.starts_with('0'))
{
return None;
}
let lane_id = lane_id
.parse::<u32>()
.ok()
.filter(|value| (1..=crate::web::frame::MAX_STREAM_ID).contains(value))?;
(token, ParsedCarrier::Lane(lane_id))
} else { } else {
return None; return None;
}; };
@@ -412,13 +410,29 @@ fn parse_upgrade<B>(request: &Request<B>) -> Option<ParsedUpgrade> {
protocol: protocol.to_string(), protocol: protocol.to_string(),
accept: base64::engine::general_purpose::STANDARD.encode(accept.finalize()), accept: base64::engine::general_purpose::STANDARD.encode(accept.finalize()),
carrier, carrier,
acknowledge_commit,
}) })
} }
fn single_header<'a, B>( fn parse_lane_protocol(value: &str) -> Option<(&str, u32)> {
request: &'a Request<B>, let (token, lane_id) = value.split_once('.')?;
if lane_id.is_empty()
|| lane_id.starts_with('+')
|| (lane_id.len() > 1 && lane_id.starts_with('0'))
{
return None;
}
let lane_id = lane_id
.parse::<u32>()
.ok()
.filter(|value| (1..=crate::web::frame::MAX_STREAM_ID).contains(value))?;
Some((token, lane_id))
}
fn single_header<B>(
request: &Request<B>,
name: impl hyper::header::AsHeaderName, name: impl hyper::header::AsHeaderName,
) -> Option<&'a str> { ) -> Option<&str> {
let mut values = request.headers().get_all(name).iter(); let mut values = request.headers().get_all(name).iter();
let value = values.next()?.to_str().ok()?; let value = values.next()?.to_str().ok()?;
values.next().is_none().then_some(value) values.next().is_none().then_some(value)
+36 -203
View File
@@ -2,7 +2,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bytes::Bytes; use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::protocol::{Message, Role, WebSocketConfig}; use tokio_tungstenite::tungstenite::protocol::{Message, Role, WebSocketConfig};
@@ -10,7 +9,7 @@ use tokio_util::sync::CancellationToken;
use super::ConnectionIo; use super::ConnectionIo;
use crate::web::manager::{ use crate::web::manager::{
ManagerError, WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection, WebProcessRuntime, WebSocketBudgetLease, WebSocketConnection,
}; };
use crate::web::session::{WebSession, WebSocketLaneReservation}; use crate::web::session::{WebSession, WebSocketLaneReservation};
use crate::web::trace::{TraceDirection, TraceWebSocketContext}; use crate::web::trace::{TraceDirection, TraceWebSocketContext};
@@ -18,6 +17,10 @@ use crate::web::trace::{TraceDirection, TraceWebSocketContext};
const READ_BUFFER_BYTES: usize = 64 * 1024; const READ_BUFFER_BYTES: usize = 64 * 1024;
const WRITE_BUFFER_BYTES: usize = 64 * 1024; const WRITE_BUFFER_BYTES: usize = 64 * 1024;
// Cancellation-safe message I/O and budget retries remain separate from carrier loops.
mod io;
use io::{flush, process_lane, process_multiplex, read_message, record_message, reserve_data, send};
pub(super) async fn run_upgraded( pub(super) async fn run_upgraded(
on_upgrade: hyper::upgrade::OnUpgrade, on_upgrade: hyper::upgrade::OnUpgrade,
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
@@ -25,6 +28,7 @@ pub(super) async fn run_upgraded(
connection: WebSocketConnection, connection: WebSocketConnection,
mut lane_reservation: Option<WebSocketLaneReservation>, mut lane_reservation: Option<WebSocketLaneReservation>,
trace: Option<TraceWebSocketContext>, trace: Option<TraceWebSocketContext>,
acknowledge_commit: bool,
) { ) {
let Ok(upgraded) = on_upgrade.await else { let Ok(upgraded) = on_upgrade.await else {
return; return;
@@ -57,6 +61,7 @@ pub(super) async fn run_upgraded(
reservation, reservation,
cancellation.clone(), cancellation.clone(),
trace.as_ref(), trace.as_ref(),
acknowledge_commit,
) )
.await; .await;
} else { } else {
@@ -67,6 +72,7 @@ pub(super) async fn run_upgraded(
&connection, &connection,
cancellation.clone(), cancellation.clone(),
trace.as_ref(), trace.as_ref(),
acknowledge_commit,
) )
.await; .await;
} }
@@ -82,7 +88,7 @@ pub(super) async fn run_upgraded(
if let Some(reservation) = lane_reservation { if let Some(reservation) = lane_reservation {
session.close_websocket_lane(reservation.lane_id()); session.close_websocket_lane(reservation.lane_id());
drop(reservation); drop(reservation);
} else { } else if !acknowledge_commit || session.is_carrier_committed() {
session.close(); session.close();
} }
} }
@@ -96,6 +102,7 @@ async fn run_multiplex(
connection: &WebSocketConnection, connection: &WebSocketConnection,
cancellation: CancellationToken, cancellation: CancellationToken,
trace: Option<&TraceWebSocketContext>, trace: Option<&TraceWebSocketContext>,
acknowledge_commit: bool,
) -> Result<(), ()> { ) -> Result<(), ()> {
let mut sequence = 1u64; let mut sequence = 1u64;
let mut cursor = 0u64; let mut cursor = 0u64;
@@ -136,6 +143,18 @@ async fn run_multiplex(
started, started,
); );
result?; result?;
if acknowledge_commit && sequence == 1 && session.is_carrier_committed() {
let started = Instant::now();
send(socket, runtime, Message::Binary(Bytes::new())).await?;
record_message(
runtime,
trace,
TraceDirection::Response,
"carrier-ack",
&[],
started,
);
}
sequence = sequence.checked_add(1).ok_or(())?; sequence = sequence.checked_add(1).ok_or(())?;
connection.mark_peer_activity(); connection.mark_peer_activity();
next_ping = Instant::now() + liveness_interval; next_ping = Instant::now() + liveness_interval;
@@ -255,6 +274,7 @@ async fn run_multiplex(
} }
} }
#[allow(clippy::too_many_arguments)]
async fn run_lane( async fn run_lane(
socket: &mut CarrierSocket, socket: &mut CarrierSocket,
runtime: &Arc<WebProcessRuntime>, runtime: &Arc<WebProcessRuntime>,
@@ -263,6 +283,7 @@ async fn run_lane(
reservation: &mut WebSocketLaneReservation, reservation: &mut WebSocketLaneReservation,
cancellation: CancellationToken, cancellation: CancellationToken,
trace: Option<&TraceWebSocketContext>, trace: Option<&TraceWebSocketContext>,
acknowledge_commit: bool,
) -> Result<(), ()> { ) -> Result<(), ()> {
let mut sequence = 1u64; let mut sequence = 1u64;
let mut cursor = 0u64; let mut cursor = 0u64;
@@ -309,6 +330,18 @@ async fn run_lane(
started, started,
); );
result?; result?;
if acknowledge_commit && sequence == 1 && session.is_carrier_committed() {
let started = Instant::now();
send(socket, runtime, Message::Binary(Bytes::new())).await?;
record_message(
runtime,
trace,
TraceDirection::Response,
"carrier-ack",
&[],
started,
);
}
sequence = sequence.checked_add(1).ok_or(())?; sequence = sequence.checked_add(1).ok_or(())?;
connection.mark_peer_activity(); connection.mark_peer_activity();
next_ping = Instant::now() + liveness_interval; next_ping = Instant::now() + liveness_interval;
@@ -436,203 +469,3 @@ enum DriverEvent {
Down(crate::web::session::PollResult), Down(crate::web::session::PollResult),
Liveness, Liveness,
} }
async fn read_message(
socket: &mut CarrierSocket,
runtime: &Arc<WebProcessRuntime>,
owner: crate::web::manager::ProfileKey,
cancellation: &CancellationToken,
retained_budget: &mut Option<WebSocketBudgetLease>,
) -> Result<(Message, Option<WebSocketBudgetLease>), ()> {
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
ready = socket.get_ref().readable() => ready.map_err(|_| ())?,
}
if retained_budget.is_none() {
let maximum = runtime
.active_generation()
.config()
.web
.limits
.carrier_batch_bytes;
*retained_budget = Some(reserve_data(runtime, owner, maximum, cancellation).await?);
}
let message = tokio::select! {
_ = cancellation.cancelled() => return Err(()),
message = socket.next() => message.ok_or(())?.map_err(|_| ())?,
};
if socket.get_ref().websocket_fragmented_message() {
return Ok((message, None));
}
let mut budget = retained_budget.take().ok_or(())?;
budget.shrink_to(message.len());
Ok((message, Some(budget)))
}
async fn reserve_data(
runtime: &Arc<WebProcessRuntime>,
owner: crate::web::manager::ProfileKey,
bytes: usize,
cancellation: &CancellationToken,
) -> Result<WebSocketBudgetLease, ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
if let Some(budget) = runtime.try_websocket_data_budget(owner, bytes.max(1)) {
return Ok(budget);
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
async fn process_multiplex(
runtime: &Arc<WebProcessRuntime>,
session: &Arc<WebSession>,
sequence: u64,
body: &[u8],
cancellation: &CancellationToken,
) -> Result<(), ()> {
retry_backpressure(runtime, cancellation, || {
session.process_up(sequence, body).map(|_| ())
})
.await
}
async fn process_lane(
runtime: &Arc<WebProcessRuntime>,
session: &Arc<WebSession>,
reservation: &mut WebSocketLaneReservation,
sequence: u64,
body: &[u8],
cancellation: &CancellationToken,
) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
match session.process_websocket_lane(reservation, sequence, body) {
Ok(()) => return Ok(()),
Err(ManagerError::Backpressure) => {}
Err(_) => return Err(()),
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
async fn retry_backpressure<F>(
runtime: &Arc<WebProcessRuntime>,
cancellation: &CancellationToken,
mut operation: F,
) -> Result<(), ()>
where
F: FnMut() -> Result<(), ManagerError>,
{
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
match operation() {
Ok(()) => return Ok(()),
Err(ManagerError::Backpressure) => {}
Err(_) => return Err(()),
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
async fn send(
socket: &mut CarrierSocket,
runtime: &WebProcessRuntime,
message: Message,
) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_write_secs,
);
tokio::time::timeout(timeout, socket.send(message))
.await
.map_err(|_| ())?
.map_err(|_| ())
}
async fn flush(socket: &mut CarrierSocket, runtime: &WebProcessRuntime) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_write_secs,
);
tokio::time::timeout(timeout, socket.flush())
.await
.map_err(|_| ())?
.map_err(|_| ())
}
fn record_message(
runtime: &WebProcessRuntime,
trace: Option<&TraceWebSocketContext>,
direction: TraceDirection,
message_type: &'static str,
payload: &[u8],
started: Instant,
) {
let Some(trace) = trace else {
return;
};
runtime.trace().record_websocket_message(
trace,
direction,
message_type,
payload,
started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
);
}
+214
View File
@@ -0,0 +1,214 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::protocol::Message;
use tokio_util::sync::CancellationToken;
use super::CarrierSocket;
use crate::web::manager::{ManagerError, WebProcessRuntime, WebSocketBudgetLease};
use crate::web::session::{WebSession, WebSocketLaneReservation};
use crate::web::trace::{TraceDirection, TraceWebSocketContext};
pub(super) async fn read_message(
socket: &mut CarrierSocket,
runtime: &Arc<WebProcessRuntime>,
owner: crate::web::manager::ProfileKey,
cancellation: &CancellationToken,
retained_budget: &mut Option<WebSocketBudgetLease>,
) -> Result<(Message, Option<WebSocketBudgetLease>), ()> {
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
ready = socket.get_ref().readable() => ready.map_err(|_| ())?,
}
if retained_budget.is_none() {
let maximum = runtime
.active_generation()
.config()
.web
.limits
.carrier_batch_bytes;
*retained_budget = Some(reserve_data(runtime, owner, maximum, cancellation).await?);
}
let message = tokio::select! {
_ = cancellation.cancelled() => return Err(()),
message = socket.next() => message.ok_or(())?.map_err(|_| ())?,
};
if socket.get_ref().websocket_fragmented_message() {
return Ok((message, None));
}
let mut budget = retained_budget.take().ok_or(())?;
budget.shrink_to(message.len());
Ok((message, Some(budget)))
}
pub(super) async fn reserve_data(
runtime: &Arc<WebProcessRuntime>,
owner: crate::web::manager::ProfileKey,
bytes: usize,
cancellation: &CancellationToken,
) -> Result<WebSocketBudgetLease, ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
if let Some(budget) = runtime.try_websocket_data_budget(owner, bytes.max(1)) {
return Ok(budget);
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
pub(super) async fn process_multiplex(
runtime: &Arc<WebProcessRuntime>,
session: &Arc<WebSession>,
sequence: u64,
body: &[u8],
cancellation: &CancellationToken,
) -> Result<(), ()> {
retry_backpressure(runtime, cancellation, || {
session.process_up(sequence, body).map(|_| ())
})
.await
}
pub(super) async fn process_lane(
runtime: &Arc<WebProcessRuntime>,
session: &Arc<WebSession>,
reservation: &mut WebSocketLaneReservation,
sequence: u64,
body: &[u8],
cancellation: &CancellationToken,
) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
match session.process_websocket_lane(reservation, sequence, body) {
Ok(()) => return Ok(()),
Err(ManagerError::Backpressure) => {}
Err(_) => return Err(()),
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
async fn retry_backpressure<F>(
runtime: &Arc<WebProcessRuntime>,
cancellation: &CancellationToken,
mut operation: F,
) -> Result<(), ()>
where
F: FnMut() -> Result<(), ManagerError>,
{
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_backpressure_secs,
);
tokio::time::timeout(timeout, async {
loop {
let notify = runtime.budget_notify();
let notified = notify.notified();
match operation() {
Ok(()) => return Ok(()),
Err(ManagerError::Backpressure) => {}
Err(_) => return Err(()),
}
tokio::select! {
_ = cancellation.cancelled() => return Err(()),
_ = notified => {}
}
}
})
.await
.map_err(|_| ())?
}
pub(super) async fn send(
socket: &mut CarrierSocket,
runtime: &WebProcessRuntime,
message: Message,
) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_write_secs,
);
tokio::time::timeout(timeout, socket.send(message))
.await
.map_err(|_| ())?
.map_err(|_| ())
}
pub(super) async fn flush(
socket: &mut CarrierSocket,
runtime: &WebProcessRuntime,
) -> Result<(), ()> {
let timeout = Duration::from_secs(
runtime
.active_generation()
.config()
.web
.timeouts
.websocket_write_secs,
);
tokio::time::timeout(timeout, socket.flush())
.await
.map_err(|_| ())?
.map_err(|_| ())
}
pub(super) fn record_message(
runtime: &WebProcessRuntime,
trace: Option<&TraceWebSocketContext>,
direction: TraceDirection,
message_type: &'static str,
payload: &[u8],
started: Instant,
) {
let Some(trace) = trace else {
return;
};
runtime.trace().record_websocket_message(
trace,
direction,
message_type,
payload,
started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64,
);
}
+150 -4
View File
@@ -14,8 +14,10 @@ use tokio_util::sync::CancellationToken;
use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation}; use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation};
use crate::web::frame::{self, FrameType}; use crate::web::frame::{self, FrameType};
use crate::web::http::tests::runtime_config; use crate::web::http::tests::{negotiation_runtime_config, runtime_config};
use crate::web::manager::WebProcessRuntime; use crate::web::manager::{
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime,
};
fn request(protocol: &str) -> Request<()> { fn request(protocol: &str) -> Request<()> {
Request::builder() Request::builder()
@@ -39,6 +41,14 @@ fn canonical_multiplex_and_lane_protocols_are_accepted() {
let lane = parse_upgrade(&request(&format!("tproxy-lane-v1.{token}.16777215"))).unwrap(); let lane = parse_upgrade(&request(&format!("tproxy-lane-v1.{token}.16777215"))).unwrap();
assert!(matches!(lane.carrier, ParsedCarrier::Lane(16_777_215))); assert!(matches!(lane.carrier, ParsedCarrier::Lane(16_777_215)));
let automatic = parse_upgrade(&request(&format!("tproxy-auto-v1.{token}"))).unwrap();
assert!(automatic.acknowledge_commit);
let automatic_lane =
parse_upgrade(&request(&format!("tproxy-auto-lane-v1.{token}.7"))).unwrap();
assert!(matches!(automatic_lane.carrier, ParsedCarrier::Lane(7)));
assert!(automatic_lane.acknowledge_commit);
assert!(!multiplex.acknowledge_commit);
} }
#[test] #[test]
@@ -92,7 +102,20 @@ fn live_runtime(carrier: WebCarrier) -> LiveRuntime {
} }
fn live_runtime_with_long_poll(carrier: WebCarrier, long_poll_secs: u64) -> LiveRuntime { fn live_runtime_with_long_poll(carrier: WebCarrier, long_poll_secs: u64) -> LiveRuntime {
let mut config = runtime_config([31; 32], carrier); live_runtime_from_config(runtime_config([31; 32], carrier), long_poll_secs)
}
fn live_negotiation_runtime(carrier: WebCarrier, carriers: Arc<[WebCarrier]>) -> LiveRuntime {
live_runtime_from_config(
negotiation_runtime_config([31; 32], carrier, false, carriers),
1,
)
}
fn live_runtime_from_config(
mut config: crate::config::ProxyConfig,
long_poll_secs: u64,
) -> LiveRuntime {
config.web.timeouts.long_poll_secs = long_poll_secs; config.web.timeouts.long_poll_secs = long_poll_secs;
config.web.timeouts.websocket_write_secs = 2; config.web.timeouts.websocket_write_secs = 2;
config.web.timeouts.websocket_backpressure_secs = 2; config.web.timeouts.websocket_backpressure_secs = 2;
@@ -105,6 +128,48 @@ fn live_runtime_with_long_poll(carrier: WebCarrier, long_poll_secs: u64) -> Live
} }
} }
fn create_automatic_session(
runtime: &Arc<WebProcessRuntime>,
) -> (TokenHash, Bytes, String, TokenHash) {
let profile = runtime
.active_generation()
.config()
.web
.runtime
.as_ref()
.unwrap()
.profiles[0]
.clone();
let client_ip = "192.0.2.10".parse().unwrap();
let bootstrap = runtime.issue_bootstrap(profile, client_ip).unwrap().token;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&bootstrap)
.unwrap();
let bootstrap_hash = Sha256::digest(raw).into();
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let session = runtime
.create_session(
bootstrap_hash,
"proxy.example.com",
client_ip,
&hello,
CarrierRequest::automatic(
CarrierClientClass::Bridge,
CarrierCapabilities::all(),
1,
None,
[9; 32],
),
)
.unwrap()
.token;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&session)
.unwrap();
let session_hash = Sha256::digest(raw).into();
(bootstrap_hash, hello, session, session_hash)
}
fn create_session(runtime: &Arc<WebProcessRuntime>) -> (String, TokenHash) { fn create_session(runtime: &Arc<WebProcessRuntime>) -> (String, TokenHash) {
let profile = runtime let profile = runtime
.active_generation() .active_generation()
@@ -123,7 +188,13 @@ fn create_session(runtime: &Arc<WebProcessRuntime>) -> (String, TokenHash) {
let bootstrap_hash = Sha256::digest(raw).into(); let bootstrap_hash = Sha256::digest(raw).into();
let hello = frame::encode(FrameType::Hello, 0, &[1]); let hello = frame::encode(FrameType::Hello, 0, &[1]);
let session = runtime let session = runtime
.create_session(bootstrap_hash, "proxy.example.com", client_ip, &hello) .create_session(
bootstrap_hash,
"proxy.example.com",
client_ip,
&hello,
CarrierRequest::legacy([0; 32]),
)
.unwrap() .unwrap()
.token; .token;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
@@ -309,3 +380,78 @@ async fn malformed_websocket_lane_closes_only_that_lane() {
let _ = second.close(None).await; let _ = second.close(None).await;
live.shutdown().await; live.shutdown().await;
} }
#[tokio::test]
async fn automatic_websocket_carriers_ack_the_first_committing_message() {
for carrier in [WebCarrier::Websocket, WebCarrier::WebsocketLanes] {
let live = live_negotiation_runtime(carrier, Arc::from([carrier]));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (_, _, session, session_hash) = create_automatic_session(&live.runtime);
let protocol = match carrier {
WebCarrier::Websocket => format!("tproxy-auto-v1.{session}"),
WebCarrier::WebsocketLanes => format!("tproxy-auto-lane-v1.{session}.7"),
_ => unreachable!(),
};
let mut socket = upgrade(&listener, &live.runtime, &protocol).await;
socket
.send(Message::Binary(frame::encode(FrameType::Open, 7, &[])))
.await
.unwrap();
let acknowledgement = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(acknowledgement, Message::Binary(Bytes::new()));
assert!(
live.runtime
.get_session(session_hash, "proxy.example.com")
.unwrap()
.is_carrier_committed()
);
let _ = socket.close(None).await;
live.shutdown().await;
}
}
#[tokio::test]
async fn failed_automatic_multiplex_socket_remains_supersedable() {
let live = live_negotiation_runtime(
WebCarrier::Https,
Arc::from([WebCarrier::Websocket, WebCarrier::Https]),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (bootstrap_hash, hello, session, session_hash) =
create_automatic_session(&live.runtime);
let protocol = format!("tproxy-auto-v1.{session}");
let mut socket = upgrade(&listener, &live.runtime, &protocol).await;
socket.close(None).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
live.runtime
.get_session(session_hash, "proxy.example.com")
.is_ok()
);
let replacement = live
.runtime
.create_session(
bootstrap_hash,
"proxy.example.com",
"192.0.2.10".parse().unwrap(),
&hello,
CarrierRequest::automatic(
CarrierClientClass::Bridge,
CarrierCapabilities::all(),
2,
Some(CarrierFailure::Upgrade),
[9; 32],
),
)
.unwrap();
assert_eq!(replacement.carrier, WebCarrier::Https);
assert_eq!(replacement.attempt, Some(2));
live.shutdown().await;
}
+29
View File
@@ -16,8 +16,14 @@ use crate::web::trace::WebTraceStore;
// Credential maps, quotas, and token-bucket helpers remain private to the manager. // Credential maps, quotas, and token-bucket helpers remain private to the manager.
mod state; mod state;
// Carrier attempt metadata remains explicit and independent from HTTP parsing.
mod negotiation;
// Bounded process-local carrier evidence is isolated from session registries.
mod learning;
// Bootstrap credentials and idempotent session creation are isolated from queue accounting. // Bootstrap credentials and idempotent session creation are isolated from queue accounting.
mod credentials; mod credentials;
// First-session admission and bounded carrier replacement share one state machine.
mod session_creation;
// Stream admission and synthetic tuple ownership are process-scoped. // Stream admission and synthetic tuple ownership are process-scoped.
mod admission; mod admission;
// Shutdown and expiry work remain outside request-path coordination. // Shutdown and expiry work remain outside request-path coordination.
@@ -29,6 +35,9 @@ mod websocket;
pub(crate) use budget::WebSocketBudgetLease; pub(crate) use budget::WebSocketBudgetLease;
use budget::{WebDataBudget, WebSocketBudgetClass}; use budget::{WebDataBudget, WebSocketBudgetClass};
use state::ManagerState; use state::ManagerState;
pub(crate) use negotiation::{
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierLearningContext, CarrierRequest,
};
pub(crate) use websocket::{WebSocketConnection, WebSocketKind}; pub(crate) use websocket::{WebSocketConnection, WebSocketKind};
const TOKEN_BYTES: usize = 32; const TOKEN_BYTES: usize = 32;
@@ -56,12 +65,28 @@ pub(crate) enum ManagerError {
Closed, Closed,
} }
impl ManagerError {
/// Returns the stable non-sensitive failure token used by WEB diagnostics.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Authentication => "authentication",
Self::Backpressure => "backpressure",
Self::Limit => "limit",
Self::Protocol => "protocol",
Self::Concurrent => "concurrent",
Self::Closed => "closed",
}
}
}
/// Successful idempotent session creation result. /// Successful idempotent session creation result.
pub(crate) struct CreateResult { pub(crate) struct CreateResult {
/// Opaque bearer token for the created or replayed session. /// Opaque bearer token for the created or replayed session.
pub(crate) token: String, pub(crate) token: String,
/// Carrier frozen into the created or replayed session. /// Carrier frozen into the created or replayed session.
pub(crate) carrier: WebCarrier, pub(crate) carrier: WebCarrier,
/// One-based carrier attempt echoed only for negotiated sessions.
pub(crate) attempt: Option<u8>,
} }
/// Successful bridge bootstrap issuance result. /// Successful bridge bootstrap issuance result.
@@ -78,6 +103,7 @@ pub(crate) struct WebProcessRuntime {
trace: Arc<WebTraceStore>, trace: Arc<WebTraceStore>,
limits: WebLimitsConfig, limits: WebLimitsConfig,
state: Mutex<ManagerState>, state: Mutex<ManagerState>,
learning: Mutex<learning::CarrierLearning>,
http_connections: Arc<Semaphore>, http_connections: Arc<Semaphore>,
http_handlers: Arc<Semaphore>, http_handlers: Arc<Semaphore>,
lane_polls: Arc<Semaphore>, lane_polls: Arc<Semaphore>,
@@ -116,6 +142,7 @@ impl WebProcessRuntime {
trace: Arc<WebTraceStore>, trace: Arc<WebTraceStore>,
) -> Arc<Self> { ) -> Arc<Self> {
let limits = active_runtime.load().config().web.limits.clone(); let limits = active_runtime.load().config().web.limits.clone();
let learning_capacity = limits.max_carrier_learning_entries;
let websocket_connections = limits let websocket_connections = limits
.max_http_connections .max_http_connections
.saturating_sub(limits.websocket_http_connection_reserve); .saturating_sub(limits.websocket_http_connection_reserve);
@@ -136,6 +163,7 @@ impl WebProcessRuntime {
data_budget: WebDataBudget::new(limits.clone()), data_budget: WebDataBudget::new(limits.clone()),
limits, limits,
state: Mutex::new(ManagerState::default()), state: Mutex::new(ManagerState::default()),
learning: Mutex::new(learning::CarrierLearning::new(learning_capacity)),
shutdown: CancellationToken::new(), shutdown: CancellationToken::new(),
tasks: TaskTracker::new(), tasks: TaskTracker::new(),
sessions_created: AtomicU64::new(0), sessions_created: AtomicU64::new(0),
@@ -307,6 +335,7 @@ impl WebProcessRuntime {
} }
/// Admits one WebSocket with owner-first bounded replacement. /// Admits one WebSocket with owner-first bounded replacement.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn admit_websocket( pub(crate) async fn admit_websocket(
self: &Arc<Self>, self: &Arc<Self>,
owner: ProfileKey, owner: ProfileKey,
+10 -130
View File
@@ -3,19 +3,14 @@ use std::sync::Arc;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing; use zeroize::Zeroizing;
use super::state::{ use super::state::{
Bootstrap, allow_rate, decrement_map, evict_oldest_unused_bootstrap, matching_profile, Bootstrap, allow_rate, evict_oldest_unused_bootstrap, matching_profile, new_unique_token,
new_unique_token, profile_key, remove_expired_locked, remove_expired_locked,
};
use super::{
BootstrapResult, CreateResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime,
}; };
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
use crate::config::WebRuntimeProfile; use crate::config::WebRuntimeProfile;
use crate::web::frame;
use crate::web::session::WebSession; use crate::web::session::WebSession;
impl WebProcessRuntime { impl WebProcessRuntime {
@@ -78,6 +73,13 @@ impl WebProcessRuntime {
body_digest: [0; TOKEN_BYTES], body_digest: [0; TOKEN_BYTES],
session_token: Zeroizing::new(String::new()), session_token: Zeroizing::new(String::new()),
session: None, session: None,
carrier_request: None,
carrier_candidates: Arc::from([]),
carrier_scores: [0; 4],
carrier_attempt: 0,
carrier_transitioning: false,
carrier_committed: false,
session_client_ip: None,
used: false, used: false,
}, },
); );
@@ -117,128 +119,6 @@ impl WebProcessRuntime {
.map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile))) .map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile)))
} }
/// Creates a session exactly once or replays the original successful result.
pub(crate) fn create_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
host: &str,
client_ip: IpAddr,
body: &[u8],
) -> std::result::Result<CreateResult, ManagerError> {
if !frame::validate_hello(body, &self.limits) {
return Err(ManagerError::Protocol);
}
let body_digest: TokenHash = Sha256::digest(body).into();
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
return Err(ManagerError::Authentication);
};
if entry.profile.host != host || now > entry.expires_at {
return Err(ManagerError::Authentication);
}
if entry.used {
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
if !digest_matches {
return Err(ManagerError::Authentication);
}
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
let result = CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
};
let identity = session.trace_identity();
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
crate::web::trace::TraceLifecycleEvent::SessionReplayed,
None,
None,
);
return Ok(result);
}
let trace_session_id = entry.trace_session_id;
let issued_profile = Arc::clone(&entry.profile);
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &issued_profile))
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if state.sessions.len() >= self.limits.max_sessions_global
|| state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
>= self.limits.max_sessions_per_ip
|| state
.sessions_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
>= profile.max_sessions
|| !allow_rate(
&mut state.session_rate,
now,
self.limits.new_sessions_per_minute,
self.limits.new_sessions_burst,
)
{
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
trace_session_id,
profile,
profile_key,
self.limits.clone(),
config.web.timeouts.clone(),
);
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.used = true;
entry.body_digest = body_digest;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
let issuance_ip = entry.issuance_ip;
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.sessions_created.fetch_add(1, Ordering::Relaxed);
let identity = session.trace_identity();
let result = CreateResult {
token: session_token,
carrier: session.carrier(),
};
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
crate::web::trace::TraceLifecycleEvent::SessionCreated,
None,
None,
);
Ok(result)
}
/// Resolves an authenticated session token. /// Resolves an authenticated session token.
pub(crate) fn get_session( pub(crate) fn get_session(
&self, &self,
+346
View File
@@ -0,0 +1,346 @@
/// Telemt Carrier Selection and Failure Dampening - Copyright 2077
/// anhand des Kundenverhaltens Rückschlüsse gegen DSGVO ziehen...?!
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use super::negotiation::{CarrierClientClass, CarrierLearningContext};
use super::ProfileKey;
use crate::config::WebCarrier;
const PROFILE_WEIGHT: i16 = 4;
const USER_AGENT_WEIGHT: i16 = 4;
const IP_WEIGHT: i16 = 1;
const SCORE_MIN: i8 = -8;
const SCORE_MAX: i8 = 8;
const PROFILE_MIN_OUTCOMES: u8 = 8;
const PROFILE_MIN_COHORTS: usize = 4;
const COHORT_CONTEXT: &[u8] = b"telemt-web-carrier-cohort-v1\0";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum EvidenceKey {
Profile(ProfileKey),
UserAgent(ProfileKey, CarrierClientClass, [u8; 32]),
Ip(ProfileKey, IpAddr),
}
struct Evidence {
created_at: Instant,
lifetime: Duration,
scores: [i8; 4],
outcomes: u8,
cohorts: [Option<[u8; 32]>; PROFILE_MIN_COHORTS],
}
impl Evidence {
fn new(created_at: Instant, lifetime: Duration) -> Self {
Self {
created_at,
lifetime,
scores: [0; 4],
outcomes: 0,
cohorts: [None; PROFILE_MIN_COHORTS],
}
}
fn update(&mut self, carrier: WebCarrier, delta: i8, cohort: Option<[u8; 32]>) {
let score = &mut self.scores[carrier.index()];
*score = score.saturating_add(delta).clamp(SCORE_MIN, SCORE_MAX);
self.outcomes = self.outcomes.saturating_add(1).min(PROFILE_MIN_OUTCOMES);
if let Some(cohort) = cohort
&& !self.cohorts.contains(&Some(cohort))
&& let Some(slot) = self.cohorts.iter_mut().find(|slot| slot.is_none())
{
*slot = Some(cohort);
}
}
}
/// Process-local bounded fixed-window carrier evidence store.
pub(super) struct CarrierLearning {
entries: HashMap<EvidenceKey, Evidence>,
capacity: usize,
}
impl CarrierLearning {
/// Creates an empty store under the restart-owned capacity ceiling.
pub(super) fn new(capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(capacity),
capacity,
}
}
/// Ranks supported configured candidates using only unexpired evidence.
pub(super) fn rank(
&mut self,
now: Instant,
configured: &[WebCarrier],
request: super::CarrierRequest,
profile_key: ProfileKey,
client_ip: IpAddr,
) -> (Vec<WebCarrier>, [i16; 4]) {
self.prune(now);
let mut scores = [0i16; 4];
let profile = self.entries.get(&EvidenceKey::Profile(profile_key));
let profile_ready = profile.is_some_and(|entry| {
entry.outcomes >= PROFILE_MIN_OUTCOMES
&& entry.cohorts.iter().flatten().count() >= PROFILE_MIN_COHORTS
});
let user_agent = self.entries.get(&EvidenceKey::UserAgent(
profile_key,
request.class(),
request.user_agent_hash(),
));
let ip = self.entries.get(&EvidenceKey::Ip(profile_key, client_ip));
for carrier in WebCarrier::ALL {
let index = carrier.index();
if profile_ready {
scores[index] += i16::from(profile.map_or(0, |entry| entry.scores[index]))
* PROFILE_WEIGHT;
}
scores[index] += i16::from(user_agent.map_or(0, |entry| entry.scores[index]))
* USER_AGENT_WEIGHT;
scores[index] +=
i16::from(ip.map_or(0, |entry| entry.scores[index])) * IP_WEIGHT;
}
let mut ranked = configured
.iter()
.copied()
.filter(|carrier| request.supports(*carrier))
.collect::<Vec<_>>();
ranked.sort_by_key(|carrier| std::cmp::Reverse(scores[carrier.index()]));
(ranked, scores)
}
/// Records one committed success or one server-accepted supersession failure.
pub(super) fn record(
&mut self,
now: Instant,
lifetime: Duration,
context: CarrierLearningContext,
carrier: WebCarrier,
success: bool,
) {
self.prune(now);
let delta = if success { 1 } else { -1 };
let cohort = cohort_hash(context);
self.update(
EvidenceKey::Profile(context.profile_key),
now,
lifetime,
carrier,
delta,
Some(cohort),
);
self.update(
EvidenceKey::UserAgent(
context.profile_key,
context.class,
context.user_agent_hash,
),
now,
lifetime,
carrier,
delta,
None,
);
self.update(
EvidenceKey::Ip(context.profile_key, context.client_ip),
now,
lifetime,
carrier,
delta,
None,
);
}
/// Removes fixed-window entries after their creation-time expiry.
pub(super) fn prune(&mut self, now: Instant) {
self.entries.retain(|_, entry| {
now.saturating_duration_since(entry.created_at) <= entry.lifetime
});
}
fn update(
&mut self,
key: EvidenceKey,
now: Instant,
lifetime: Duration,
carrier: WebCarrier,
delta: i8,
cohort: Option<[u8; 32]>,
) {
if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
let oldest = self
.entries
.iter()
.min_by_key(|(_, entry)| entry.created_at)
.map(|(key, _)| *key);
if let Some(oldest) = oldest {
self.entries.remove(&oldest);
}
}
self.entries
.entry(key)
.or_insert_with(|| Evidence::new(now, lifetime))
.update(carrier, delta, cohort);
}
}
fn cohort_hash(context: CarrierLearningContext) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update(COHORT_CONTEXT);
digest.update(context.profile_key);
digest.update([match context.class {
CarrierClientClass::Legacy => 0,
CarrierClientClass::Bridge => 1,
CarrierClientClass::BrowserHint => 2,
}]);
digest.update(context.user_agent_hash);
match context.client_ip {
IpAddr::V4(address) => {
digest.update([4]);
digest.update(address.octets());
}
IpAddr::V6(address) => {
digest.update([6]);
digest.update(address.octets());
}
}
digest.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::web::manager::{CarrierCapabilities, CarrierRequest};
fn request(hash: u8) -> CarrierRequest {
CarrierRequest::automatic(
CarrierClientClass::Bridge,
CarrierCapabilities::all(),
1,
None,
[hash; 32],
)
}
#[test]
fn evidence_is_bounded_and_expires_without_sliding() {
let start = Instant::now();
let mut learning = CarrierLearning::new(3);
let context = CarrierLearningContext {
profile_key: [1; 32],
client_ip: "192.0.2.1".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [2; 32],
};
learning.record(
start,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
assert_eq!(learning.entries.len(), 3);
learning.record(
start + Duration::from_secs(5),
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
learning.prune(start + Duration::from_secs(11));
assert!(learning.entries.is_empty());
}
#[test]
fn user_agent_and_ip_evidence_rank_stably() {
let now = Instant::now();
let mut learning = CarrierLearning::new(16);
let context = CarrierLearningContext {
profile_key: [1; 32],
client_ip: "192.0.2.1".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [2; 32],
};
learning.record(
now,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
let (ranked, scores) = learning.rank(
now,
&[WebCarrier::Https, WebCarrier::Websocket],
request(2),
context.profile_key,
context.client_ip,
);
assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
assert_eq!(scores[WebCarrier::Websocket.index()], 5);
}
#[test]
fn profile_evidence_requires_outcome_and_cohort_thresholds() {
let now = Instant::now();
let profile_key = [1; 32];
let configured = [WebCarrier::Https, WebCarrier::Websocket];
let unrelated_ip = "198.51.100.10".parse().unwrap();
let mut learning = CarrierLearning::new(64);
for cohort in 1..=3u8 {
let context = CarrierLearningContext {
profile_key,
client_ip: IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, cohort)),
class: CarrierClientClass::Bridge,
user_agent_hash: [cohort; 32],
};
for _ in 0..2 {
learning.record(
now,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
}
}
let (ranked, _) = learning.rank(
now,
&configured,
request(99),
profile_key,
unrelated_ip,
);
assert_eq!(ranked, configured);
let fourth = CarrierLearningContext {
profile_key,
client_ip: "192.0.2.4".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [4; 32],
};
for _ in 0..2 {
learning.record(
now,
Duration::from_secs(10),
fourth,
WebCarrier::Websocket,
true,
);
}
let (ranked, scores) = learning.rank(
now,
&configured,
request(99),
profile_key,
unrelated_ip,
);
assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
assert_eq!(scores[WebCarrier::Websocket.index()], 32);
}
}
+11 -21
View File
@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
use tracing::info; use tracing::info;
use super::state::{ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked}; use super::state::{
decrement_map, remember_closed_token_locked, remove_bootstrap_locked, remove_expired_locked,
};
use super::{ProfileKey, TokenHash, WebProcessRuntime}; use super::{ProfileKey, TokenHash, WebProcessRuntime};
impl WebProcessRuntime { impl WebProcessRuntime {
@@ -22,33 +24,20 @@ impl WebProcessRuntime {
} }
decrement_map(&mut state.sessions_per_ip, &client_ip); decrement_map(&mut state.sessions_per_ip, &client_ip);
decrement_map(&mut state.sessions_per_profile, &profile_key); decrement_map(&mut state.sessions_per_profile, &profile_key);
let expiry = Instant::now() remember_closed_token_locked(
+ Duration::from_secs( &mut state,
hash,
profile_host,
Duration::from_secs(
self.active_runtime self.active_runtime
.load() .load()
.config() .config()
.web .web
.timeouts .timeouts
.bootstrap_lifetime_secs, .bootstrap_lifetime_secs,
); ),
state.closed_tokens.insert( self.limits.max_sessions_global.saturating_mul(16),
hash,
ClosedToken {
expires_at: expiry,
host: profile_host.to_string(),
},
); );
while state.closed_tokens.len() > self.limits.max_sessions_global.saturating_mul(16) {
let Some(oldest) = state
.closed_tokens
.iter()
.min_by_key(|(_, closed)| closed.expires_at)
.map(|(hash, _)| *hash)
else {
break;
};
state.closed_tokens.remove(&oldest);
}
let bootstrap_hashes = state let bootstrap_hashes = state
.bootstraps .bootstraps
.iter() .iter()
@@ -124,6 +113,7 @@ impl WebProcessRuntime {
pub(super) fn cleanup(&self) { pub(super) fn cleanup(&self) {
self.cleanup_websockets(); self.cleanup_websockets();
let now = Instant::now(); let now = Instant::now();
self.learning.lock().prune(now);
let sessions = { let sessions = {
let mut state = self.state.lock(); let mut state = self.state.lock();
remove_expired_locked(&mut state, now); remove_expired_locked(&mut state, now);
+187
View File
@@ -0,0 +1,187 @@
use std::net::IpAddr;
use crate::config::WebCarrier;
/// Stable client classification used only for carrier negotiation and learning.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum CarrierClientClass {
/// A client that did not present server-bridge negotiation metadata.
Legacy,
/// The generated bridge presented the explicit capability marker.
Bridge,
/// Strict same-origin browser metadata survived while the marker did not.
BrowserHint,
}
impl CarrierClientClass {
/// Returns the non-sensitive token exposed by WEB debug lifecycle records.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Bridge => "bridge",
Self::BrowserHint => "browser-hint",
}
}
}
/// Canonical carrier failure category reported by the generated bridge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CarrierFailure {
/// The cumulative attempt deadline elapsed.
Timeout,
/// The browser observed a network failure.
Network,
/// A WebSocket upgrade or post-upgrade acknowledgement failed.
Upgrade,
/// The carrier returned an unexpected HTTP result.
Http,
/// The carrier violated its response or framing contract.
Protocol,
}
impl CarrierFailure {
/// Parses one canonical bridge failure token.
pub(crate) const fn parse(value: &str) -> Option<Self> {
match value.as_bytes() {
b"timeout" => Some(Self::Timeout),
b"network" => Some(Self::Network),
b"upgrade" => Some(Self::Upgrade),
b"http" => Some(Self::Http),
b"protocol" => Some(Self::Protocol),
_ => None,
}
}
/// Returns the canonical non-sensitive failure token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Network => "network",
Self::Upgrade => "upgrade",
Self::Http => "http",
Self::Protocol => "protocol",
}
}
}
/// Fixed carrier capability set sent by the generated bridge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierCapabilities(u8);
impl CarrierCapabilities {
/// Returns a set containing every carrier implemented by the generated bridge.
pub(crate) const fn all() -> Self {
Self(0b1111)
}
/// Builds a set from a validated bit representation.
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
if bits != 0 && bits & !0b1111 == 0 {
Some(Self(bits))
} else {
None
}
}
/// Returns whether the bridge can run one carrier.
pub(crate) const fn contains(self, carrier: WebCarrier) -> bool {
self.0 & (1 << carrier.index()) != 0
}
}
/// Immutable metadata attached to one session-creation attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierRequest {
class: CarrierClientClass,
capabilities: Option<CarrierCapabilities>,
attempt: Option<u8>,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
}
impl CarrierRequest {
/// Constructs legacy metadata without enabling negotiation or learning.
pub(crate) const fn legacy(user_agent_hash: [u8; 32]) -> Self {
Self {
class: CarrierClientClass::Legacy,
capabilities: None,
attempt: None,
failure: None,
user_agent_hash,
}
}
/// Constructs validated automatic-negotiation metadata.
pub(crate) const fn automatic(
class: CarrierClientClass,
capabilities: CarrierCapabilities,
attempt: u8,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
) -> Self {
Self {
class,
capabilities: Some(capabilities),
attempt: Some(attempt),
failure,
user_agent_hash,
}
}
/// Returns whether this request participates in server-side negotiation.
pub(crate) const fn is_automatic(self) -> bool {
self.capabilities.is_some()
}
/// Returns the canonical attempt number when negotiation is active.
pub(crate) const fn attempt(self) -> Option<u8> {
self.attempt
}
/// Returns the reported reason for advancing from the previous candidate.
pub(crate) const fn failure(self) -> Option<CarrierFailure> {
self.failure
}
/// Returns whether a carrier is supported by this request.
pub(crate) const fn supports(self, carrier: WebCarrier) -> bool {
match self.capabilities {
Some(capabilities) => capabilities.contains(carrier),
None => false,
}
}
/// Returns the stable non-sensitive client class.
pub(crate) const fn class(self) -> CarrierClientClass {
self.class
}
/// Returns the normalized User-Agent digest.
pub(crate) const fn user_agent_hash(self) -> [u8; 32] {
self.user_agent_hash
}
/// Checks the capability identity frozen across sequential attempts.
pub(crate) fn matches_client(self, other: Self) -> bool {
self.class == other.class
&& self.capabilities_bits() == other.capabilities_bits()
&& self.user_agent_hash == other.user_agent_hash
}
fn capabilities_bits(self) -> Option<u8> {
self.capabilities.map(|capabilities| capabilities.0)
}
}
/// Secret-independent evidence owner frozen into an automatic session.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierLearningContext {
/// Stable profile namespace.
pub(crate) profile_key: super::ProfileKey,
/// Effective client address from the trusted L7 boundary.
pub(crate) client_ip: IpAddr,
/// Client-class namespace for normalized User-Agent evidence.
pub(crate) class: CarrierClientClass,
/// Domain-separated normalized User-Agent digest.
pub(crate) user_agent_hash: [u8; 32],
}
+505
View File
@@ -0,0 +1,505 @@
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
use super::state::{
ManagerState, allow_rate, decrement_map, matching_profile, new_unique_token, profile_key,
remember_closed_token_locked, remove_expired_locked,
};
use super::{
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash,
WebProcessRuntime,
};
use crate::config::{WebCarrier, WebRuntimeProfile, WebTimeoutsConfig};
use crate::web::frame;
use crate::web::session::WebSession;
use crate::web::trace::{TraceIdentity, TraceLifecycleEvent};
struct Replacement {
old_session: Arc<WebSession>,
profile: Arc<WebRuntimeProfile>,
profile_key: super::ProfileKey,
trace_session_id: u64,
attempt: u8,
carrier: WebCarrier,
request: CarrierRequest,
scores: [i16; 4],
}
impl WebProcessRuntime {
/// Creates, replays, or atomically supersedes one pre-commit carrier session.
pub(crate) fn create_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
host: &str,
client_ip: IpAddr,
body: &[u8],
carrier_request: CarrierRequest,
) -> std::result::Result<CreateResult, ManagerError> {
if !frame::validate_hello(body, &self.limits) {
return Err(ManagerError::Protocol);
}
let body_digest: TokenHash = Sha256::digest(body).into();
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
return Err(ManagerError::Authentication);
};
if entry.profile.host != host || now > entry.expires_at {
return Err(ManagerError::Authentication);
}
if entry.used {
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
let client_matches = entry.session_client_ip == Some(client_ip);
let request_matches = entry
.carrier_request
.is_some_and(|current| current.matches_client(carrier_request));
if !digest_matches || !client_matches || !request_matches {
return Err(ManagerError::Authentication);
}
if entry.carrier_transitioning {
return Err(ManagerError::Concurrent);
}
if carrier_request.attempt() == Some(entry.carrier_attempt)
|| (!carrier_request.is_automatic() && entry.carrier_attempt == 1)
{
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
let result = CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
attempt: carrier_request.attempt(),
};
let identity = session.trace_identity();
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionReplayed,
None,
None,
);
return Ok(result);
}
let next_attempt = entry.carrier_attempt.saturating_add(1);
if !carrier_request.is_automatic()
|| carrier_request.attempt() != Some(next_attempt)
|| entry.carrier_committed
{
return Err(ManagerError::Protocol);
}
let Some(carrier) = entry
.carrier_candidates
.get(usize::from(next_attempt - 1))
.copied()
else {
return Err(ManagerError::Protocol);
};
let old_session = entry.session.clone().ok_or(ManagerError::Authentication)?;
let replacement = Replacement {
profile: Arc::clone(&entry.profile),
profile_key: old_session.profile_key(),
trace_session_id: entry.trace_session_id,
old_session,
attempt: next_attempt,
carrier,
request: carrier_request,
scores: entry.carrier_scores,
};
state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?
.carrier_transitioning = true;
drop(state);
return self.replace_session(
bootstrap_hash,
client_ip,
replacement,
&config.web.timeouts,
);
}
if (carrier_request.is_automatic() && carrier_request.attempt() != Some(1))
|| (!carrier_request.is_automatic() && carrier_request.attempt().is_some())
{
return Err(ManagerError::Protocol);
}
let trace_session_id = entry.trace_session_id;
let issued_profile = Arc::clone(&entry.profile);
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &issued_profile))
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if carrier_request.is_automatic() && !profile.carrier_negotiation_enabled {
return Err(ManagerError::Protocol);
}
let (candidates, scores) = if carrier_request.is_automatic() && profile.carrier_learning {
self.learning.lock().rank(
now,
&profile.carriers,
carrier_request,
profile_key,
client_ip,
)
} else if carrier_request.is_automatic() {
(
profile
.carriers
.iter()
.copied()
.filter(|carrier| carrier_request.supports(*carrier))
.collect(),
[0; 4],
)
} else {
(vec![profile.carrier], [0; 4])
};
let Some(carrier) = candidates.first().copied() else {
return Err(ManagerError::Protocol);
};
if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) {
return Err(ManagerError::Limit);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
let learning_context = (carrier_request.is_automatic() && profile.carrier_learning)
.then_some(CarrierLearningContext {
profile_key,
client_ip,
class: carrier_request.class(),
user_agent_hash: carrier_request.user_agent_hash(),
});
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
trace_session_id,
Arc::clone(&profile),
profile_key,
carrier,
1,
bootstrap_hash,
learning_context,
self.limits.clone(),
config.web.timeouts.clone(),
);
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.used = true;
entry.body_digest = body_digest;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
entry.carrier_request = Some(carrier_request);
entry.carrier_candidates = candidates.into();
entry.carrier_scores = scores;
entry.carrier_attempt = 1;
entry.session_client_ip = Some(client_ip);
let issuance_ip = entry.issuance_ip;
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.sessions_created.fetch_add(1, Ordering::Relaxed);
let identity = session.trace_identity();
let result = CreateResult {
token: session_token,
carrier,
attempt: carrier_request.attempt(),
};
drop(state);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierClassified,
carrier_request.class().as_str(),
carrier,
1,
scores,
None,
);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierSelected,
carrier_request.class().as_str(),
carrier,
1,
scores,
None,
);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionCreated,
None,
None,
);
Ok(result)
}
fn replace_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
client_ip: IpAddr,
replacement: Replacement,
timeouts: &WebTimeoutsConfig,
) -> std::result::Result<CreateResult, ManagerError> {
if !replacement.old_session.begin_carrier_supersede() {
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Protocol);
}
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
entry.carrier_transitioning
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
&& entry
.session
.as_ref()
.is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session))
}) && state
.sessions
.get(&replacement.old_session.token_hash())
.is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session));
if !valid
|| state.closed
|| !config.web.enabled
|| !generation
.proxy_shared
.is_user_enabled(&replacement.profile.user)
{
drop(state);
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Closed);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
drop(state);
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Limit);
};
let learning_context = replacement.profile.carrier_learning.then_some(
CarrierLearningContext {
profile_key: replacement.profile_key,
client_ip,
class: replacement.request.class(),
user_agent_hash: replacement.request.user_agent_hash(),
},
);
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
replacement.trace_session_id,
Arc::clone(&replacement.profile),
replacement.profile_key,
replacement.carrier,
replacement.attempt,
bootstrap_hash,
learning_context,
self.limits.clone(),
timeouts.clone(),
);
let old_hash = replacement.old_session.token_hash();
state.sessions.remove(&old_hash);
remember_closed_token_locked(
&mut state,
old_hash,
&replacement.profile.host,
Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
self.limits.max_sessions_global.saturating_mul(16),
);
state.sessions.insert(session_hash, Arc::clone(&session));
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
entry.carrier_request = Some(replacement.request);
entry.carrier_attempt = replacement.attempt;
entry.carrier_transitioning = false;
entry.carrier_committed = false;
self.sessions_created.fetch_add(1, Ordering::Relaxed);
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
let result = CreateResult {
token: session_token,
carrier: replacement.carrier,
attempt: Some(replacement.attempt),
};
let identity = session.trace_identity();
let old_identity = replacement.old_session.trace_identity();
drop(state);
replacement.old_session.finish_carrier_supersede();
if let Some(context) = learning_context {
self.record_carrier_outcome(context, replacement.old_session.carrier(), false);
}
self.trace.record_carrier_lifecycle(
client_ip,
old_identity.clone(),
TraceLifecycleEvent::CarrierFailed,
replacement.request.class().as_str(),
replacement.old_session.carrier(),
replacement.attempt - 1,
replacement.scores,
replacement.request.failure().map(|failure| failure.as_str()),
);
self.trace.record_carrier_lifecycle(
client_ip,
old_identity,
TraceLifecycleEvent::CarrierSuperseded,
replacement.request.class().as_str(),
replacement.old_session.carrier(),
replacement.attempt - 1,
replacement.scores,
replacement.request.failure().map(|failure| failure.as_str()),
);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierSelected,
replacement.request.class().as_str(),
replacement.carrier,
replacement.attempt,
replacement.scores,
None,
);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionCreated,
None,
replacement.request.failure().map(|failure| failure.as_str()),
);
Ok(result)
}
fn cancel_replacement(&self, bootstrap_hash: TokenHash, old_session: &Arc<WebSession>) {
old_session.cancel_carrier_supersede();
let mut state = self.state.lock();
if let Some(entry) = state.bootstraps.get_mut(&bootstrap_hash)
&& entry
.session
.as_ref()
.is_some_and(|session| Arc::ptr_eq(session, old_session))
{
entry.carrier_transitioning = false;
}
}
/// Commits learning only after one accepted OPEN or DATA batch.
#[allow(clippy::too_many_arguments)]
pub(crate) fn carrier_committed(
&self,
bootstrap_hash: TokenHash,
session_hash: TokenHash,
attempt: u8,
carrier: WebCarrier,
learning_context: Option<CarrierLearningContext>,
client_ip: IpAddr,
identity: TraceIdentity,
) {
let mut state = self.state.lock();
let scores = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| {
if entry.carrier_attempt == attempt
&& entry
.session
.as_ref()
.is_some_and(|session| session.token_hash() == session_hash)
{
entry.carrier_committed = true;
Some(entry.carrier_scores)
} else {
None
}
});
drop(state);
let Some(scores) = scores else { return };
if let Some(context) = learning_context {
self.record_carrier_outcome(context, carrier, true);
}
self.trace.record_carrier_lifecycle(
client_ip,
identity,
TraceLifecycleEvent::CarrierCommitted,
learning_context
.map_or("legacy", |context| context.class.as_str()),
carrier,
attempt,
scores,
None,
);
}
fn record_carrier_outcome(
&self,
context: CarrierLearningContext,
carrier: WebCarrier,
success: bool,
) {
let generation = self.active_generation();
if !generation.config().web.carrier_learning {
return;
}
let lifetime = Duration::from_secs(
generation.config().web.timeouts.carrier_learning_secs,
);
self.learning
.lock()
.record(Instant::now(), lifetime, context, carrier, success);
}
}
fn admit_initial(
runtime: &WebProcessRuntime,
state: &mut ManagerState,
now: Instant,
client_ip: IpAddr,
profile_key: super::ProfileKey,
profile: &WebRuntimeProfile,
) -> bool {
let admitted = state.sessions.len() < runtime.limits.max_sessions_global
&& state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
< runtime.limits.max_sessions_per_ip
&& state
.sessions_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
< profile.max_sessions
&& allow_rate(
&mut state.session_rate,
now,
runtime.limits.new_sessions_per_minute,
runtime.limits.new_sessions_burst,
);
if !admitted {
runtime.limit_hits.fetch_add(1, Ordering::Relaxed);
}
admitted
}
+50 -3
View File
@@ -1,14 +1,14 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::{Duration, Instant};
use base64::Engine as _; use base64::Engine as _;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use zeroize::Zeroizing; use zeroize::Zeroizing;
use super::{ProfileKey, TOKEN_BYTES, TokenHash}; use super::{CarrierRequest, ProfileKey, TOKEN_BYTES, TokenHash};
use crate::config::{WebRuntimeConfig, WebRuntimeProfile}; use crate::config::{WebCarrier, WebRuntimeConfig, WebRuntimeProfile};
use crate::maestro::generation::RuntimeGeneration; use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::WebSession; use crate::web::session::WebSession;
@@ -32,6 +32,20 @@ pub(super) struct Bootstrap {
pub(super) session_token: Zeroizing<String>, pub(super) session_token: Zeroizing<String>,
/// Created session retained while retry replay remains valid. /// Created session retained while retry replay remains valid.
pub(super) session: Option<Arc<WebSession>>, pub(super) session: Option<Arc<WebSession>>,
/// Metadata that defines exact attempt replay and candidate advancement.
pub(super) carrier_request: Option<CarrierRequest>,
/// Learning-ranked carrier order frozen by the first automatic attempt.
pub(super) carrier_candidates: Arc<[WebCarrier]>,
/// Weighted learning scores captured when the candidate order was frozen.
pub(super) carrier_scores: [i16; 4],
/// Current one-based carrier attempt, or zero before session creation.
pub(super) carrier_attempt: u8,
/// Prevents concurrent retries from replacing the same attempt twice.
pub(super) carrier_transitioning: bool,
/// Records the first accepted OPEN or DATA transition exactly once.
pub(super) carrier_committed: bool,
/// Effective address frozen by the first session-creation request.
pub(super) session_client_ip: Option<IpAddr>,
/// Distinguishes unused issuance quota from completed creation replay state. /// Distinguishes unused issuance quota from completed creation replay state.
pub(super) used: bool, pub(super) used: bool,
} }
@@ -135,6 +149,11 @@ pub(super) fn matching_profile(
&& profile.user == expected.user && profile.user == expected.user
&& profile.secret_mode == expected.secret_mode && profile.secret_mode == expected.secret_mode
&& profile.carrier == expected.carrier && profile.carrier == expected.carrier
&& profile.carrier_negotiation_enabled == expected.carrier_negotiation_enabled
&& profile.carrier_learning == expected.carrier_learning
&& profile.carriers == expected.carriers
&& profile.carrier_negotiation_deadlines_secs
== expected.carrier_negotiation_deadlines_secs
&& profile.capability == expected.capability && profile.capability == expected.capability
&& profile.key_fingerprint == expected.key_fingerprint && profile.key_fingerprint == expected.key_fingerprint
}) })
@@ -198,6 +217,34 @@ pub(super) fn remove_bootstrap_locked(state: &mut ManagerState, hash: TokenHash)
} }
} }
/// Retains one bounded host-bound marker for an invalidated session credential.
pub(super) fn remember_closed_token_locked(
state: &mut ManagerState,
hash: TokenHash,
host: &str,
lifetime: Duration,
capacity: usize,
) {
state.closed_tokens.insert(
hash,
ClosedToken {
expires_at: Instant::now() + lifetime,
host: host.to_string(),
},
);
while state.closed_tokens.len() > capacity {
let Some(oldest) = state
.closed_tokens
.iter()
.min_by_key(|(_, closed)| closed.expires_at)
.map(|(hash, _)| *hash)
else {
break;
};
state.closed_tokens.remove(&oldest);
}
}
/// Decrements one counted owner and removes its map entry at zero. /// Decrements one counted owner and removes its map entry at zero.
pub(super) fn decrement_map<K, Q>(values: &mut HashMap<K, usize>, key: &Q) pub(super) fn decrement_map<K, Q>(values: &mut HashMap<K, usize>, key: &Q)
where where
+1
View File
@@ -98,6 +98,7 @@ impl Drop for WebSocketConnection {
} }
} }
#[allow(clippy::too_many_arguments)]
pub(super) async fn admit( pub(super) async fn admit(
runtime: &Arc<WebProcessRuntime>, runtime: &Arc<WebProcessRuntime>,
owner: ProfileKey, owner: ProfileKey,
+71 -74
View File
@@ -2,9 +2,9 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io; use std::io;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::task::{Context, Poll, Waker}; use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant}; use std::time::Instant;
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
use parking_lot::Mutex; use parking_lot::Mutex;
@@ -15,6 +15,7 @@ use tokio_util::sync::CancellationToken;
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig}; use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
use crate::web::frame::{self, FrameType}; use crate::web::frame::{self, FrameType};
use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime}; use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
use crate::web::manager::CarrierLearningContext;
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes. // Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
mod backend; mod backend;
@@ -25,6 +26,8 @@ mod lanes;
// WebSocket carrier state owns pre-OPEN lane reservations and failure isolation. // WebSocket carrier state owns pre-OPEN lane reservations and failure isolation.
mod websocket; mod websocket;
pub(crate) use websocket::WebSocketLaneReservation; pub(crate) use websocket::WebSocketLaneReservation;
// Session closure and carrier-attempt transitions share one cancellation boundary.
mod lifecycle;
// Uplink batches own exactly-once sequencing and client-frame validation. // Uplink batches own exactly-once sequencing and client-frame validation.
mod uplink; mod uplink;
@@ -116,9 +119,19 @@ struct SessionState {
pending_control_bytes: usize, pending_control_bytes: usize,
pending_control_items: usize, pending_control_items: usize,
last_activity: Instant, last_activity: Instant,
negotiation_phase: SessionNegotiationPhase,
close_requested: bool,
closed: bool, closed: bool,
} }
#[derive(Clone, Copy, PartialEq, Eq)]
enum SessionNegotiationPhase {
Uncommitted,
Replacing,
Committed,
Superseded,
}
/// One bounded WEB carrier session containing logical MTProxy streams. /// One bounded WEB carrier session containing logical MTProxy streams.
pub(crate) struct WebSession { pub(crate) struct WebSession {
manager: std::sync::Weak<WebProcessRuntime>, manager: std::sync::Weak<WebProcessRuntime>,
@@ -127,6 +140,10 @@ pub(crate) struct WebSession {
trace_session_id: u64, trace_session_id: u64,
profile: Arc<WebRuntimeProfile>, profile: Arc<WebRuntimeProfile>,
profile_key: ProfileKey, profile_key: ProfileKey,
selected_carrier: WebCarrier,
carrier_attempt: u8,
bootstrap_hash: TokenHash,
learning_context: Option<CarrierLearningContext>,
limits: WebLimitsConfig, limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig, timeouts: WebTimeoutsConfig,
state: Mutex<SessionState>, state: Mutex<SessionState>,
@@ -158,11 +175,15 @@ impl WebSession {
trace_session_id: u64, trace_session_id: u64,
profile: Arc<WebRuntimeProfile>, profile: Arc<WebRuntimeProfile>,
profile_key: ProfileKey, profile_key: ProfileKey,
selected_carrier: WebCarrier,
carrier_attempt: u8,
bootstrap_hash: TokenHash,
learning_context: Option<CarrierLearningContext>,
limits: WebLimitsConfig, limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig, timeouts: WebTimeoutsConfig,
) -> Arc<Self> { ) -> Arc<Self> {
let mut carrier_lanes = HashMap::new(); let mut carrier_lanes = HashMap::new();
if profile.carrier == WebCarrier::HttpsLanes { if selected_carrier == WebCarrier::HttpsLanes {
carrier_lanes.insert(0, CarrierLane::new()); carrier_lanes.insert(0, CarrierLane::new());
} }
Arc::new(Self { Arc::new(Self {
@@ -172,6 +193,10 @@ impl WebSession {
trace_session_id, trace_session_id,
profile, profile,
profile_key, profile_key,
selected_carrier,
carrier_attempt,
bootstrap_hash,
learning_context,
limits, limits,
timeouts, timeouts,
state: Mutex::new(SessionState { state: Mutex::new(SessionState {
@@ -193,6 +218,8 @@ impl WebSession {
pending_control_bytes: 0, pending_control_bytes: 0,
pending_control_items: 0, pending_control_items: 0,
last_activity: Instant::now(), last_activity: Instant::now(),
negotiation_phase: SessionNegotiationPhase::Uncommitted,
close_requested: false,
closed: false, closed: false,
}), }),
down_notify: Arc::new(Notify::new()), down_notify: Arc::new(Notify::new()),
@@ -216,7 +243,7 @@ impl WebSession {
/// Returns the immutable carrier selected when this session was created. /// Returns the immutable carrier selected when this session was created.
pub(crate) fn carrier(&self) -> WebCarrier { pub(crate) fn carrier(&self) -> WebCarrier {
self.profile.carrier self.selected_carrier
} }
/// Returns the stable quota owner without exposing profile credentials. /// Returns the stable quota owner without exposing profile credentials.
@@ -229,6 +256,11 @@ impl WebSession {
self.trace_session_id self.trace_session_id
} }
/// Returns whether accepted carrier progress made this attempt immutable.
pub(crate) fn is_carrier_committed(&self) -> bool {
self.state.lock().negotiation_phase == SessionNegotiationPhase::Committed
}
/// Returns a cloned non-secret identity only for enabled debug capture. /// Returns a cloned non-secret identity only for enabled debug capture.
pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity { pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity {
crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile) crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile)
@@ -253,82 +285,47 @@ impl WebSession {
} }
} }
/// Closes carrier state while relay tasks retain their admission until exit. fn ensure_carrier_active_locked(
pub(crate) fn close(&self) { &self,
let (data_bytes, data_items, control_bytes, control_items) = { state: &SessionState,
let mut state = self.state.lock(); ) -> Result<(), crate::web::manager::ManagerError> {
if state.closed { match state.negotiation_phase {
return; SessionNegotiationPhase::Uncommitted | SessionNegotiationPhase::Committed => Ok(()),
SessionNegotiationPhase::Replacing | SessionNegotiationPhase::Superseded => {
Err(crate::web::manager::ManagerError::Closed)
} }
state.closed = true;
for stream in state.streams.values_mut() {
if let Some(waker) = stream.read_waker.take() {
waker.wake();
}
if let Some(waker) = stream.write_waker.take() {
waker.wake();
}
}
state.streams.clear();
state.pending_frames.clear();
state.pending_windows.clear();
state.unacked = None;
for lane in state.carrier_lanes.values() {
lane.notify.notify_waiters();
}
state.carrier_lanes.clear();
let control_bytes = state.pending_control_bytes;
let control_items = state.pending_control_items;
let data_bytes = state.pending_bytes.saturating_sub(control_bytes);
let data_items = state.pending_items.saturating_sub(control_items);
state.pending_bytes = 0;
state.pending_items = 0;
state.pending_control_bytes = 0;
state.pending_control_items = 0;
(data_bytes, data_items, control_bytes, control_items)
};
self.cancel.cancel();
if self.carrier().is_multiplexed() {
self.down_notify.notify_waiters();
} }
}
fn commit_carrier_locked(&self, state: &mut SessionState, progress: bool) -> bool {
if !progress {
return false;
}
match state.negotiation_phase {
SessionNegotiationPhase::Uncommitted => {
state.negotiation_phase = SessionNegotiationPhase::Committed;
true
}
SessionNegotiationPhase::Committed
| SessionNegotiationPhase::Replacing
| SessionNegotiationPhase::Superseded => false,
}
}
fn finish_carrier_commit(&self) {
if let Some(manager) = self.manager.upgrade() { if let Some(manager) = self.manager.upgrade() {
manager.release_pending(self.profile_key, data_bytes, data_items, false); manager.carrier_committed(
manager.release_pending(self.profile_key, control_bytes, control_items, true); self.bootstrap_hash,
if !self.finished.swap(true, Ordering::AcqRel) { self.token_hash,
self.trace_lifecycle( self.carrier_attempt,
crate::web::trace::TraceLifecycleEvent::SessionClosed, self.selected_carrier,
None, self.learning_context,
Some("closed"), self.client_ip,
); self.trace_identity(),
manager.session_finished( );
self.token_hash,
self.client_ip,
self.profile_key,
&self.profile.host,
);
}
} }
} }
/// Waits for all logical-stream tasks after admission has closed.
pub(crate) async fn wait(&self) {
loop {
let notified = self.tasks_done.notified();
if self.tasks_live.load(Ordering::Acquire) == 0 {
return;
}
notified.await;
}
}
/// Returns whether reconnect grace elapsed without activity.
pub(crate) fn is_idle(&self, now: Instant) -> bool {
let state = self.state.lock();
!state.closed
&& now.saturating_duration_since(state.last_activity)
>= Duration::from_secs(self.timeouts.reconnect_grace_secs)
}
/// Polls client-to-server bytes and returns consumed flow-control credit. /// Polls client-to-server bytes and returns consumed flow-control credit.
pub(super) fn poll_read( pub(super) fn poll_read(
&self, &self,
+13 -4
View File
@@ -70,6 +70,10 @@ fn test_runtime_with_dc(
user: "default".to_string(), user: "default".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier, carrier,
carrier_negotiation_enabled: false,
carrier_learning: true,
carriers: Arc::from([carrier]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [7; 32], capability: [7; 32],
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 4, max_sessions: 4,
@@ -120,6 +124,10 @@ fn test_runtime_with_dc(
1, 1,
profile, profile,
[7; 32], [7; 32],
carrier,
1,
[9; 32],
None,
limits, limits,
timeouts, timeouts,
); );
@@ -323,10 +331,11 @@ async fn silent_streams_do_not_consume_active_handshake_capacity() {
assert_eq!(runtime.process_frame(2, 4, FrameType::Data, &[2]), Ok(4)); assert_eq!(runtime.process_frame(2, 4, FrameType::Data, &[2]), Ok(4));
settle_tasks().await; settle_tasks().await;
let state = runtime.session.state.lock(); {
assert!(state.streams.contains_key(&1)); let state = runtime.session.state.lock();
assert!(!state.streams.contains_key(&2)); assert!(state.streams.contains_key(&1));
drop(state); assert!(!state.streams.contains_key(&2));
}
runtime.shutdown().await; runtime.shutdown().await;
} }
+8
View File
@@ -426,6 +426,10 @@ mod tests {
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https, carrier: WebCarrier::Https,
carrier_negotiation_enabled: false,
carrier_learning: true,
carriers: Arc::from([WebCarrier::Https]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [0; 32], capability: [0; 32],
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 1, max_sessions: 1,
@@ -439,6 +443,10 @@ mod tests {
1, 1,
profile, profile,
[2; 32], [2; 32],
WebCarrier::Https,
1,
[3; 32],
None,
WebLimitsConfig::default(), WebLimitsConfig::default(),
WebTimeoutsConfig::default(), WebTimeoutsConfig::default(),
) )
+11
View File
@@ -41,12 +41,17 @@ impl WebSession {
return Err(ManagerError::Protocol); return Err(ManagerError::Protocol);
} }
let digest: TokenHash = Sha256::digest(body).into(); let digest: TokenHash = Sha256::digest(body).into();
let progress = frames
.iter()
.any(|frame| matches!(frame.frame_type, FrameType::Open | FrameType::Data));
let mut opened = Vec::new(); let mut opened = Vec::new();
let mut committed = false;
let result = { let result = {
let mut state = self.state.lock(); let mut state = self.state.lock();
if state.closed { if state.closed {
return Err(ManagerError::Closed); return Err(ManagerError::Closed);
} }
self.ensure_carrier_active_locked(&state)?;
state.last_activity = Instant::now(); state.last_activity = Instant::now();
if !state.carrier_lanes.contains_key(&lane_id) { if !state.carrier_lanes.contains_key(&lane_id) {
if lane_id != 0 if lane_id != 0
@@ -125,6 +130,9 @@ impl WebSession {
lane.last_up_digest = digest; lane.last_up_digest = digest;
} }
} }
if applied {
committed = self.commit_carrier_locked(&mut state, progress);
}
applied.then_some(sequence).ok_or(ManagerError::Closed) applied.then_some(sequence).ok_or(ManagerError::Closed)
}; };
if matches!(result, Err(ManagerError::Backpressure)) { if matches!(result, Err(ManagerError::Backpressure)) {
@@ -137,6 +145,9 @@ impl WebSession {
} }
return result; return result;
} }
if committed {
self.finish_carrier_commit();
}
for (stream_id, peer_port) in opened { for (stream_id, peer_port) in opened {
self.spawn_stream(stream_id, peer_port, false); self.spawn_stream(stream_id, peer_port, false);
} }
+8
View File
@@ -14,6 +14,10 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::HttpsLanes, carrier: WebCarrier::HttpsLanes,
carrier_negotiation_enabled: false,
carrier_learning: true,
carriers: Arc::from([WebCarrier::HttpsLanes]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [0; 32], capability: [0; 32],
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 1, max_sessions: 1,
@@ -27,6 +31,10 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
1, 1,
profile, profile,
[2; 32], [2; 32],
WebCarrier::HttpsLanes,
1,
[3; 32],
None,
limits, limits,
WebTimeoutsConfig::default(), WebTimeoutsConfig::default(),
) )
+161
View File
@@ -0,0 +1,161 @@
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use super::{SessionNegotiationPhase, WebSession};
struct ReleasedQueues {
data_bytes: usize,
data_items: usize,
control_bytes: usize,
control_items: usize,
}
impl WebSession {
/// Closes carrier state while relay tasks retain their admission until exit.
pub(crate) fn close(&self) {
let Some(released) = self.begin_close(false) else {
return;
};
self.finish_close(released, false);
}
/// Atomically prevents first-frame commit while one successor is prepared.
pub(crate) fn begin_carrier_supersede(&self) -> bool {
let mut state = self.state.lock();
if state.closed || state.close_requested {
return false;
}
match state.negotiation_phase {
SessionNegotiationPhase::Uncommitted => {
state.negotiation_phase = SessionNegotiationPhase::Replacing;
true
}
SessionNegotiationPhase::Replacing
| SessionNegotiationPhase::Committed
| SessionNegotiationPhase::Superseded => false,
}
}
/// Restores an uncommitted attempt after successor admission failed.
pub(crate) fn cancel_carrier_supersede(&self) {
let close_requested = {
let mut state = self.state.lock();
if !state.closed && state.negotiation_phase == SessionNegotiationPhase::Replacing {
state.negotiation_phase = SessionNegotiationPhase::Uncommitted;
}
state.close_requested
};
if close_requested {
self.close();
}
}
/// Completes manager-owned replacement without unregistering the old session twice.
pub(crate) fn finish_carrier_supersede(&self) {
let Some(released) = self.begin_close(true) else {
return;
};
self.finish_close(released, true);
}
/// Waits for all logical-stream tasks after admission has closed.
pub(crate) async fn wait(&self) {
loop {
let notified = self.tasks_done.notified();
if self.tasks_live.load(Ordering::Acquire) == 0 {
return;
}
notified.await;
}
}
/// Returns whether reconnect grace elapsed without activity.
pub(crate) fn is_idle(&self, now: Instant) -> bool {
let state = self.state.lock();
!state.closed
&& state.negotiation_phase != SessionNegotiationPhase::Replacing
&& now.saturating_duration_since(state.last_activity)
>= Duration::from_secs(self.timeouts.reconnect_grace_secs)
}
fn begin_close(&self, superseded: bool) -> Option<ReleasedQueues> {
let mut state = self.state.lock();
if state.closed || (superseded && state.negotiation_phase != SessionNegotiationPhase::Replacing) {
return None;
}
if !superseded && state.negotiation_phase == SessionNegotiationPhase::Replacing {
state.close_requested = true;
return None;
}
state.closed = true;
if superseded {
state.negotiation_phase = SessionNegotiationPhase::Superseded;
}
for stream in state.streams.values_mut() {
if let Some(waker) = stream.read_waker.take() {
waker.wake();
}
if let Some(waker) = stream.write_waker.take() {
waker.wake();
}
}
state.streams.clear();
state.pending_frames.clear();
state.pending_windows.clear();
state.unacked = None;
for lane in state.carrier_lanes.values() {
lane.notify.notify_waiters();
}
state.carrier_lanes.clear();
let control_bytes = state.pending_control_bytes;
let control_items = state.pending_control_items;
let data_bytes = state.pending_bytes.saturating_sub(control_bytes);
let data_items = state.pending_items.saturating_sub(control_items);
state.pending_bytes = 0;
state.pending_items = 0;
state.pending_control_bytes = 0;
state.pending_control_items = 0;
Some(ReleasedQueues {
data_bytes,
data_items,
control_bytes,
control_items,
})
}
fn finish_close(&self, released: ReleasedQueues, superseded: bool) {
self.cancel.cancel();
if self.carrier().is_multiplexed() {
self.down_notify.notify_waiters();
}
if let Some(manager) = self.manager.upgrade() {
manager.release_pending(
self.profile_key,
released.data_bytes,
released.data_items,
false,
);
manager.release_pending(
self.profile_key,
released.control_bytes,
released.control_items,
true,
);
if !self.finished.swap(true, Ordering::AcqRel) {
self.trace_lifecycle(
crate::web::trace::TraceLifecycleEvent::SessionClosed,
None,
Some(if superseded { "superseded" } else { "closed" }),
);
if !superseded {
manager.session_finished(
self.token_hash,
self.client_ip,
self.profile_key,
&self.profile.host,
);
}
}
}
}
}
+17
View File
@@ -48,12 +48,17 @@ impl WebSession {
return Err(ManagerError::Protocol); return Err(ManagerError::Protocol);
} }
let digest: TokenHash = Sha256::digest(body).into(); let digest: TokenHash = Sha256::digest(body).into();
let progress = frames
.iter()
.any(|frame| matches!(frame.frame_type, FrameType::Open | FrameType::Data));
let mut opened = Vec::new(); let mut opened = Vec::new();
let mut committed = false;
let result = { let result = {
let mut state = self.state.lock(); let mut state = self.state.lock();
if state.closed { if state.closed {
return Err(ManagerError::Closed); return Err(ManagerError::Closed);
} }
self.ensure_carrier_active_locked(&state)?;
state.last_activity = Instant::now(); state.last_activity = Instant::now();
if sequence == state.last_up_sequence && sequence != 0 { if sequence == state.last_up_sequence && sequence != 0 {
return if bool::from(state.last_up_digest.ct_eq(&digest)) { return if bool::from(state.last_up_digest.ct_eq(&digest)) {
@@ -99,6 +104,7 @@ impl WebSession {
} else { } else {
state.last_up_sequence = sequence; state.last_up_sequence = sequence;
state.last_up_digest = digest; state.last_up_digest = digest;
committed = self.commit_carrier_locked(&mut state, progress);
Ok(sequence) Ok(sequence)
} }
}; };
@@ -112,6 +118,9 @@ impl WebSession {
} }
return result; return result;
} }
if committed {
self.finish_carrier_commit();
}
for (stream_id, peer_port) in opened { for (stream_id, peer_port) in opened {
self.spawn_stream(stream_id, peer_port, false); self.spawn_stream(stream_id, peer_port, false);
} }
@@ -362,6 +371,10 @@ mod tests {
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https, carrier: WebCarrier::Https,
carrier_negotiation_enabled: false,
carrier_learning: true,
carriers: Arc::from([WebCarrier::Https]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [0; 32], capability: [0; 32],
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 1, max_sessions: 1,
@@ -375,6 +388,10 @@ mod tests {
1, 1,
profile, profile,
[2; 32], [2; 32],
WebCarrier::Https,
1,
[3; 32],
None,
WebLimitsConfig::default(), WebLimitsConfig::default(),
WebTimeoutsConfig::default(), WebTimeoutsConfig::default(),
) )
+11
View File
@@ -121,12 +121,17 @@ impl WebSession {
return Err(ManagerError::Protocol); return Err(ManagerError::Protocol);
} }
let digest = Sha256::digest(body).into(); let digest = Sha256::digest(body).into();
let progress = frames
.iter()
.any(|frame| matches!(frame.frame_type, frame::FrameType::Open | frame::FrameType::Data));
let mut opened = Vec::new(); let mut opened = Vec::new();
let mut committed = false;
let result = { let result = {
let mut state = self.state.lock(); let mut state = self.state.lock();
if state.closed { if state.closed {
return Err(ManagerError::Closed); return Err(ManagerError::Closed);
} }
self.ensure_carrier_active_locked(&state)?;
if !reservation.transferred if !reservation.transferred
&& state.websocket_lane_reservations.get(&lane_id) != Some(&reservation.peer_port) && state.websocket_lane_reservations.get(&lane_id) != Some(&reservation.peer_port)
{ {
@@ -181,9 +186,15 @@ impl WebSession {
} }
} }
state.last_activity = Instant::now(); state.last_activity = Instant::now();
if applied {
committed = self.commit_carrier_locked(&mut state, progress);
}
applied.then_some(()).ok_or(ManagerError::Protocol) applied.then_some(()).ok_or(ManagerError::Protocol)
}; };
result?; result?;
if committed {
self.finish_carrier_commit();
}
for (stream_id, peer_port) in opened { for (stream_id, peer_port) in opened {
if stream_id != lane_id || peer_port != reservation.peer_port { if stream_id != lane_id || peer_port != reservation.peer_port {
return Err(ManagerError::Protocol); return Err(ManagerError::Protocol);
+8
View File
@@ -33,6 +33,10 @@ fn runtime(admission: bool) -> TestRuntime {
user: "default".to_string(), user: "default".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::WebsocketLanes, carrier: WebCarrier::WebsocketLanes,
carrier_negotiation_enabled: false,
carrier_learning: true,
carriers: Arc::from([WebCarrier::WebsocketLanes]),
carrier_negotiation_deadlines_secs: [3, 5, 8, 12],
capability: [7; 32], capability: [7; 32],
key_fingerprint: "0000000000000000".to_string(), key_fingerprint: "0000000000000000".to_string(),
max_sessions: 2, max_sessions: 2,
@@ -60,6 +64,10 @@ fn runtime(admission: bool) -> TestRuntime {
1, 1,
profile, profile,
[7; 32], [7; 32],
WebCarrier::WebsocketLanes,
1,
[9; 32],
None,
limits, limits,
timeouts, timeouts,
); );
+16 -11
View File
@@ -81,10 +81,11 @@ impl HttpTraceExchange {
.headers() .headers()
.get(hyper::header::USER_AGENT) .get(hyper::header::USER_AGENT)
.map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)), .map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)),
policy if policy.capture_headers {
.capture_headers sanitized_headers(request.headers())
.then(|| sanitized_headers(request.headers())) } else {
.unwrap_or_default(), Vec::new()
},
sensitive_values(request.headers(), request.uri().query()), sensitive_values(request.headers(), request.uri().query()),
) )
} else { } else {
@@ -423,13 +424,17 @@ mod tests {
.header("authorization", format!("Bearer {request_token}")) .header("authorization", format!("Bearer {request_token}"))
.body(()) .body(())
.unwrap(); .unwrap();
let mut policy = WebDebugConfig::default(); let policy = WebDebugConfig {
policy.enabled = true; enabled: true,
policy.body_capture = WebDebugBodyCapture::Prefix; body_capture: WebDebugBodyCapture::Prefix,
policy.body_prefix_bytes = 256; body_prefix_bytes: 256,
let mut limits = WebLimitsConfig::default(); ..Default::default()
limits.debug_records_capacity = 4; };
limits.debug_bytes_global = 16 * 1024; let limits = WebLimitsConfig {
debug_records_capacity: 4,
debug_bytes_global: 16 * 1024,
..Default::default()
};
let store = WebTraceStore::new(policy, &limits); let store = WebTraceStore::new(policy, &limits);
let exchange = store let exchange = store
.begin_http(&request, "192.0.2.30".parse().unwrap()) .begin_http(&request, "192.0.2.30".parse().unwrap())
+2 -1
View File
@@ -13,5 +13,6 @@ pub(crate) use exchange::HttpTraceExchange;
pub(crate) use store::{StoredTraceRecord, WebTraceStore, epoch_millis as store_epoch_millis}; pub(crate) use store::{StoredTraceRecord, WebTraceStore, epoch_millis as store_epoch_millis};
pub(crate) use types::{ pub(crate) use types::{
TraceBodySnapshot, TraceBodyState, TraceDirection, TraceFrame, TraceHeader, TraceIdentity, TraceBodySnapshot, TraceBodyState, TraceDirection, TraceFrame, TraceHeader, TraceIdentity,
TraceLifecycleEvent, TraceRecord, TraceRecordKind, TraceRoute, TraceWebSocketContext, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind, TraceRoute,
TraceWebSocketContext,
}; };
+22 -18
View File
@@ -28,12 +28,11 @@ pub(super) fn request_dynamic_bytes<B>(
lossy_text_reservation(value.as_bytes(), USER_AGENT_MAX_BYTES) lossy_text_reservation(value.as_bytes(), USER_AGENT_MAX_BYTES)
}), }),
) )
.saturating_add( .saturating_add(if policy.capture_headers {
policy sanitized_header_bytes(request.headers())
.capture_headers } else {
.then(|| sanitized_header_bytes(request.headers())) 0
.unwrap_or(0), })
)
.saturating_add(sensitive_value_bytes( .saturating_add(sensitive_value_bytes(
request.headers(), request.headers(),
request.uri().query(), request.uri().query(),
@@ -45,10 +44,11 @@ pub(super) fn response_dynamic_bytes<B>(
response: &hyper::Response<B>, response: &hyper::Response<B>,
policy: &WebDebugConfig, policy: &WebDebugConfig,
) -> usize { ) -> usize {
policy if policy.capture_headers {
.capture_headers sanitized_header_bytes(response.headers())
.then(|| sanitized_header_bytes(response.headers())) } else {
.unwrap_or(0) 0
}
.saturating_add(sensitive_value_bytes(response.headers(), None)) .saturating_add(sensitive_value_bytes(response.headers(), None))
} }
@@ -173,11 +173,11 @@ fn sanitized_header_bytes(headers: &hyper::HeaderMap) -> usize {
total total
.saturating_add(std::mem::size_of::<TraceHeader>()) .saturating_add(std::mem::size_of::<TraceHeader>())
.saturating_add(name.as_str().len()) .saturating_add(name.as_str().len())
.saturating_add( .saturating_add(if header_value_allowed(name) {
header_value_allowed(name) lossy_text_reservation(value.as_bytes(), 4096)
.then(|| lossy_text_reservation(value.as_bytes(), 4096)) } else {
.unwrap_or(0), 0
) })
}) })
} }
@@ -195,6 +195,8 @@ fn header_value_allowed(name: &hyper::header::HeaderName) -> bool {
| "x-down-cursor" | "x-down-cursor"
| "x-lane-id" | "x-lane-id"
| "x-lane-closed" | "x-lane-closed"
| "x-carrier-attempt"
| "x-carrier-failure"
| "x-carrier-mode" | "x-carrier-mode"
| "retry-after" | "retry-after"
| "cache-control" | "cache-control"
@@ -273,9 +275,11 @@ mod tests {
#[test] #[test]
fn full_capture_keeps_decoy_bodies_prefix_bounded() { fn full_capture_keeps_decoy_bodies_prefix_bounded() {
let mut policy = WebDebugConfig::default(); let policy = WebDebugConfig {
policy.body_capture = WebDebugBodyCapture::Full; body_capture: WebDebugBodyCapture::Full,
policy.decoy_body_prefix_bytes = 123; decoy_body_prefix_bytes: 123,
..Default::default()
};
assert_eq!(capture_limit(&policy, TraceRoute::Decoy, 4096), Some(123)); assert_eq!(capture_limit(&policy, TraceRoute::Decoy, 4096), Some(123));
assert_eq!(capture_limit(&policy, TraceRoute::Uplink, 4096), Some(4096)); assert_eq!(capture_limit(&policy, TraceRoute::Uplink, 4096), Some(4096));
} }
+68 -9
View File
@@ -9,7 +9,8 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use super::exchange::HttpTraceExchange; use super::exchange::HttpTraceExchange;
use super::types::{ use super::types::{
TraceIdentity, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind, TraceCarrierDetail, TraceIdentity, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord,
TraceRecordKind,
}; };
use crate::config::{WebDebugConfig, WebLimitsConfig}; use crate::config::{WebDebugConfig, WebLimitsConfig};
@@ -166,6 +167,57 @@ impl WebTraceStore {
event: TraceLifecycleEvent, event: TraceLifecycleEvent,
stream_id: Option<u32>, stream_id: Option<u32>,
reason: Option<&'static str>, reason: Option<&'static str>,
) {
self.record_lifecycle_detail(
peer_ip,
effective_ip,
identity,
event,
stream_id,
reason,
None,
);
}
/// Records one carrier transition with structured, non-secret negotiation fields.
#[allow(clippy::too_many_arguments)]
pub(crate) fn record_carrier_lifecycle(
&self,
effective_ip: IpAddr,
identity: TraceIdentity,
event: TraceLifecycleEvent,
client_class: &'static str,
carrier: crate::config::WebCarrier,
attempt: u8,
scores: [i16; 4],
reason: Option<&'static str>,
) {
self.record_lifecycle_detail(
None,
Some(effective_ip),
identity,
event,
None,
reason,
Some(TraceCarrierDetail {
client_class,
carrier,
attempt,
scores,
}),
);
}
#[allow(clippy::too_many_arguments)]
fn record_lifecycle_detail(
&self,
peer_ip: Option<IpAddr>,
effective_ip: Option<IpAddr>,
identity: TraceIdentity,
event: TraceLifecycleEvent,
stream_id: Option<u32>,
reason: Option<&'static str>,
carrier: Option<TraceCarrierDetail>,
) { ) {
if !self.enabled.load(Ordering::Acquire) { if !self.enabled.load(Ordering::Acquire) {
return; return;
@@ -197,6 +249,7 @@ impl WebTraceStore {
event, event,
stream_id, stream_id,
reason, reason,
carrier,
}), }),
}; };
if !self.try_commit(record, reservation, epoch) { if !self.try_commit(record, reservation, epoch) {
@@ -378,11 +431,15 @@ mod tests {
use super::*; use super::*;
fn store(records_capacity: usize, bytes_capacity: usize) -> Arc<WebTraceStore> { fn store(records_capacity: usize, bytes_capacity: usize) -> Arc<WebTraceStore> {
let mut policy = WebDebugConfig::default(); let policy = WebDebugConfig {
policy.enabled = true; enabled: true,
let mut limits = WebLimitsConfig::default(); ..Default::default()
limits.debug_records_capacity = records_capacity; };
limits.debug_bytes_global = bytes_capacity; let limits = WebLimitsConfig {
debug_records_capacity: records_capacity,
debug_bytes_global: bytes_capacity,
..Default::default()
};
WebTraceStore::new(policy, &limits) WebTraceStore::new(policy, &limits)
} }
@@ -427,9 +484,11 @@ mod tests {
.begin_http(&request, "192.0.2.20".parse().unwrap()) .begin_http(&request, "192.0.2.20".parse().unwrap())
.unwrap(); .unwrap();
let mut changed = WebDebugConfig::default(); let changed = WebDebugConfig {
changed.enabled = true; enabled: true,
changed.capture_headers = false; capture_headers: false,
..Default::default()
};
store.apply_policy(&changed); store.apply_policy(&changed);
exchange.commit(); exchange.commit();
+21 -18
View File
@@ -65,16 +65,15 @@ impl WebTraceStore {
) )
.unwrap_or(0); .unwrap_or(0);
let capture_bytes = payload.len().min(capture_limit); let capture_bytes = payload.len().min(capture_limit);
let frame_reservation = policy let frame_reservation = if policy.capture_frames {
.capture_frames payload
.then(|| { .len()
payload .div_ceil(frame::HEADER_BYTES)
.len() .clamp(1, self.frame_limits.max_frames_per_body)
.div_ceil(frame::HEADER_BYTES) .saturating_mul(std::mem::size_of::<TraceFrame>())
.clamp(1, self.frame_limits.max_frames_per_body) } else {
.saturating_mul(std::mem::size_of::<TraceFrame>()) 0
}) };
.unwrap_or(0);
let identity_bytes = context let identity_bytes = context
.identity .identity
.user .user
@@ -168,14 +167,18 @@ mod tests {
#[test] #[test]
fn websocket_message_capture_retains_bounded_identity_body_timing_and_frames() { fn websocket_message_capture_retains_bounded_identity_body_timing_and_frames() {
let mut policy = WebDebugConfig::default(); let policy = WebDebugConfig {
policy.enabled = true; enabled: true,
policy.capture_frames = true; capture_frames: true,
policy.capture_timings = true; capture_timings: true,
policy.body_capture = WebDebugBodyCapture::Full; body_capture: WebDebugBodyCapture::Full,
let mut limits = WebLimitsConfig::default(); ..Default::default()
limits.debug_records_capacity = 4; };
limits.debug_bytes_global = 64 * 1024; let limits = WebLimitsConfig {
debug_records_capacity: 4,
debug_bytes_global: 64 * 1024,
..Default::default()
};
let store = WebTraceStore::new(policy, &limits); let store = WebTraceStore::new(policy, &limits);
let request = hyper::Request::builder() let request = hyper::Request::builder()
.header(hyper::header::USER_AGENT, "trace-client") .header(hyper::header::USER_AGENT, "trace-client")
+30
View File
@@ -228,6 +228,16 @@ pub(crate) enum TraceLifecycleEvent {
BridgeIssued, BridgeIssued,
/// Bootstrap or bridge admission was rejected. /// Bootstrap or bridge admission was rejected.
BootstrapRejected, BootstrapRejected,
/// A session request was classified for legacy or automatic negotiation.
CarrierClassified,
/// One carrier candidate was selected for an attempt.
CarrierSelected,
/// A provisional carrier was reported failed by the bridge.
CarrierFailed,
/// An uncommitted carrier session was atomically superseded.
CarrierSuperseded,
/// The first OPEN or DATA batch made a carrier immutable.
CarrierCommitted,
/// A new session was created. /// A new session was created.
SessionCreated, SessionCreated,
/// An idempotent session creation was replayed. /// An idempotent session creation was replayed.
@@ -264,6 +274,11 @@ impl TraceLifecycleEvent {
match self { match self {
Self::BridgeIssued => "bridge_issued", Self::BridgeIssued => "bridge_issued",
Self::BootstrapRejected => "bootstrap_rejected", Self::BootstrapRejected => "bootstrap_rejected",
Self::CarrierClassified => "carrier_classified",
Self::CarrierSelected => "carrier_selected",
Self::CarrierFailed => "carrier_failed",
Self::CarrierSuperseded => "carrier_superseded",
Self::CarrierCommitted => "carrier_committed",
Self::SessionCreated => "session_created", Self::SessionCreated => "session_created",
Self::SessionReplayed => "session_replayed", Self::SessionReplayed => "session_replayed",
Self::SessionRejected => "session_rejected", Self::SessionRejected => "session_rejected",
@@ -282,6 +297,19 @@ impl TraceLifecycleEvent {
} }
} }
/// Non-sensitive carrier-negotiation fields attached to lifecycle records.
#[derive(Debug)]
pub(crate) struct TraceCarrierDetail {
/// Stable client classification; never a raw User-Agent.
pub(crate) client_class: &'static str,
/// Candidate associated with the lifecycle transition.
pub(crate) carrier: crate::config::WebCarrier,
/// One-based candidate attempt.
pub(crate) attempt: u8,
/// Weighted learning scores indexed by the canonical carrier order.
pub(crate) scores: [i16; 4],
}
/// One typed WEB lifecycle observation. /// One typed WEB lifecycle observation.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct TraceLifecycleRecord { pub(crate) struct TraceLifecycleRecord {
@@ -291,6 +319,8 @@ pub(crate) struct TraceLifecycleRecord {
pub(crate) stream_id: Option<u32>, pub(crate) stream_id: Option<u32>,
/// Closed outcome or rejection reason. /// Closed outcome or rejection reason.
pub(crate) reason: Option<&'static str>, pub(crate) reason: Option<&'static str>,
/// Carrier negotiation detail when this is a carrier lifecycle event.
pub(crate) carrier: Option<TraceCarrierDetail>,
} }
/// Trace record payload variant. /// Trace record payload variant.