mirror of
https://github.com/telemt/telemt.git
synced 2026-09-05 18:16:06 +03:00
Carrier negotiation + WebSocket lifecycle
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -339,6 +339,9 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"stream_handshake_secs",
|
||||
"stream_first_byte_secs",
|
||||
"long_poll_secs",
|
||||
"bridge_request_secs",
|
||||
"bridge_retry_secs",
|
||||
"carrier_probe_coalesce_ms",
|
||||
"lane_open_wait_secs",
|
||||
"carrier_health_secs",
|
||||
"websocket_upgrade_secs",
|
||||
|
||||
@@ -33,6 +33,18 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
||||
if !(2..=86_400).contains(&timeouts.carrier_learning_secs) {
|
||||
return config_error("web.timeouts.carrier_learning_secs must be within [2, 86400]");
|
||||
}
|
||||
if !(1..=60).contains(&timeouts.bridge_request_secs) {
|
||||
return config_error("web.timeouts.bridge_request_secs must be within [1, 60]");
|
||||
}
|
||||
if !(1..=300).contains(&timeouts.bridge_retry_secs) {
|
||||
return config_error("web.timeouts.bridge_retry_secs must be within [1, 300]");
|
||||
}
|
||||
if timeouts.bridge_request_secs > timeouts.bridge_retry_secs {
|
||||
return config_error("web.timeouts.bridge_request_secs must not exceed bridge_retry_secs");
|
||||
}
|
||||
if timeouts.carrier_probe_coalesce_ms > 10 {
|
||||
return config_error("web.timeouts.carrier_probe_coalesce_ms must be within [0, 10]");
|
||||
}
|
||||
if timeouts.stream_first_byte_secs > 300 {
|
||||
return config_error("web.timeouts.stream_first_byte_secs must be within [1, 300]");
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ fn web_carriers_reject_true_empty_and_duplicates() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_carrier_deadlines_and_learning_window_are_configurable() {
|
||||
fn web_carrier_and_bridge_deadlines_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]]",
|
||||
"[web.timeouts]\ncarrier_negotiation_deadlines_secs = [1, 2, 4, 9]\ncarrier_learning_secs = 30\nbridge_request_secs = 7\nbridge_retry_secs = 41\ncarrier_probe_coalesce_ms = 4\n\n[[web.vhosts]]",
|
||||
);
|
||||
let config = load_config_from_temp_toml(&configured);
|
||||
assert_eq!(
|
||||
@@ -119,6 +119,51 @@ fn web_carrier_deadlines_and_learning_window_are_configurable() {
|
||||
[1, 2, 4, 9]
|
||||
);
|
||||
assert_eq!(config.web.timeouts.carrier_learning_secs, 30);
|
||||
assert_eq!(config.web.timeouts.bridge_request_secs, 7);
|
||||
assert_eq!(config.web.timeouts.bridge_retry_secs, 41);
|
||||
assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_bridge_deadlines_are_known_in_strict_mode() {
|
||||
let configured = WEB_CONFIG.replace(
|
||||
"[[web.vhosts]]",
|
||||
"[web.timeouts]\nbridge_request_secs = 7\nbridge_retry_secs = 41\ncarrier_probe_coalesce_ms = 4\n\n[[web.vhosts]]",
|
||||
);
|
||||
let configured = format!("[general]\nconfig_strict = true\n{configured}");
|
||||
let config = load_config_from_temp_toml(&configured);
|
||||
|
||||
assert_eq!(config.web.timeouts.bridge_request_secs, 7);
|
||||
assert_eq!(config.web.timeouts.bridge_retry_secs, 41);
|
||||
assert_eq!(config.web.timeouts.carrier_probe_coalesce_ms, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_bridge_deadlines_are_bounded_and_ordered() {
|
||||
for (field, value) in [
|
||||
("bridge_request_secs", "0"),
|
||||
("bridge_request_secs", "61"),
|
||||
("bridge_retry_secs", "0"),
|
||||
("bridge_retry_secs", "301"),
|
||||
("carrier_probe_coalesce_ms", "11"),
|
||||
] {
|
||||
let invalid = WEB_CONFIG.replace(
|
||||
"[[web.vhosts]]",
|
||||
&format!("[web.timeouts]\n{field} = {value}\n\n[[web.vhosts]]"),
|
||||
);
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&invalid).contains(&format!("web.timeouts.{field}"))
|
||||
);
|
||||
}
|
||||
|
||||
let reversed = WEB_CONFIG.replace(
|
||||
"[[web.vhosts]]",
|
||||
"[web.timeouts]\nbridge_request_secs = 20\nbridge_retry_secs = 10\n\n[[web.vhosts]]",
|
||||
);
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&reversed)
|
||||
.contains("bridge_request_secs must not exceed bridge_retry_secs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -288,6 +288,15 @@ pub struct WebTimeoutsConfig {
|
||||
/// Maximum wait for one empty downlink long poll.
|
||||
#[serde(default = "default_web_long_poll_timeout_secs")]
|
||||
pub long_poll_secs: u64,
|
||||
/// Deadline for one generated-bridge HTTP attempt and response body.
|
||||
#[serde(default = "default_web_bridge_request_secs")]
|
||||
pub bridge_request_secs: u64,
|
||||
/// Absolute generated-bridge budget for one retryable HTTP operation.
|
||||
#[serde(default = "default_web_bridge_retry_secs")]
|
||||
pub bridge_retry_secs: u64,
|
||||
/// Optional delay for coalescing the first OPEN with immediate DATA.
|
||||
#[serde(default = "default_web_carrier_probe_coalesce_ms")]
|
||||
pub carrier_probe_coalesce_ms: u64,
|
||||
/// Grace for a canonical downlink poll that races its lane OPEN.
|
||||
#[serde(default = "default_web_lane_open_wait_secs")]
|
||||
pub lane_open_wait_secs: u64,
|
||||
@@ -340,6 +349,9 @@ impl Default for WebTimeoutsConfig {
|
||||
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
|
||||
stream_first_byte_secs: default_web_stream_first_byte_secs(),
|
||||
long_poll_secs: default_web_long_poll_timeout_secs(),
|
||||
bridge_request_secs: default_web_bridge_request_secs(),
|
||||
bridge_retry_secs: default_web_bridge_retry_secs(),
|
||||
carrier_probe_coalesce_ms: default_web_carrier_probe_coalesce_ms(),
|
||||
lane_open_wait_secs: default_web_lane_open_wait_secs(),
|
||||
carrier_health_secs: default_web_carrier_health_secs(),
|
||||
websocket_upgrade_secs: default_web_websocket_upgrade_secs(),
|
||||
|
||||
@@ -85,6 +85,9 @@ u64_default!(default_web_body_timeout_secs, 30);
|
||||
u64_default!(default_web_stream_handshake_timeout_secs, 10);
|
||||
u64_default!(default_web_stream_first_byte_secs, 30);
|
||||
u64_default!(default_web_long_poll_timeout_secs, 25);
|
||||
u64_default!(default_web_bridge_request_secs, 10);
|
||||
u64_default!(default_web_bridge_retry_secs, 90);
|
||||
u64_default!(default_web_carrier_probe_coalesce_ms, 0);
|
||||
u64_default!(default_web_lane_open_wait_secs, 2);
|
||||
u64_default!(default_web_carrier_health_secs, 30);
|
||||
u64_default!(default_web_websocket_upgrade_secs, 5);
|
||||
|
||||
+16
-16
@@ -3572,24 +3572,24 @@ async fn render_metrics(
|
||||
let _ = writeln!(out, "# TYPE telemt_user_connections_current gauge");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_user_octets_from_client_total Per-user total bytes received"
|
||||
"# HELP telemt_user_octets_from_client Per-user bytes received"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_user_octets_from_client_total counter");
|
||||
let _ = writeln!(out, "# TYPE telemt_user_octets_from_client counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_user_octets_to_client_total Per-user total bytes sent"
|
||||
"# HELP telemt_user_octets_to_client Per-user bytes sent"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_user_octets_to_client_total counter");
|
||||
let _ = writeln!(out, "# TYPE telemt_user_octets_to_client counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_user_msgs_from_client_total Per-user total messages received"
|
||||
"# HELP telemt_user_msgs_from_client Per-user messages received"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_user_msgs_from_client_total counter");
|
||||
let _ = writeln!(out, "# TYPE telemt_user_msgs_from_client counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_user_msgs_to_client_total Per-user total messages sent"
|
||||
"# HELP telemt_user_msgs_to_client Per-user messages sent"
|
||||
);
|
||||
let _ = writeln!(out, "# TYPE telemt_user_msgs_to_client_total counter");
|
||||
let _ = writeln!(out, "# TYPE telemt_user_msgs_to_client counter");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# HELP telemt_ip_reservation_rollback_total IP reservation rollbacks caused by later limit checks"
|
||||
@@ -3708,28 +3708,28 @@ async fn render_metrics(
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_user_octets_from_client_total{{user=\"{}\"}} {}",
|
||||
"telemt_user_octets_from_client{{user=\"{}\"}} {}",
|
||||
user,
|
||||
s.octets_from_client
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_user_octets_to_client_total{{user=\"{}\"}} {}",
|
||||
"telemt_user_octets_to_client{{user=\"{}\"}} {}",
|
||||
user,
|
||||
s.octets_to_client
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_user_msgs_from_client_total{{user=\"{}\"}} {}",
|
||||
"telemt_user_msgs_from_client{{user=\"{}\"}} {}",
|
||||
user,
|
||||
s.msgs_from_client
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"telemt_user_msgs_to_client_total{{user=\"{}\"}} {}",
|
||||
"telemt_user_msgs_to_client{{user=\"{}\"}} {}",
|
||||
user,
|
||||
s.msgs_to_client.load(std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
@@ -3990,10 +3990,10 @@ mod tests {
|
||||
assert!(output.contains("telemt_me_endpoint_quarantine_draining_suppressed_total 1"));
|
||||
assert!(output.contains("telemt_user_connections_total{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_connections_current{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_octets_from_client_total{user=\"alice\"} 1024"));
|
||||
assert!(output.contains("telemt_user_octets_to_client_total{user=\"alice\"} 2048"));
|
||||
assert!(output.contains("telemt_user_msgs_from_client_total{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_msgs_to_client_total{user=\"alice\"} 2"));
|
||||
assert!(output.contains("telemt_user_octets_from_client{user=\"alice\"} 1024"));
|
||||
assert!(output.contains("telemt_user_octets_to_client{user=\"alice\"} 2048"));
|
||||
assert!(output.contains("telemt_user_msgs_from_client{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_msgs_to_client{user=\"alice\"} 2"));
|
||||
assert!(output.contains("telemt_user_unique_ips_current{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_unique_ips_recent_window{user=\"alice\"} 1"));
|
||||
assert!(output.contains("telemt_user_unique_ips_limit{user=\"alice\"} 4"));
|
||||
|
||||
+14
-455
@@ -24,12 +24,17 @@ pub(crate) fn render(
|
||||
negotiation_enabled: bool,
|
||||
candidate_count: usize,
|
||||
carrier_deadlines: [u64; 4],
|
||||
long_poll_secs: u64,
|
||||
bridge_request_secs: u64,
|
||||
bridge_retry_secs: u64,
|
||||
carrier_probe_coalesce_ms: u64,
|
||||
rng: &SecureRandom,
|
||||
) -> BridgePage {
|
||||
let mut nonce = [0u8; 18];
|
||||
rng.fill(&mut nonce);
|
||||
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
|
||||
let body = DOCUMENT
|
||||
.replace("__RUNTIME__", RUNTIME)
|
||||
.replace("__NONCE__", &nonce)
|
||||
.replace("__HOST__", host)
|
||||
.replace("__BOOTSTRAP__", bootstrap)
|
||||
@@ -41,6 +46,13 @@ pub(crate) fn render(
|
||||
if negotiation_enabled { "true" } else { "false" },
|
||||
)
|
||||
.replace("__CANDIDATE_COUNT__", &candidate_count.to_string())
|
||||
.replace("__LONG_POLL_SECS__", &long_poll_secs.to_string())
|
||||
.replace("__BRIDGE_REQUEST_SECS__", &bridge_request_secs.to_string())
|
||||
.replace("__BRIDGE_RETRY_SECS__", &bridge_retry_secs.to_string())
|
||||
.replace(
|
||||
"__CARRIER_PROBE_COALESCE_MS__",
|
||||
&carrier_probe_coalesce_ms.to_string(),
|
||||
)
|
||||
.replace(
|
||||
"__CARRIER_DEADLINES__",
|
||||
&carrier_deadlines
|
||||
@@ -57,461 +69,8 @@ pub(crate) fn render(
|
||||
}
|
||||
}
|
||||
|
||||
const DOCUMENT: &str = r##"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Connection</title>
|
||||
</head>
|
||||
<body>
|
||||
<script nonce="__NONCE__">
|
||||
(()=>{
|
||||
'use strict';
|
||||
const bootstrap="__BOOTSTRAP__";
|
||||
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
|
||||
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
|
||||
let negotiatedCandidateCount=candidateCount,negotiatedFinalDeadline=candidateDeadlines[3],negotiatedFrozen=false;
|
||||
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
|
||||
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]||'';
|
||||
history.replaceState(null,'',location.pathname);
|
||||
let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
|
||||
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,currentAttempt=null;
|
||||
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
|
||||
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
|
||||
const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{
|
||||
if(signal&&signal.aborted){reject(new Error('request aborted'));return}
|
||||
const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()}
|
||||
function abort(){clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new Error('request aborted'))}
|
||||
if(signal)signal.addEventListener('abort',abort,{once:true});
|
||||
});
|
||||
const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
|
||||
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',
|
||||
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
|
||||
});
|
||||
const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
|
||||
function reserve(data,lane){
|
||||
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(lane&&(data.byteLength>laneQueueLimit-lane.bytes-(lane.socket?lane.socket.bufferedAmount:0)||lane.items>=laneItemLimit))return false;
|
||||
queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
|
||||
}
|
||||
function release(bytes,items,lane){queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}}
|
||||
function frameBound(value,maxFrames,maxBytes){
|
||||
const view=new DataView(value);let offset=0,frames=0;
|
||||
while(offset<value.byteLength){
|
||||
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
|
||||
const size=view.getUint32(offset+4),end=offset+8+size;
|
||||
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
|
||||
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
|
||||
frames++;offset=end;
|
||||
}
|
||||
if(!frames)throw new Error('empty frame batch');
|
||||
return {frames,bytes:offset};
|
||||
}
|
||||
function splitFrames(value){
|
||||
const view=new DataView(value),result=[];let offset=0;
|
||||
while(offset<value.byteLength){
|
||||
if(value.byteLength-offset<8||result.length>=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');
|
||||
result.push({type,id,data:offset===0&&end===value.byteLength?value:value.slice(offset,end)});offset=end;
|
||||
}
|
||||
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){
|
||||
let total=0,count=0,frames=0;
|
||||
while(count<values.length){
|
||||
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
|
||||
if(count===0&&!whole){
|
||||
const head=new Uint8Array(values[0],0,bound.bytes).slice();
|
||||
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
|
||||
return {body:head.buffer,total:bound.bytes,count:1};
|
||||
}
|
||||
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
|
||||
total+=values[count].byteLength;frames+=bound.frames;count++;
|
||||
}
|
||||
const joined=new Uint8Array(total);let offset=0;
|
||||
for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
|
||||
return {body:joined.buffer,total,count};
|
||||
}
|
||||
function retryAfterMs(response){
|
||||
const header=response.headers.get('Retry-After');
|
||||
if(!header)return 0;
|
||||
const seconds=Number(header);
|
||||
if(Number.isFinite(seconds)&&seconds>=0)return Math.min(seconds*1000,30000);
|
||||
const when=Date.parse(header);
|
||||
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
|
||||
return 0;
|
||||
}
|
||||
async function request(path,frozenOptions){
|
||||
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
||||
while(true){
|
||||
const controller=new AbortController(),external=frozenOptions.signal;
|
||||
if(closed||(external&&external.aborted))throw new Error('request aborted');
|
||||
const abort=()=>controller.abort();if(external)external.addEventListener('abort',abort,{once:true});
|
||||
const requestOptions=Object.assign({},frozenOptions,{signal:controller.signal});const timer=setTimeout(abort,90000);
|
||||
let serviceUnavailable=false,wait=0;
|
||||
try{
|
||||
const response=await fetch(relayOrigin+path,requestOptions);
|
||||
if(response.status!==503)return response;
|
||||
serviceUnavailable=true;wait=retryAfterMs(response);await response.arrayBuffer();
|
||||
}catch(error){
|
||||
if(closed||(external&&external.aborted))throw error;
|
||||
if(++attempt===9)throw new Error('carrier retry limit reached');
|
||||
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
||||
if(serviceUnavailable&&Date.now()>=deadline)throw new Error('carrier retry limit reached');
|
||||
status('reconnecting');await pause(wait||(delay+Math.floor(Math.random()*Math.max(1,delay/4))),external);
|
||||
if(closed||(external&&external.aborted))throw new Error('request aborted');
|
||||
if(!serviceUnavailable)delay=Math.min(delay*2,5000);
|
||||
}
|
||||
}
|
||||
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
|
||||
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
|
||||
function sessionEcho(response,expectedAttempt,states,exactAttempt){
|
||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
|
||||
if(!knownCarrier(selected))throw new Error('invalid carrier mode');
|
||||
if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:''}}
|
||||
const count=response.headers.get('X-Carrier-Candidate-Count')||'',deadline=response.headers.get('X-Carrier-Deadline')||'',state=response.headers.get('X-Carrier-State')||'';
|
||||
if(!/^[1-4]$/.test(count)||!/^[1-9]\d*$/.test(deadline)||!states.includes(state))throw new Error('invalid carrier state');
|
||||
const echoedAttempt=Number(echo),parsedCount=Number(count),parsedDeadline=Number(deadline);
|
||||
if(!Number.isInteger(echoedAttempt)||echoedAttempt<1||(exactAttempt?echoedAttempt!==expectedAttempt:echoedAttempt>expectedAttempt))throw new Error('invalid carrier attempt');
|
||||
if(parsedCount>candidateCount||parsedDeadline>candidateDeadlines[3])throw new Error('invalid carrier bounds');
|
||||
if(!negotiatedFrozen){negotiatedCandidateCount=parsedCount;negotiatedFinalDeadline=parsedDeadline;negotiatedFrozen=true}
|
||||
else if(parsedCount!==negotiatedCandidateCount||parsedDeadline!==negotiatedFinalDeadline)throw new Error('changed carrier bounds');
|
||||
if(echoedAttempt>negotiatedCandidateCount)throw new Error('carrier attempt exceeds candidates');
|
||||
return {selected,state};
|
||||
}
|
||||
function armCarrierDeadline(epoch){
|
||||
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
||||
if(carrierTimer)clearTimeout(carrierTimer);
|
||||
const deadline=carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline:candidateDeadlines[carrierAttempt-1];
|
||||
const remaining=negotiationStartedAt+deadline*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;currentAttempt=null;
|
||||
}
|
||||
function advanceConfirmed(reason,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
||||
resetCandidate();
|
||||
if(carrierAttempt>=negotiatedCandidateCount||Date.now()>=negotiationStartedAt+negotiatedFinalDeadline*1000){switching=false;fail();return}
|
||||
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
|
||||
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
|
||||
}
|
||||
function advanceCarrier(reason,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
|
||||
if(!negotiationEnabled){fail();return}
|
||||
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
const snapshot=currentAttempt;if(attemptController)attemptController.abort();attemptController=null;
|
||||
if(!snapshot||snapshot.epoch!==epoch){switching=false;fail();return}
|
||||
resolveAttempt(reason,epoch,snapshot);
|
||||
}
|
||||
async function resolveAttempt(reason,epoch,snapshot){
|
||||
const controller=new AbortController();attemptController=controller;
|
||||
const remaining=negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now();
|
||||
if(remaining<=0){switching=false;fail();return}
|
||||
const timer=setTimeout(()=>controller.abort(),remaining);
|
||||
try{
|
||||
const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(snapshot.attempt,snapshot.failure),controller.signal);
|
||||
const response=await request('/api/v1/session',frozen);
|
||||
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||
if(response.status===409){sessionEcho(response,snapshot.attempt,['committed','healthy'],false);await response.arrayBuffer();switching=false;fail();return}
|
||||
if(response.status!==200){await response.arrayBuffer();switching=false;fail();return}
|
||||
const echo=sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true);
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'||(snapshot.selected&&echo.selected!==snapshot.selected))throw new Error('changed carrier replay');
|
||||
const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return;
|
||||
cleanupToken=token;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||
if(echo.state!=='provisional'){switching=false;fail();return}
|
||||
advanceConfirmed(reason,epoch);
|
||||
}catch(error){if(!closed&&epoch===attemptEpoch){switching=false;fail()}}
|
||||
finally{clearTimeout(timer);if(attemptController===controller)attemptController=null}
|
||||
}
|
||||
function maybeStartCandidate(){
|
||||
let probe;try{probe=findProbe()}catch(error){fail();return}
|
||||
if(!probe||closed||carrierCommitted)return;
|
||||
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){
|
||||
const controller=new AbortController(),attempt=carrierAttempt,failure=carrierFailure;
|
||||
const snapshot={epoch,attempt,failure,hello:helloFrame,selected:''};currentAttempt=snapshot;attemptController=controller;
|
||||
try{
|
||||
status('connecting');
|
||||
const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(attempt,failure),controller.signal);
|
||||
const response=await request('/api/v1/session',frozen);
|
||||
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
|
||||
if(response.status===409){sessionEcho(response,attempt,['committed','healthy'],false);await response.arrayBuffer();fail();return}
|
||||
if(response.status!==200){await response.arrayBuffer();advanceCarrier('http',epoch);return}
|
||||
const echo=sessionEcho(response,attempt,['provisional'],true),selected=echo.selected;snapshot.selected=selected;
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'){await response.arrayBuffer();advanceCarrier('protocol',epoch);return}
|
||||
const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return;
|
||||
carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||
maybeStartCandidate();
|
||||
}catch(error){if(closed||epoch!==attemptEpoch)return;advanceCarrier('network',epoch)}
|
||||
}
|
||||
async function probeHttp(probe,laneID,epoch){
|
||||
try{
|
||||
const headers={'X-Up-Seq':'1'},token=sessionToken,controller=attemptController,body=probe.data;if(laneID!==null)headers['X-Lane-ID']=String(laneID);
|
||||
const response=await request('/api/v1/up',options('POST',token,body,headers,controller.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;
|
||||
if(switching){fail();return}
|
||||
try{consumeProbe(probe)}catch(error){fail();return}
|
||||
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
attemptController=null;currentAttempt=null;
|
||||
status('connected');
|
||||
if(carrier==='https')poll();
|
||||
else if(carrier==='https-lanes'){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){
|
||||
try{
|
||||
if(carrier==='https')queueUp(data);
|
||||
else if(carrier==='websocket')queueSocket(data);
|
||||
else for(const value of splitFrames(data))queueLane(value);
|
||||
}catch(error){fail()}
|
||||
}
|
||||
function queueUp(data){if(!reserve(data,null)){fail();return}upPending.push(data);runUp()}
|
||||
async function runUp(){
|
||||
if(upRunning)return;upRunning=true;
|
||||
try{
|
||||
while(!closed&&sessionToken&&upPending.length){
|
||||
const batch=joinPending(upPending,null),sequence=String(upSequence);
|
||||
const response=await request('/api/v1/up',options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence}));
|
||||
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
|
||||
release(batch.total,batch.count,null);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++;
|
||||
}
|
||||
}catch(error){fail()}
|
||||
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
|
||||
}
|
||||
function openCandidateSocket(probe,laneID,epoch){
|
||||
const token=sessionToken,protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+token:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+token+'.'+String(laneID);
|
||||
const next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';let opened=false,lane=null;
|
||||
if(laneID===null)socket=next;else{lane=ensureLane(laneID);lane.socket=next}
|
||||
next.onopen=()=>{
|
||||
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');
|
||||
};
|
||||
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()}
|
||||
async function waitSocket(next,size,limit){
|
||||
while(!closed&&next.readyState===WebSocket.OPEN&&next.bufferedAmount>limit-size)await pause(10);
|
||||
if(closed||next.readyState!==WebSocket.OPEN)throw new Error('websocket closed');
|
||||
}
|
||||
async function runSocketUp(){
|
||||
if(upRunning||!socketReady)return;upRunning=true;
|
||||
try{
|
||||
while(!closed&&socketReady&&upPending.length){
|
||||
const batch=joinPending(upPending,null);await waitSocket(socket,batch.total,queueLimit);socket.send(batch.body);
|
||||
release(batch.total,batch.count,null);port.postMessage({t:'traffic',up:batch.total,down:0});
|
||||
}
|
||||
}catch(error){if(!closed)fail()}
|
||||
finally{upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}
|
||||
}
|
||||
async function poll(){
|
||||
while(!closed&&sessionToken){
|
||||
try{
|
||||
pollController=new AbortController();
|
||||
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
|
||||
if(response.status===204){status('connected');continue}
|
||||
if(response.status!==200)throw new Error('downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
||||
if(!next||!data.byteLength)throw new Error('invalid downlink response');
|
||||
if(closed)return;
|
||||
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
|
||||
}catch(error){if(!closed)fail();return}
|
||||
}
|
||||
}
|
||||
function ensureLane(id){
|
||||
let lane=lanes.get(id);
|
||||
if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,polling:false,controller:null,socket:null,ready:false,remoteClosed:false};lanes.set(id,lane)}
|
||||
return lane;
|
||||
}
|
||||
function rememberLaneClosed(id){
|
||||
if(!id||closedLanes.has(id))return;
|
||||
if(closedLaneOrder.length===closedLaneLimit)closedLanes.delete(closedLaneOrder.shift());
|
||||
closedLanes.add(id);closedLaneOrder.push(id);
|
||||
}
|
||||
function closeFrame(id){const value=new Uint8Array(8);value[0]=3;value[1]=(id>>>16)&255;value[2]=(id>>>8)&255;value[3]=id&255;return value.buffer}
|
||||
function finishLane(lane,notifyClient){
|
||||
if(lanes.get(lane.id)!==lane)return;
|
||||
if(lane.socket&&lane.socket.readyState<WebSocket.CLOSING)lane.socket.close();
|
||||
if(lane.bytes||lane.items)release(lane.bytes,lane.items,lane);
|
||||
lane.pending.length=0;lanes.delete(lane.id);rememberLaneClosed(lane.id);
|
||||
if(notifyClient&&!lane.remoteClosed&&port){const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
|
||||
}
|
||||
function queueLane(value){
|
||||
let lane=lanes.get(value.id);
|
||||
if(!lane&&(value.type===2||value.type===3||value.type===4))return;
|
||||
if(!lane&&closedLanes.has(value.id))throw new Error('closed lane was reused');
|
||||
if(!lane&&value.type!==1)throw new Error('lane did not begin with OPEN');
|
||||
lane=lane||ensureLane(value.id);
|
||||
if(!reserve(value.data,lane)){fail();return}
|
||||
lane.pending.push(value.data);
|
||||
if(carrier==='websocket-lanes'){openLaneSocket(lane);runLaneSocketUp(lane)}else runLaneUp(lane);
|
||||
}
|
||||
function openLaneSocket(lane){
|
||||
if(lane.socket||closed)return;lane.socket=new WebSocket(socketURL(),'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id));lane.socket.binaryType='arraybuffer';
|
||||
lane.socket.onopen=()=>{if(closed||lanes.get(lane.id)!==lane)return;lane.ready=true;status('connected');runLaneSocketUp(lane)};
|
||||
lane.socket.onmessage=event=>{
|
||||
if(closed||lanes.get(lane.id)!==lane||!(event.data instanceof ArrayBuffer)){finishLane(lane,true);return}
|
||||
let values;try{values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame')}catch(error){finishLane(lane,true);return}
|
||||
if(values.some(value=>value.type===3))lane.remoteClosed=true;
|
||||
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
|
||||
};
|
||||
lane.socket.onerror=()=>{};lane.socket.onclose=()=>{lane.ready=false;lane.socket=null;if(!closed)finishLane(lane,true)};
|
||||
}
|
||||
async function runLaneSocketUp(lane){
|
||||
if(lane.running||!lane.ready)return;lane.running=true;
|
||||
try{
|
||||
while(!closed&&lane.ready&&lanes.get(lane.id)===lane&&lane.pending.length){
|
||||
const batch=joinPending(lane.pending,lane);await waitSocket(lane.socket,batch.total,laneQueueLimit);lane.socket.send(batch.body);
|
||||
release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});
|
||||
}
|
||||
}catch(error){if(!closed)finishLane(lane,true)}
|
||||
finally{lane.running=false;if(!closed&&lane.ready&&lane.pending.length)runLaneSocketUp(lane)}
|
||||
}
|
||||
async function runLaneUp(lane){
|
||||
if(lane.running)return;lane.running=true;
|
||||
try{
|
||||
while(!closed&&sessionToken&&lane.pending.length){
|
||||
const batch=joinPending(lane.pending,lane),sequence=String(lane.sequence),laneID=String(lane.id);
|
||||
const response=await request('/api/v1/up',options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID}));
|
||||
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected');
|
||||
release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});lane.sequence++;
|
||||
if(!lane.polling)pollLane(lane);
|
||||
}
|
||||
}catch(error){fail()}
|
||||
finally{lane.running=false;if(!closed&&sessionToken&&lane.pending.length)runLaneUp(lane)}
|
||||
}
|
||||
async function pollLane(lane){
|
||||
if(!lane||lane.polling)return;lane.polling=true;
|
||||
try{
|
||||
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
|
||||
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
|
||||
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
|
||||
if(response.status===204){
|
||||
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
|
||||
status('connected');continue;
|
||||
}
|
||||
if(response.status!==200)throw new Error('lane downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
||||
if(!next||!data.byteLength)throw new Error('invalid lane downlink response');
|
||||
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
|
||||
if(closed)return;
|
||||
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
|
||||
}
|
||||
}catch(error){if(!closed)fail()}
|
||||
finally{lane.polling=false;lane.controller=null}
|
||||
}
|
||||
function deleteSession(){
|
||||
const token=cleanupToken||sessionToken;if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,null,undefined,true)).catch(()=>{});
|
||||
}
|
||||
function close(notifyServer){
|
||||
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(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();
|
||||
}
|
||||
function activatePort(nextPort){
|
||||
initialized=true;port=nextPort;
|
||||
port.onmessage=message=>{
|
||||
if(message.data instanceof ArrayBuffer){
|
||||
if(!createStarted){createStarted=true;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
|
||||
else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
|
||||
else queueCarrier(message.data);
|
||||
}else if(message.data&&message.data.t==='close')close(true);
|
||||
};
|
||||
port.start();status('connecting');
|
||||
}
|
||||
addEventListener('message',event=>{
|
||||
if(initialized||event.source!==parent||event.data===null||typeof event.data!=='object')return;
|
||||
const keys=Object.keys(event.data).sort();
|
||||
if(keys.length!==2||keys[0]!=='t'||keys[1]!=='v'||event.data.t!=='tproxy-init'||event.data.v!==1||event.ports.length!==1)return;
|
||||
let source;try{source=new URL(event.origin)}catch(error){return}
|
||||
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
|
||||
activatePort(event.ports[0]);
|
||||
},{once:false});
|
||||
const androidBridge=globalThis.TelegramWebProxy;
|
||||
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
|
||||
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
|
||||
if(value instanceof ArrayBuffer){
|
||||
let frames;try{frames=splitFrames(value)}catch(error){fail();return}
|
||||
for(const frame of frames)androidBridge.postMessage(frame.data);
|
||||
}else androidBridge.postMessage(JSON.stringify(value));
|
||||
}};
|
||||
androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
|
||||
activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
|
||||
}
|
||||
addEventListener('pagehide',()=>close(true),{once:true});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
const DOCUMENT: &str = include_str!("bridge/document.html");
|
||||
const RUNTIME: &str = include_str!("bridge/runtime.js");
|
||||
|
||||
// Rendered wire-contract tests remain separate from the embedded document.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Connection</title>
|
||||
</head>
|
||||
<body>
|
||||
<script nonce="__NONCE__">
|
||||
__RUNTIME__
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,522 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
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 longPollMs=__LONG_POLL_SECS__*1000,bridgeRequestMs=__BRIDGE_REQUEST_SECS__*1000,bridgeRetryMs=__BRIDGE_RETRY_SECS__*1000;
|
||||
const probeCoalesceMs=__CARRIER_PROBE_COALESCE_MS__;
|
||||
let negotiatedCandidateCount=candidateCount,negotiatedFinalDeadline=candidateDeadlines[3],negotiatedFrozen=false;
|
||||
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
|
||||
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]||'';
|
||||
history.replaceState(null,'',location.pathname);
|
||||
let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
|
||||
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
|
||||
let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
|
||||
let negotiationStartedAt=0,carrierTimer=null,probeTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null;
|
||||
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
|
||||
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
|
||||
const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{
|
||||
if(signal&&signal.aborted){reject(new Error('request aborted'));return}
|
||||
const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()}
|
||||
function abort(){clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new Error('request aborted'))}
|
||||
if(signal)signal.addEventListener('abort',abort,{once:true});
|
||||
});
|
||||
const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
|
||||
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',
|
||||
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
|
||||
});
|
||||
const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
|
||||
function reserve(data,lane){
|
||||
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(lane&&(data.byteLength>laneQueueLimit-lane.bytes-(lane.socket?lane.socket.bufferedAmount:0)||lane.items>=laneItemLimit))return false;
|
||||
queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
|
||||
}
|
||||
function release(bytes,items,lane){
|
||||
if(bytes>queuedBytes||items>queuedItems||(lane&&(bytes>lane.bytes||items>lane.items)))throw new Error('queue accounting invariant');
|
||||
queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}
|
||||
}
|
||||
function releasePending(values,lane){
|
||||
if(!values.length)return;let bytes=0;for(const value of values)bytes+=value.byteLength;
|
||||
const items=values.length;values.length=0;release(bytes,items,lane);
|
||||
}
|
||||
function frameBound(value,maxFrames,maxBytes){
|
||||
const view=new DataView(value);let offset=0,frames=0;
|
||||
while(offset<value.byteLength){
|
||||
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
|
||||
const size=view.getUint32(offset+4),end=offset+8+size;
|
||||
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
|
||||
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
|
||||
frames++;offset=end;
|
||||
}
|
||||
if(!frames)throw new Error('empty frame batch');
|
||||
return {frames,bytes:offset};
|
||||
}
|
||||
function splitFrames(value){
|
||||
const view=new DataView(value),result=[];let offset=0;
|
||||
while(offset<value.byteLength){
|
||||
if(value.byteLength-offset<8||result.length>=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');
|
||||
result.push({type,id,data:offset===0&&end===value.byteLength?value:value.slice(offset,end)});offset=end;
|
||||
}
|
||||
if(!result.length)throw new Error('empty frame batch');return result;
|
||||
}
|
||||
function probeFrames(){
|
||||
const result=[];let scanned=0;
|
||||
for(let index=0;index<pending.length;index++){
|
||||
const source=pending[index],view=new DataView(source);let start=0;
|
||||
while(start<source.byteLength){
|
||||
if(source.byteLength-start<8||result.length>=4096)throw new Error('invalid frame batch');
|
||||
const type=view.getUint8(start),id=(view.getUint8(start+1)<<16)|(view.getUint8(start+2)<<8)|view.getUint8(start+3);
|
||||
const size=view.getUint32(start+4),end=start+8+size,bytes=end-start;
|
||||
if((type===2&&!size)||size>1048576||end>source.byteLength)throw new Error('invalid frame');
|
||||
if(scanned+bytes>batchLimit)return result;
|
||||
result.push({source,index,start,end,type,id});scanned+=bytes;start=end;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function findProbe(includeData){
|
||||
const frames=probeFrames(),first=frames.findIndex(frame=>frame.type===1||frame.type===2);if(first<0)return null;
|
||||
const laneMode=carrier==='https-lanes'||carrier==='websocket-lanes',selected=[];let hasData=frames[first].type===2;
|
||||
if(laneMode){
|
||||
selected.push(frames[first]);
|
||||
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++)if(frames[index].id===frames[first].id){selected.push(frames[index]);if(frames[index].type===2){hasData=true;break}}
|
||||
}else{
|
||||
let last=first;
|
||||
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++){if(frames[index].id===frames[first].id&&frames[index].type===2){last=index;hasData=true;break}}
|
||||
for(let index=0;index<=(hasData?last:first);index++)selected.push(frames[index]);
|
||||
}
|
||||
const spans=[];let total=0;
|
||||
for(const frame of selected){
|
||||
const previous=spans[spans.length-1];total+=frame.end-frame.start;
|
||||
if(previous&&previous.source===frame.source&&previous.end===frame.start)previous.end=frame.end;
|
||||
else spans.push({source:frame.source,index:frame.index,start:frame.start,end:frame.end});
|
||||
}
|
||||
const joined=new Uint8Array(total);let offset=0;
|
||||
for(const span of spans){const part=new Uint8Array(span.source,span.start,span.end-span.start);joined.set(part,offset);offset+=part.byteLength}
|
||||
return {spans,id:frames[first].id,data:joined.buffer,hasData};
|
||||
}
|
||||
function consumeProbe(probe){
|
||||
const groups=new Map();
|
||||
for(const span of probe.spans){if(pending[span.index]!==span.source)throw new Error('stale carrier probe');const values=groups.get(span.index)||[];values.push(span);groups.set(span.index,values)}
|
||||
const indexes=Array.from(groups.keys()).sort((left,right)=>right-left);
|
||||
for(const index of indexes){
|
||||
const source=pending[index],spans=groups.get(index).sort((left,right)=>left.start-right.start);let removed=0,offset=0;
|
||||
for(const span of spans){if(span.start<offset)throw new Error('overlapping carrier probe');removed+=span.end-span.start;offset=span.end}
|
||||
if(removed===source.byteLength){pending.splice(index,1);release(removed,1,null);continue}
|
||||
const merged=new Uint8Array(source.byteLength-removed);let write=0;offset=0;
|
||||
for(const span of spans){merged.set(new Uint8Array(source,offset,span.start-offset),write);write+=span.start-offset;offset=span.end}
|
||||
merged.set(new Uint8Array(source,offset),write);pending[index]=merged.buffer;release(removed,0,null);
|
||||
}
|
||||
}
|
||||
function joinPending(values,lane){
|
||||
let total=0,count=0,frames=0;
|
||||
while(count<values.length){
|
||||
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
|
||||
if(count===0&&!whole){
|
||||
const head=new Uint8Array(values[0],0,bound.bytes).slice();
|
||||
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
|
||||
return {body:head.buffer,total:bound.bytes,count:1};
|
||||
}
|
||||
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
|
||||
total+=values[count].byteLength;frames+=bound.frames;count++;
|
||||
}
|
||||
const joined=new Uint8Array(total);let offset=0;
|
||||
for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
|
||||
return {body:joined.buffer,total,count};
|
||||
}
|
||||
function takeBatch(values,lane){
|
||||
const batch=joinPending(values,lane);
|
||||
return Object.assign(batch,{lane,controller:null,cancelled:false,settled:false});
|
||||
}
|
||||
function settleBatch(lease){
|
||||
if(!lease||lease.settled)return false;lease.settled=true;
|
||||
if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null;
|
||||
release(lease.total,lease.count,lease.lane);return true;
|
||||
}
|
||||
function cancelBatch(lease){
|
||||
if(!lease||lease.settled)return;lease.cancelled=true;if(lease.controller)lease.controller.abort();settleBatch(lease);
|
||||
}
|
||||
function retryAfterMs(response){
|
||||
const header=response.headers.get('Retry-After');
|
||||
if(!header)return 0;
|
||||
const seconds=Number(header);
|
||||
if(Number.isFinite(seconds)&&seconds>=0)return Math.min(seconds*1000,30000);
|
||||
const when=Date.parse(header);
|
||||
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
|
||||
return 0;
|
||||
}
|
||||
function retryableStatus(status){return status===408||status===429||status===502||status===503||status===504}
|
||||
async function request(path,frozenOptions){
|
||||
let delay=250,attempt=0;const deadline=Date.now()+bridgeRetryMs,external=frozenOptions.signal;
|
||||
const attemptLimit=path==='/api/v1/down'?longPollMs+bridgeRequestMs:bridgeRequestMs;
|
||||
while(attempt<9){
|
||||
if(closed||(external&&external.aborted))throw new Error('request aborted');
|
||||
const remaining=deadline-Date.now();if(remaining<=0)break;attempt++;
|
||||
const controller=new AbortController(),abort=()=>controller.abort();
|
||||
if(external)external.addEventListener('abort',abort,{once:true});
|
||||
const requestOptions=Object.assign({},frozenOptions,{signal:controller.signal});
|
||||
const timer=setTimeout(abort,Math.max(1,Math.min(attemptLimit,remaining)));
|
||||
let response=null,wait=0;
|
||||
try{
|
||||
const fetched=await fetch(relayOrigin+path,requestOptions),body=await fetched.arrayBuffer();
|
||||
response={status:fetched.status,headers:fetched.headers,body};
|
||||
if(!retryableStatus(response.status))return response;
|
||||
wait=retryAfterMs(response);
|
||||
}catch(error){
|
||||
if(closed||(external&&external.aborted))throw error;
|
||||
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
||||
const after=deadline-Date.now();if(attempt>=9||after<=0)break;
|
||||
status('reconnecting');
|
||||
const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
|
||||
await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,5000);
|
||||
}
|
||||
throw new Error('carrier retry limit reached');
|
||||
}
|
||||
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
|
||||
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
|
||||
function sessionEcho(response,expectedAttempt,states,exactAttempt){
|
||||
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
|
||||
if(!knownCarrier(selected))throw new Error('invalid carrier mode');
|
||||
if(!negotiationEnabled){if(echo!=='')throw new Error('unexpected carrier attempt');return {selected,state:''}}
|
||||
const count=response.headers.get('X-Carrier-Candidate-Count')||'',deadline=response.headers.get('X-Carrier-Deadline')||'',state=response.headers.get('X-Carrier-State')||'';
|
||||
if(!/^[1-4]$/.test(count)||!/^[1-9]\d*$/.test(deadline)||!states.includes(state))throw new Error('invalid carrier state');
|
||||
const echoedAttempt=Number(echo),parsedCount=Number(count),parsedDeadline=Number(deadline);
|
||||
if(!Number.isInteger(echoedAttempt)||echoedAttempt<1||(exactAttempt?echoedAttempt!==expectedAttempt:echoedAttempt>expectedAttempt))throw new Error('invalid carrier attempt');
|
||||
if(parsedCount>candidateCount||parsedDeadline>candidateDeadlines[3])throw new Error('invalid carrier bounds');
|
||||
if(!negotiatedFrozen){negotiatedCandidateCount=parsedCount;negotiatedFinalDeadline=parsedDeadline;negotiatedFrozen=true}
|
||||
else if(parsedCount!==negotiatedCandidateCount||parsedDeadline!==negotiatedFinalDeadline)throw new Error('changed carrier bounds');
|
||||
if(echoedAttempt>negotiatedCandidateCount)throw new Error('carrier attempt exceeds candidates');
|
||||
return {selected,state};
|
||||
}
|
||||
function armCarrierDeadline(epoch){
|
||||
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
|
||||
if(carrierTimer)clearTimeout(carrierTimer);
|
||||
const deadline=carrierAttempt>=negotiatedCandidateCount?negotiatedFinalDeadline:candidateDeadlines[carrierAttempt-1];
|
||||
const remaining=negotiationStartedAt+deadline*1000-Date.now();
|
||||
carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
|
||||
}
|
||||
function clearProbeTimer(){if(probeTimer){clearTimeout(probeTimer.timer);probeTimer=null}}
|
||||
function resetCandidate(){
|
||||
clearProbeTimer();
|
||||
if(pollController)pollController.abort();pollController=null;
|
||||
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
|
||||
cancelBatch(upLease);releasePending(upPending,null);
|
||||
for(const lane of lanes.values()){
|
||||
if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
|
||||
}
|
||||
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upSequence=1;downCursor='0';upRunning=false;
|
||||
sessionToken='';carrier='';candidateRunning=false;currentAttempt=null;
|
||||
}
|
||||
function advanceConfirmed(reason,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
|
||||
resetCandidate();
|
||||
if(carrierAttempt>=negotiatedCandidateCount||Date.now()>=negotiationStartedAt+negotiatedFinalDeadline*1000){switching=false;fail();return}
|
||||
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
|
||||
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
|
||||
}
|
||||
function advanceCarrier(reason,epoch){
|
||||
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
|
||||
if(!negotiationEnabled){fail();return}
|
||||
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;clearProbeTimer();
|
||||
const snapshot=currentAttempt;if(attemptController)attemptController.abort();attemptController=null;
|
||||
if(!snapshot||snapshot.epoch!==epoch){switching=false;fail();return}
|
||||
if(snapshot.selected){advanceConfirmed(reason,epoch);return}
|
||||
resolveAttempt(reason,epoch,snapshot);
|
||||
}
|
||||
async function resolveAttempt(reason,epoch,snapshot){
|
||||
const controller=new AbortController();attemptController=controller;
|
||||
const remaining=negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now();
|
||||
if(remaining<=0){switching=false;fail();return}
|
||||
const timer=setTimeout(()=>controller.abort(),remaining);
|
||||
try{
|
||||
const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(snapshot.attempt,snapshot.failure),controller.signal);
|
||||
const response=await request('/api/v1/session',frozen);
|
||||
if(closed||epoch!==attemptEpoch)return
|
||||
if(response.status===409){sessionEcho(response,snapshot.attempt,['committed','healthy'],false);switching=false;fail();return}
|
||||
if(response.status!==200){switching=false;fail();return}
|
||||
const echo=sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true);
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'||(snapshot.selected&&echo.selected!==snapshot.selected))throw new Error('changed carrier replay');
|
||||
const welcome=response.body;if(closed||epoch!==attemptEpoch)return;
|
||||
cleanupToken=token;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||
if(echo.state!=='provisional'){switching=false;fail();return}
|
||||
advanceConfirmed(reason,epoch);
|
||||
}catch(error){if(!closed&&epoch===attemptEpoch){switching=false;fail()}}
|
||||
finally{clearTimeout(timer);if(attemptController===controller)attemptController=null}
|
||||
}
|
||||
function startCandidate(probe,epoch){
|
||||
if(!probe||closed||carrierCommitted||!sessionToken||candidateRunning||epoch!==attemptEpoch)return;
|
||||
clearProbeTimer();candidateRunning=true;
|
||||
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);
|
||||
}
|
||||
function maybeStartCandidate(){
|
||||
if(closed||carrierCommitted||!sessionToken||candidateRunning)return;const epoch=attemptEpoch;
|
||||
let probe;try{probe=findProbe(probeCoalesceMs>0)}catch(error){fail();return}if(!probe)return;
|
||||
if(!probeCoalesceMs||probe.hasData){startCandidate(probe,epoch);return}
|
||||
if(probeTimer)return;const owner={epoch,timer:null};
|
||||
owner.timer=setTimeout(()=>{if(probeTimer!==owner||closed||owner.epoch!==attemptEpoch)return;probeTimer=null;let current;try{current=findProbe(false)}catch(error){fail();return}startCandidate(current,owner.epoch)},probeCoalesceMs);
|
||||
probeTimer=owner;
|
||||
}
|
||||
async function createSession(epoch){
|
||||
const controller=new AbortController(),attempt=carrierAttempt,failure=carrierFailure;
|
||||
const snapshot={epoch,attempt,failure,hello:helloFrame,selected:''};currentAttempt=snapshot;attemptController=controller;
|
||||
try{
|
||||
status('connecting');
|
||||
const frozen=options('POST',bootstrap,snapshot.hello,attemptHeaders(attempt,failure),controller.signal);
|
||||
const response=await request('/api/v1/session',frozen);
|
||||
if(closed||epoch!==attemptEpoch)return
|
||||
if(response.status===409){sessionEcho(response,attempt,['committed','healthy'],false);fail();return}
|
||||
if(response.status!==200){advanceCarrier('http',epoch);return}
|
||||
const echo=sessionEcho(response,attempt,['provisional'],true),selected=echo.selected;snapshot.selected=selected;
|
||||
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
|
||||
if(!token||cursor!=='0'){advanceCarrier('protocol',epoch);return}
|
||||
const welcome=response.body;if(closed||epoch!==attemptEpoch)return;
|
||||
carrier=selected;sessionToken=token;cleanupToken=token;downCursor=cursor;
|
||||
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
|
||||
if(carrier==='websocket')openCandidateSocket(null,null,epoch);
|
||||
maybeStartCandidate();
|
||||
}catch(error){if(closed||epoch!==attemptEpoch)return;advanceCarrier('network',epoch)}
|
||||
}
|
||||
async function probeHttp(probe,laneID,epoch){
|
||||
try{
|
||||
const headers={'X-Up-Seq':'1'},token=sessionToken,controller=attemptController,body=probe.data;if(laneID!==null)headers['X-Lane-ID']=String(laneID);
|
||||
const response=await request('/api/v1/up',options('POST',token,body,headers,controller.signal));
|
||||
if(closed||epoch!==attemptEpoch)return
|
||||
if(response.status!==204){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;
|
||||
if(switching){fail();return}
|
||||
clearProbeTimer();try{consumeProbe(probe)}catch(error){fail();return}
|
||||
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
|
||||
attemptController=null;currentAttempt=null;
|
||||
status('connected');
|
||||
if(carrier==='https')poll();
|
||||
else if(carrier==='https-lanes'){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){
|
||||
try{
|
||||
if(carrier==='https')queueUp(data);
|
||||
else if(carrier==='websocket')queueSocket(data);
|
||||
else for(const value of splitFrames(data))queueLane(value);
|
||||
}catch(error){fail()}
|
||||
}
|
||||
function queueUp(data){if(!reserve(data,null)){fail();return}upPending.push(data);runUp()}
|
||||
async function runUp(){
|
||||
if(upRunning)return;upRunning=true;let lease=null;
|
||||
try{
|
||||
while(!closed&&sessionToken&&upPending.length){
|
||||
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence);
|
||||
const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence},lease.controller.signal));
|
||||
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});upSequence++;lease=null;
|
||||
}
|
||||
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail()}
|
||||
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
|
||||
}
|
||||
function sendCandidateSocket(next){
|
||||
const state=next.telemt;if(!state||state.sent||next.readyState!==WebSocket.OPEN||!state.probe)return;
|
||||
let probe=state.probe;
|
||||
try{const fresh=findProbe(true);if(fresh&&fresh.id===probe.id)probe=fresh;next.send(probe.data)}catch(error){advanceCarrier('upgrade',state.epoch);return}
|
||||
state.probe=probe;state.sent=true;if(!negotiationEnabled)commitCarrier(probe,state.epoch);
|
||||
}
|
||||
function openCandidateSocket(probe,laneID,epoch){
|
||||
let lane=laneID===null?null:ensureLane(laneID),next=lane?lane.socket:socket;
|
||||
if(next){if(!next.telemt||next.telemt.epoch!==epoch){advanceCarrier('protocol',epoch);return}if(probe)next.telemt.probe=probe;sendCandidateSocket(next);return}
|
||||
const token=sessionToken,protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+token:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+token+'.'+String(laneID);
|
||||
next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';next.telemt={epoch,lane,probe,opened:false,sent:false};
|
||||
if(lane)lane.socket=next;else socket=next;
|
||||
next.onopen=()=>{
|
||||
const state=next.telemt;if(closed||state.epoch!==attemptEpoch){next.close();return}state.opened=true;
|
||||
if(state.lane){state.lane.ready=true}else socketReady=true;sendCandidateSocket(next);
|
||||
};
|
||||
next.onmessage=event=>{
|
||||
const state=next.telemt;if(closed||state.epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
|
||||
if(!carrierCommitted){if(!state.sent||event.data.byteLength!==0){advanceCarrier('protocol',state.epoch);return}commitCarrier(state.probe,state.epoch);return}
|
||||
try{
|
||||
if(state.lane){const values=splitFrames(event.data);for(const value of values)if(value.id!==state.lane.id)throw new Error('cross-lane frame');if(values.some(value=>value.type===3))state.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(state.lane)finishLane(state.lane,true);else fail();return}
|
||||
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
|
||||
};
|
||||
next.onerror=()=>{};
|
||||
next.onclose=()=>{
|
||||
const state=next.telemt;if(state.epoch!==attemptEpoch||closed)return;
|
||||
if(!carrierCommitted){advanceCarrier(state.opened?'network':'upgrade',state.epoch);return}
|
||||
if(state.lane){state.lane.ready=false;state.lane.socket=null;finishLane(state.lane,true)}else{socketReady=false;fail()}
|
||||
};
|
||||
}
|
||||
function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()}
|
||||
async function waitSocket(next,size,limit,signal){
|
||||
while(!closed&&next.readyState===WebSocket.OPEN&&next.bufferedAmount>limit-size)await pause(10,signal);
|
||||
if(closed||(signal&&signal.aborted)||next.readyState!==WebSocket.OPEN)throw new Error('websocket closed');
|
||||
}
|
||||
async function runSocketUp(){
|
||||
if(upRunning||!socketReady)return;upRunning=true;let lease=null;
|
||||
try{
|
||||
while(!closed&&socketReady&&upPending.length){
|
||||
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();
|
||||
await waitSocket(socket,lease.total,queueLimit,lease.controller.signal);socket.send(lease.body);
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lease=null;
|
||||
}
|
||||
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail()}
|
||||
finally{upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}
|
||||
}
|
||||
async function poll(){
|
||||
while(!closed&&sessionToken){
|
||||
try{
|
||||
pollController=new AbortController();
|
||||
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
|
||||
if(response.status===204){status('connected');continue}
|
||||
if(response.status!==200)throw new Error('downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
|
||||
if(!next||!data.byteLength)throw new Error('invalid downlink response');
|
||||
if(closed)return;
|
||||
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
|
||||
}catch(error){if(!closed)fail();return}
|
||||
}
|
||||
}
|
||||
function ensureLane(id){
|
||||
let lane=lanes.get(id);
|
||||
if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,upLease:null,polling:false,controller:null,socket:null,ready:false,remoteClosed:false};lanes.set(id,lane)}
|
||||
return lane;
|
||||
}
|
||||
function rememberLaneClosed(id){
|
||||
if(!id||closedLanes.has(id))return;
|
||||
if(closedLaneOrder.length===closedLaneLimit)closedLanes.delete(closedLaneOrder.shift());
|
||||
closedLanes.add(id);closedLaneOrder.push(id);
|
||||
}
|
||||
function closeFrame(id){const value=new Uint8Array(8);value[0]=3;value[1]=(id>>>16)&255;value[2]=(id>>>8)&255;value[3]=id&255;return value.buffer}
|
||||
function finishLane(lane,notifyClient){
|
||||
if(lanes.get(lane.id)!==lane)return;
|
||||
if(lane.controller)lane.controller.abort();lane.controller=null;cancelBatch(lane.upLease);
|
||||
if(lane.socket&&lane.socket.readyState<WebSocket.CLOSING)lane.socket.close();
|
||||
releasePending(lane.pending,lane);lanes.delete(lane.id);rememberLaneClosed(lane.id);
|
||||
if(notifyClient&&!lane.remoteClosed&&port){const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
|
||||
}
|
||||
function queueLane(value){
|
||||
let lane=lanes.get(value.id);
|
||||
if(!lane&&(value.type===2||value.type===3||value.type===4))return;
|
||||
if(!lane&&closedLanes.has(value.id))throw new Error('closed lane was reused');
|
||||
if(!lane&&value.type!==1)throw new Error('lane did not begin with OPEN');
|
||||
lane=lane||ensureLane(value.id);
|
||||
if(!reserve(value.data,lane)){fail();return}
|
||||
lane.pending.push(value.data);
|
||||
if(carrier==='websocket-lanes'){openLaneSocket(lane);runLaneSocketUp(lane)}else runLaneUp(lane);
|
||||
}
|
||||
function openLaneSocket(lane){
|
||||
if(lane.socket||closed)return;lane.socket=new WebSocket(socketURL(),'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id));lane.socket.binaryType='arraybuffer';
|
||||
lane.socket.onopen=()=>{if(closed||lanes.get(lane.id)!==lane)return;lane.ready=true;status('connected');runLaneSocketUp(lane)};
|
||||
lane.socket.onmessage=event=>{
|
||||
if(closed||lanes.get(lane.id)!==lane||!(event.data instanceof ArrayBuffer)){finishLane(lane,true);return}
|
||||
let values;try{values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame')}catch(error){finishLane(lane,true);return}
|
||||
if(values.some(value=>value.type===3))lane.remoteClosed=true;
|
||||
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
|
||||
};
|
||||
lane.socket.onerror=()=>{};lane.socket.onclose=()=>{lane.ready=false;lane.socket=null;if(!closed)finishLane(lane,true)};
|
||||
}
|
||||
async function runLaneSocketUp(lane){
|
||||
if(lane.running||!lane.ready)return;lane.running=true;let lease=null;
|
||||
try{
|
||||
while(!closed&&lane.ready&&lanes.get(lane.id)===lane&&lane.pending.length){
|
||||
lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
|
||||
await waitSocket(lane.socket,lease.total,laneQueueLimit,lease.controller.signal);lane.socket.send(lease.body);
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lease=null;
|
||||
}
|
||||
}catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))finishLane(lane,true)}
|
||||
finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&lane.ready&&lane.pending.length)runLaneSocketUp(lane)}
|
||||
}
|
||||
async function runLaneUp(lane){
|
||||
if(lane.running)return;lane.running=true;let lease=null;
|
||||
try{
|
||||
while(!closed&&sessionToken&&lane.pending.length){
|
||||
lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
|
||||
const sequence=String(lane.sequence),laneID=String(lane.id);
|
||||
const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal));
|
||||
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected');
|
||||
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lane.sequence++;lease=null;
|
||||
if(!lane.polling)pollLane(lane);
|
||||
}
|
||||
}catch(error){if(!closed&&lanes.get(lane.id)===lane&&!(lease&&lease.cancelled))fail()}
|
||||
finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&sessionToken&&lane.pending.length)runLaneUp(lane)}
|
||||
}
|
||||
async function pollLane(lane){
|
||||
if(!lane||lane.polling)return;lane.polling=true;
|
||||
try{
|
||||
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
|
||||
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
|
||||
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
|
||||
if(response.status===204){
|
||||
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
|
||||
status('connected');continue;
|
||||
}
|
||||
if(response.status!==200)throw new Error('lane downlink rejected');
|
||||
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
|
||||
if(!next||!data.byteLength)throw new Error('invalid lane downlink response');
|
||||
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
|
||||
if(closed)return;
|
||||
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
|
||||
}
|
||||
}catch(error){if(!closed)fail()}
|
||||
finally{lane.polling=false;lane.controller=null}
|
||||
}
|
||||
function deleteSession(){
|
||||
const token=cleanupToken||sessionToken;if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,null,undefined,true)).catch(()=>{});
|
||||
}
|
||||
function close(notifyServer){
|
||||
if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(attemptController)attemptController.abort();if(pollController)pollController.abort();
|
||||
if(socket)socket.close();cancelBatch(upLease);releasePending(upPending,null);
|
||||
for(const lane of lanes.values()){
|
||||
if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
|
||||
}
|
||||
if(notifyServer)deleteSession();releasePending(pending,null);lanes.clear();if(port)port.close();
|
||||
if(queuedBytes!==0||queuedItems!==0)throw new Error('queue accounting leak');
|
||||
}
|
||||
function activatePort(nextPort){
|
||||
initialized=true;port=nextPort;
|
||||
port.onmessage=message=>{
|
||||
if(message.data instanceof ArrayBuffer){
|
||||
if(!createStarted){createStarted=true;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
|
||||
else if(!carrierCommitted){if(!reserve(message.data,null)){fail();return}pending.push(message.data);maybeStartCandidate()}
|
||||
else queueCarrier(message.data);
|
||||
}else if(message.data&&message.data.t==='close')close(true);
|
||||
};
|
||||
port.start();status('connecting');
|
||||
}
|
||||
addEventListener('message',event=>{
|
||||
if(initialized||event.source!==parent||event.data===null||typeof event.data!=='object')return;
|
||||
const keys=Object.keys(event.data).sort();
|
||||
if(keys.length!==2||keys[0]!=='t'||keys[1]!=='v'||event.data.t!=='tproxy-init'||event.data.v!==1||event.ports.length!==1)return;
|
||||
let source;try{source=new URL(event.origin)}catch(error){return}
|
||||
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
|
||||
activatePort(event.ports[0]);
|
||||
},{once:false});
|
||||
const androidBridge=globalThis.TelegramWebProxy;
|
||||
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
|
||||
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
|
||||
if(value instanceof ArrayBuffer){
|
||||
let frames;try{frames=splitFrames(value)}catch(error){fail();return}
|
||||
for(const frame of frames)androidBridge.postMessage(frame.data);
|
||||
}else androidBridge.postMessage(JSON.stringify(value));
|
||||
}};
|
||||
androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
|
||||
activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
|
||||
}
|
||||
addEventListener('pagehide',()=>close(true),{once:true});
|
||||
})();
|
||||
@@ -10,6 +10,10 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage {
|
||||
true,
|
||||
candidate_count,
|
||||
[3, 5, 8, 12],
|
||||
25,
|
||||
10,
|
||||
90,
|
||||
0,
|
||||
&SecureRandom::new(),
|
||||
)
|
||||
}
|
||||
@@ -43,6 +47,30 @@ fn rendered_page_preserves_the_ios_bootstrap_literal() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_page_embeds_the_configured_bridge_timing_policy() {
|
||||
let page = render(
|
||||
"proxy.example.com",
|
||||
"GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG",
|
||||
2 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
16 * 1024,
|
||||
true,
|
||||
4,
|
||||
[3, 5, 8, 12],
|
||||
17,
|
||||
7,
|
||||
41,
|
||||
4,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
|
||||
assert!(page.body.contains("const longPollMs=17*1000"));
|
||||
assert!(page.body.contains("bridgeRequestMs=7*1000"));
|
||||
assert!(page.body.contains("bridgeRetryMs=41*1000"));
|
||||
assert!(page.body.contains("const probeCoalesceMs=4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_deadline_formula_uses_the_final_checkpoint() {
|
||||
let page = render_page("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", 3);
|
||||
@@ -67,6 +95,10 @@ fn disabled_negotiation_does_not_arm_a_carrier_deadline() {
|
||||
false,
|
||||
1,
|
||||
[3, 5, 8, 12],
|
||||
25,
|
||||
10,
|
||||
90,
|
||||
0,
|
||||
&SecureRandom::new(),
|
||||
);
|
||||
assert!(page.body.contains(
|
||||
@@ -98,6 +130,10 @@ fn retry_and_attempt_state_are_frozen_before_fetch() {
|
||||
#[test]
|
||||
fn ambiguous_commit_is_resolved_before_carrier_advance() {
|
||||
let page = render_page("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 4);
|
||||
assert!(
|
||||
page.body
|
||||
.contains("if(snapshot.selected){advanceConfirmed(reason,epoch);return}")
|
||||
);
|
||||
assert!(page.body.contains("resolveAttempt(reason,epoch,snapshot)"));
|
||||
assert!(page.body.contains(
|
||||
"sessionEcho(response,snapshot.attempt,['provisional','committed','healthy'],true)"
|
||||
|
||||
+17
-4
@@ -245,15 +245,20 @@ async fn handle_root(
|
||||
trace.register_redaction(bootstrap.token.as_bytes());
|
||||
}
|
||||
let generation = runtime.active_generation();
|
||||
let config = generation.config();
|
||||
let page = bridge::render(
|
||||
&vhost.host,
|
||||
&bootstrap.token,
|
||||
generation.config().web.limits.carrier_batch_bytes,
|
||||
generation.config().web.limits.pending_bytes_per_session,
|
||||
generation.config().web.limits.pending_items_per_session,
|
||||
config.web.limits.carrier_batch_bytes,
|
||||
config.web.limits.pending_bytes_per_session,
|
||||
config.web.limits.pending_items_per_session,
|
||||
profile.carrier_negotiation_enabled,
|
||||
profile.carriers.len(),
|
||||
profile.carrier_negotiation_deadlines_secs,
|
||||
config.web.timeouts.long_poll_secs,
|
||||
config.web.timeouts.bridge_request_secs,
|
||||
config.web.timeouts.bridge_retry_secs,
|
||||
config.web.timeouts.carrier_probe_coalesce_ms,
|
||||
&generation.rng,
|
||||
);
|
||||
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
|
||||
@@ -348,7 +353,15 @@ async fn handle_up(
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, limit, false).await {
|
||||
} = match collect_body(
|
||||
request,
|
||||
&runtime,
|
||||
Duration::from_secs(session.timeouts().body_secs),
|
||||
limit,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
|
||||
@@ -114,6 +114,7 @@ pub(super) enum CollectBodyError {
|
||||
pub(super) async fn collect_body(
|
||||
request: Request<RequestBody>,
|
||||
runtime: &WebProcessRuntime,
|
||||
body_timeout: Duration,
|
||||
limit: usize,
|
||||
allow_empty: bool,
|
||||
) -> Result<CollectedBody, CollectBodyError> {
|
||||
@@ -133,8 +134,6 @@ pub(super) async fn collect_body(
|
||||
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
|
||||
return Err(CollectBodyError::Limit);
|
||||
};
|
||||
let body_timeout =
|
||||
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs);
|
||||
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
||||
Ok(Ok(body)) => body.to_bytes(),
|
||||
_ => {
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Request, StatusCode};
|
||||
@@ -46,7 +47,15 @@ pub(super) async fn handle_down(
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
} = match collect_body(
|
||||
request,
|
||||
&runtime,
|
||||
Duration::from_secs(session.timeouts().body_secs),
|
||||
1,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
|
||||
@@ -178,7 +178,7 @@ async fn metadata_free_native_client_uses_fallback_when_candidates_are_enabled()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_native_capabilities_participate_in_automatic_selection() {
|
||||
async fn explicit_native_capabilities_are_limited_to_https() {
|
||||
let capability = [56; 32];
|
||||
let generation = test_runtime_generation(
|
||||
1,
|
||||
@@ -202,12 +202,9 @@ async fn explicit_native_capabilities_participate_in_automatic_selection() {
|
||||
.await;
|
||||
let (headers, _) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(
|
||||
response_header(headers, "x-carrier-mode"),
|
||||
"websocket-lanes"
|
||||
);
|
||||
assert_eq!(response_header(headers, "x-carrier-mode"), "https");
|
||||
assert_eq!(response_header(headers, "x-carrier-attempt"), "1");
|
||||
assert_eq!(response_header(headers, "x-carrier-candidate-count"), "2");
|
||||
assert_eq!(response_header(headers, "x-carrier-candidate-count"), "1");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
|
||||
+12
-285
@@ -140,6 +140,11 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
||||
}
|
||||
(Some(capabilities), Some(attempt)) => {
|
||||
let capabilities = parse_capabilities(capabilities)?;
|
||||
let capabilities = if native_ios {
|
||||
capabilities.intersection(CarrierCapabilities::ios())?
|
||||
} else {
|
||||
capabilities
|
||||
};
|
||||
if (attempt == 1) != failure.is_none() {
|
||||
return None;
|
||||
}
|
||||
@@ -156,15 +161,14 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
||||
))
|
||||
}
|
||||
(None, Some(attempt)) if strict_browser_hint(request, host) => {
|
||||
if native_ios {
|
||||
return None;
|
||||
}
|
||||
if (attempt == 1) != failure.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(CarrierRequest::automatic(
|
||||
if native_ios {
|
||||
CarrierClientClass::Ios
|
||||
} else {
|
||||
CarrierClientClass::BrowserHint
|
||||
},
|
||||
CarrierClientClass::BrowserHint,
|
||||
CarrierCapabilities::all(),
|
||||
attempt,
|
||||
failure,
|
||||
@@ -259,284 +263,7 @@ fn single_header<B>(request: &Request<B>, name: impl header::AsHeaderName) -> Op
|
||||
values.next().is_none().then_some(value)
|
||||
}
|
||||
|
||||
// Canonical request parsing and client-classification tests.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::{WebCarrier, WebClientIpSource};
|
||||
|
||||
#[test]
|
||||
fn canonical_bridge_query_rejects_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
|
||||
let request = Request::builder()
|
||||
.header(header::HOST, "proxy.example.com:443")
|
||||
.header("x-forwarded-for", "192.0.2.10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
|
||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&request,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("192.0.2.10".parse().unwrap())
|
||||
);
|
||||
|
||||
let expanded_ipv6 = Request::builder()
|
||||
.header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&expanded_ipv6,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("2001:db8::10".parse().unwrap())
|
||||
);
|
||||
|
||||
let without_forwarded_address = Request::builder().body(()).unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&without_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let empty_forwarded_address = Request::builder()
|
||||
.header("x-forwarded-for", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&empty_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let uppercase = Request::builder()
|
||||
.header(header::HOST, "Proxy.Example.com")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_request_host(&uppercase).is_none());
|
||||
let appended = Request::builder()
|
||||
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
client_ip(
|
||||
&appended,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||
let request = Request::builder()
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("x-up-seq", "17")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bearer_token_hash(&request),
|
||||
Some(Sha256::digest([1u8; 32]).into())
|
||||
);
|
||||
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||
|
||||
let leading_zero = Request::builder()
|
||||
.header("x-up-seq", "017")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_header_accepts_only_absent_or_one_empty_value() {
|
||||
let absent = Request::new(());
|
||||
assert!(compatible_cookie_header(&absent));
|
||||
|
||||
let empty = Request::builder()
|
||||
.header(header::COOKIE, "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(compatible_cookie_header(&empty));
|
||||
|
||||
let nonempty = Request::builder()
|
||||
.header(header::COOKIE, "state=unexpected")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!compatible_cookie_header(&nonempty));
|
||||
|
||||
let whitespace = Request::builder()
|
||||
.header(header::COOKIE, " ")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!compatible_cookie_header(&whitespace));
|
||||
|
||||
let mut duplicate_empty = Request::new(());
|
||||
duplicate_empty
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
duplicate_empty
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
assert!(!compatible_cookie_header(&duplicate_empty));
|
||||
|
||||
let mut duplicate_mixed = Request::new(());
|
||||
duplicate_mixed
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
duplicate_mixed
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "state=unexpected".parse().unwrap());
|
||||
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 native_ios_user_agent_classifies_without_overriding_capabilities() {
|
||||
let metadata_free = Request::builder()
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(!parsed.is_automatic());
|
||||
assert!(!parsed.uses_capabilities());
|
||||
|
||||
let automatic = Request::builder()
|
||||
.header("x-carrier-capabilities", "https,https-lanes")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(parsed.is_automatic());
|
||||
assert!(parsed.supports(WebCarrier::Https));
|
||||
assert!(parsed.supports(WebCarrier::HttpsLanes));
|
||||
assert!(!parsed.supports(WebCarrier::Websocket));
|
||||
assert!(!parsed.supports(WebCarrier::WebsocketLanes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_private_addresses_are_not_learning_evidence() {
|
||||
for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
|
||||
let effective_ip = address.parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", address)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", "::ffff:8.8.8.8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "request/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
use super::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::{WebCarrier, WebClientIpSource};
|
||||
|
||||
#[test]
|
||||
fn canonical_bridge_query_rejects_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
|
||||
let request = Request::builder()
|
||||
.header(header::HOST, "proxy.example.com:443")
|
||||
.header("x-forwarded-for", "192.0.2.10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
|
||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&request,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("192.0.2.10".parse().unwrap())
|
||||
);
|
||||
|
||||
let expanded_ipv6 = Request::builder()
|
||||
.header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&expanded_ipv6,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("2001:db8::10".parse().unwrap())
|
||||
);
|
||||
|
||||
let without_forwarded_address = Request::builder().body(()).unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&without_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let empty_forwarded_address = Request::builder()
|
||||
.header("x-forwarded-for", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&empty_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let uppercase = Request::builder()
|
||||
.header(header::HOST, "Proxy.Example.com")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_request_host(&uppercase).is_none());
|
||||
let appended = Request::builder()
|
||||
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
client_ip(
|
||||
&appended,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||
let request = Request::builder()
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("x-up-seq", "17")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bearer_token_hash(&request),
|
||||
Some(Sha256::digest([1u8; 32]).into())
|
||||
);
|
||||
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||
|
||||
let leading_zero = Request::builder()
|
||||
.header("x-up-seq", "017")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_header_accepts_only_absent_or_one_empty_value() {
|
||||
let absent = Request::new(());
|
||||
assert!(compatible_cookie_header(&absent));
|
||||
|
||||
let empty = Request::builder()
|
||||
.header(header::COOKIE, "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(compatible_cookie_header(&empty));
|
||||
|
||||
let nonempty = Request::builder()
|
||||
.header(header::COOKIE, "state=unexpected")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!compatible_cookie_header(&nonempty));
|
||||
|
||||
let whitespace = Request::builder()
|
||||
.header(header::COOKIE, " ")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!compatible_cookie_header(&whitespace));
|
||||
|
||||
let mut duplicate_empty = Request::new(());
|
||||
duplicate_empty
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
duplicate_empty
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
assert!(!compatible_cookie_header(&duplicate_empty));
|
||||
|
||||
let mut duplicate_mixed = Request::new(());
|
||||
duplicate_mixed
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "".parse().unwrap());
|
||||
duplicate_mixed
|
||||
.headers_mut()
|
||||
.append(header::COOKIE, "state=unexpected".parse().unwrap());
|
||||
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 native_ios_user_agent_enforces_the_https_capability_ceiling() {
|
||||
let metadata_free = Request::builder()
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(!parsed.is_automatic());
|
||||
assert!(!parsed.uses_capabilities());
|
||||
|
||||
let automatic = Request::builder()
|
||||
.header("x-carrier-capabilities", "https,https-lanes")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(parsed.is_automatic());
|
||||
assert!(parsed.supports(WebCarrier::Https));
|
||||
assert!(!parsed.supports(WebCarrier::HttpsLanes));
|
||||
assert!(!parsed.supports(WebCarrier::Websocket));
|
||||
assert!(!parsed.supports(WebCarrier::WebsocketLanes));
|
||||
|
||||
let incompatible = Request::builder()
|
||||
.header("x-carrier-capabilities", "websocket")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&incompatible, "proxy.example.com").is_none());
|
||||
|
||||
let stripped = 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")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&stripped, "proxy.example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_private_addresses_are_not_learning_evidence() {
|
||||
for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
|
||||
let effective_ip = address.parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", address)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", "::ffff:8.8.8.8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
+13
-4
@@ -1,5 +1,6 @@
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Method, Request, StatusCode};
|
||||
@@ -33,17 +34,22 @@ pub(super) async fn handle_session(
|
||||
if request.headers().contains_key(header::CONTENT_TYPE) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let session = runtime.get_session(token_hash, &vhost.host).ok();
|
||||
if let Some(trace) = request_trace(&request)
|
||||
&& let Ok(session) = runtime.get_session(token_hash, &vhost.host)
|
||||
&& let Some(session) = &session
|
||||
{
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_identity(session.trace_identity());
|
||||
}
|
||||
let body_timeout = session.as_ref().map_or_else(
|
||||
|| Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs),
|
||||
|session| Duration::from_secs(session.timeouts().body_secs),
|
||||
);
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
} = match collect_body(request, &runtime, body_timeout, 1, true).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
@@ -62,7 +68,7 @@ pub(super) async fn handle_session(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip);
|
||||
let Some((trace_session_id, profile)) =
|
||||
let Some((trace_session_id, profile, frozen_body_timeout)) =
|
||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
@@ -71,11 +77,14 @@ pub(super) async fn handle_session(
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_profile(&profile, trace_session_id);
|
||||
}
|
||||
let body_timeout = frozen_body_timeout.unwrap_or_else(|| {
|
||||
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs)
|
||||
});
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await {
|
||||
} = match collect_body(request, &runtime, body_timeout, CREATE_BODY_LIMIT, false).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
async fn request_with_body_delay(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
head: Vec<u8>,
|
||||
body: &[u8],
|
||||
delay: std::time::Duration,
|
||||
) -> Vec<u8> {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||
let (server, peer) = accepted.unwrap();
|
||||
let mut client = client.unwrap();
|
||||
let permit = runtime.try_http_connection().unwrap();
|
||||
let task = tokio::spawn(serve_connection(
|
||||
server,
|
||||
peer,
|
||||
WebClientIpSource::XForwardedFor,
|
||||
Arc::from(["127.0.0.1/32".parse().unwrap()]),
|
||||
Arc::clone(runtime),
|
||||
CancellationToken::new(),
|
||||
permit,
|
||||
));
|
||||
client.write_all(&head).await.unwrap();
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = client.write_all(body).await;
|
||||
let mut response = Vec::new();
|
||||
client.read_to_end(&mut response).await.unwrap();
|
||||
task.await.unwrap();
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_session_body_and_closed_token_timeouts_survive_reload() {
|
||||
let capability = [21u8; 32];
|
||||
let mut initial_config = runtime_config(capability, WebCarrier::Https);
|
||||
initial_config.web.timeouts.body_secs = 3;
|
||||
initial_config.web.timeouts.bootstrap_lifetime_secs = 5;
|
||||
let generation = test_runtime_generation(1, initial_config);
|
||||
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
let root = format!(
|
||||
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let root_response = request(&listener, &runtime, root).await;
|
||||
let (_, root_body) = split_response(&root_response);
|
||||
let bootstrap = std::str::from_utf8(root_body)
|
||||
.unwrap()
|
||||
.split_once("bootstrap=\"")
|
||||
.and_then(|(_, suffix)| suffix.split_once('"'))
|
||||
.map(|(token, _)| token.to_string())
|
||||
.unwrap();
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let create_head = format!(
|
||||
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
let mut create = create_head.clone();
|
||||
create.extend_from_slice(&hello);
|
||||
let create_response = request(&listener, &runtime, create).await;
|
||||
let (create_headers, _) = split_response(&create_response);
|
||||
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
|
||||
let session = response_header(create_headers, "x-session-token").to_string();
|
||||
|
||||
let mut replacement_config = runtime_config(capability, WebCarrier::Https);
|
||||
replacement_config.web.timeouts.body_secs = 1;
|
||||
replacement_config.web.timeouts.bootstrap_lifetime_secs = 1;
|
||||
let replacement = test_runtime_generation(2, replacement_config);
|
||||
active_runtime.store(Arc::clone(&replacement));
|
||||
|
||||
let retry_response = request_with_body_delay(
|
||||
&listener,
|
||||
&runtime,
|
||||
create_head,
|
||||
&hello,
|
||||
std::time::Duration::from_millis(1200),
|
||||
)
|
||||
.await;
|
||||
let (retry_headers, _) = split_response(&retry_response);
|
||||
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(retry_headers, "x-session-token"), session);
|
||||
|
||||
let close = format!(
|
||||
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let close_response = request(&listener, &runtime, close.clone()).await;
|
||||
assert!(close_response.starts_with(b"HTTP/1.1 204"));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
let close_retry_response = request(&listener, &runtime, close).await;
|
||||
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
replacement.stop_sessions().await;
|
||||
replacement.stop_background_tasks().await;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ use crate::web::manager::WebProcessRuntime;
|
||||
mod legacy_tests;
|
||||
#[path = "negotiation_tests.rs"]
|
||||
mod negotiation_tests;
|
||||
// Reload-stability tests for session-owned timeout policy.
|
||||
#[path = "session_policy_tests.rs"]
|
||||
mod session_policy_tests;
|
||||
|
||||
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
||||
|
||||
|
||||
@@ -110,19 +110,28 @@ impl WebProcessRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves non-secret bootstrap trace identity without exposing its credential.
|
||||
/// Resolves bootstrap trace identity and the frozen live-session body timeout.
|
||||
pub(crate) fn bootstrap_trace_identity(
|
||||
&self,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> Option<(u64, Arc<WebRuntimeProfile>)> {
|
||||
) -> Option<(u64, Arc<WebRuntimeProfile>, Option<Duration>)> {
|
||||
let now = Instant::now();
|
||||
self.state
|
||||
.lock()
|
||||
.bootstraps
|
||||
.get(&hash)
|
||||
.filter(|entry| entry.profile.host == host && now <= entry.expires_at)
|
||||
.map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile)))
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.trace_session_id,
|
||||
Arc::clone(&entry.profile),
|
||||
entry
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|session| Duration::from_secs(session.timeouts().body_secs)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves an authenticated session token.
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ impl WebProcessRuntime {
|
||||
client_ip: IpAddr,
|
||||
profile_key: ProfileKey,
|
||||
profile_host: &str,
|
||||
closed_token_lifetime: Duration,
|
||||
) {
|
||||
let mut state = self.state.lock();
|
||||
if state.sessions.remove(&hash).is_none() {
|
||||
@@ -28,14 +29,7 @@ impl WebProcessRuntime {
|
||||
&mut state,
|
||||
hash,
|
||||
profile_host,
|
||||
Duration::from_secs(
|
||||
self.active_runtime
|
||||
.load()
|
||||
.config()
|
||||
.web
|
||||
.timeouts
|
||||
.bootstrap_lifetime_secs,
|
||||
),
|
||||
closed_token_lifetime,
|
||||
self.limits.max_sessions_global.saturating_mul(16),
|
||||
);
|
||||
let bootstrap_hashes = state
|
||||
|
||||
@@ -77,6 +77,11 @@ impl CarrierCapabilities {
|
||||
Self(0b1111)
|
||||
}
|
||||
|
||||
/// Returns the current server-authoritative native iOS capability ceiling.
|
||||
pub(crate) const fn ios() -> Self {
|
||||
Self(1 << WebCarrier::Https.index())
|
||||
}
|
||||
|
||||
/// Builds a set from a validated bit representation.
|
||||
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
|
||||
if bits != 0 && bits & !0b1111 == 0 {
|
||||
@@ -90,6 +95,11 @@ impl CarrierCapabilities {
|
||||
pub(crate) const fn contains(self, carrier: WebCarrier) -> bool {
|
||||
self.0 & (1 << carrier.index()) != 0
|
||||
}
|
||||
|
||||
/// Intersects declared capabilities with an authoritative server ceiling.
|
||||
pub(crate) const fn intersection(self, ceiling: Self) -> Option<Self> {
|
||||
Self::from_bits(self.0 & ceiling.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable metadata attached to one session-creation attempt.
|
||||
|
||||
@@ -376,178 +376,7 @@ impl WebProcessRuntime {
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn replace_session(
|
||||
self: &Arc<Self>,
|
||||
bootstrap_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
replacement: Replacement,
|
||||
) -> std::result::Result<CreateResult, ManagerError> {
|
||||
if !replacement.old_session.begin_carrier_supersede() {
|
||||
let committed = replacement.old_session.is_carrier_committed();
|
||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||
return Err(if committed {
|
||||
ManagerError::Committed
|
||||
} else {
|
||||
ManagerError::Closed
|
||||
});
|
||||
}
|
||||
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_phase == CarrierChainPhase::Provisional
|
||||
&& !entry.close_requested
|
||||
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
|
||||
&& now < replacement.carrier_deadline_at
|
||||
&& 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
|
||||
&& replacement.learning_epoch != 0)
|
||||
.then_some(CarrierLearningContext {
|
||||
profile_key: replacement.profile_key,
|
||||
client_ip,
|
||||
class: replacement.request.class(),
|
||||
user_agent_hash: replacement.request.user_agent_hash(),
|
||||
epoch: replacement.learning_epoch,
|
||||
ip_learning_eligible: replacement.ip_learning_eligible,
|
||||
});
|
||||
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,
|
||||
Some(replacement.carrier_deadline_at),
|
||||
replacement.request.class(),
|
||||
learning_context,
|
||||
true,
|
||||
self.limits.clone(),
|
||||
replacement.old_session.timeouts().clone(),
|
||||
);
|
||||
let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else {
|
||||
drop(state);
|
||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||
session.close();
|
||||
return Err(ManagerError::Closed);
|
||||
};
|
||||
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_phase = CarrierChainPhase::Provisional;
|
||||
if let Some(slot) = entry
|
||||
.carrier_failures
|
||||
.get_mut(usize::from(replacement.attempt.saturating_sub(2)))
|
||||
{
|
||||
*slot = Some(replacement.old_session.carrier());
|
||||
}
|
||||
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),
|
||||
candidate_count: Some(u8::try_from(entry.carrier_candidates.len()).unwrap_or(4)),
|
||||
deadline_secs: Some(entry.profile.carrier_negotiation_deadlines_secs[3]),
|
||||
carrier_state: Some(CarrierChainPhase::Provisional.as_str()),
|
||||
};
|
||||
let identity = session.trace_identity();
|
||||
let old_identity = replacement.old_session.trace_identity();
|
||||
drop(state);
|
||||
supersede.finish();
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic pre-commit carrier replacement and frozen-policy transfer.
|
||||
mod replacement;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::*;
|
||||
|
||||
impl WebProcessRuntime {
|
||||
pub(super) fn replace_session(
|
||||
self: &Arc<Self>,
|
||||
bootstrap_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
replacement: Replacement,
|
||||
) -> std::result::Result<CreateResult, ManagerError> {
|
||||
if !replacement.old_session.begin_carrier_supersede() {
|
||||
let committed = replacement.old_session.is_carrier_committed();
|
||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||
return Err(if committed {
|
||||
ManagerError::Committed
|
||||
} else {
|
||||
ManagerError::Closed
|
||||
});
|
||||
}
|
||||
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_phase == CarrierChainPhase::Provisional
|
||||
&& !entry.close_requested
|
||||
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
|
||||
&& now < replacement.carrier_deadline_at
|
||||
&& 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
|
||||
&& replacement.learning_epoch != 0)
|
||||
.then_some(CarrierLearningContext {
|
||||
profile_key: replacement.profile_key,
|
||||
client_ip,
|
||||
class: replacement.request.class(),
|
||||
user_agent_hash: replacement.request.user_agent_hash(),
|
||||
epoch: replacement.learning_epoch,
|
||||
ip_learning_eligible: replacement.ip_learning_eligible,
|
||||
});
|
||||
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,
|
||||
Some(replacement.carrier_deadline_at),
|
||||
replacement.request.class(),
|
||||
learning_context,
|
||||
true,
|
||||
self.limits.clone(),
|
||||
replacement.old_session.timeouts().clone(),
|
||||
);
|
||||
let Some(supersede) = replacement.old_session.prepare_carrier_supersede() else {
|
||||
drop(state);
|
||||
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
|
||||
session.close();
|
||||
return Err(ManagerError::Closed);
|
||||
};
|
||||
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(replacement.old_session.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_phase = CarrierChainPhase::Provisional;
|
||||
if let Some(slot) = entry
|
||||
.carrier_failures
|
||||
.get_mut(usize::from(replacement.attempt.saturating_sub(2)))
|
||||
{
|
||||
*slot = Some(replacement.old_session.carrier());
|
||||
}
|
||||
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),
|
||||
candidate_count: Some(u8::try_from(entry.carrier_candidates.len()).unwrap_or(4)),
|
||||
deadline_secs: Some(entry.profile.carrier_negotiation_deadlines_secs[3]),
|
||||
carrier_state: Some(CarrierChainPhase::Provisional.as_str()),
|
||||
};
|
||||
let identity = session.trace_identity();
|
||||
let old_identity = replacement.old_session.trace_identity();
|
||||
drop(state);
|
||||
supersede.finish();
|
||||
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)
|
||||
}
|
||||
}
|
||||
+12
-14
@@ -42,26 +42,13 @@ impl WebSession {
|
||||
drop(completion);
|
||||
return false;
|
||||
}
|
||||
let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else {
|
||||
manager.record_stream_rejected();
|
||||
self.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream.id),
|
||||
Some("connection_limit"),
|
||||
);
|
||||
completion
|
||||
.retain_rejected
|
||||
.store(retain_reservation_on_reject, Ordering::Release);
|
||||
drop(completion);
|
||||
return false;
|
||||
};
|
||||
let connection_permits = Arc::clone(&generation.max_connections);
|
||||
let deps = generation.client_runtime_deps();
|
||||
let replay_checker = Arc::clone(&generation.replay_checker);
|
||||
let session = Arc::clone(self);
|
||||
let cancel = self.cancel.clone();
|
||||
let retain_rejected = Arc::clone(&completion.retain_rejected);
|
||||
let future = async move {
|
||||
let _connection_permit = connection_permit;
|
||||
let _completion = completion;
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamAdmitted,
|
||||
@@ -77,6 +64,7 @@ impl WebSession {
|
||||
logical_stream,
|
||||
deps,
|
||||
replay_checker,
|
||||
connection_permits,
|
||||
peer_port,
|
||||
) => {}
|
||||
}
|
||||
@@ -219,6 +207,7 @@ async fn run_stream(
|
||||
stream: WebLogicalStream,
|
||||
deps: crate::proxy::authenticated::ClientRuntimeDeps,
|
||||
replay_checker: Arc<crate::stats::ReplayChecker>,
|
||||
connection_permits: Arc<tokio::sync::Semaphore>,
|
||||
peer_port: u16,
|
||||
) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -267,6 +256,15 @@ async fn run_stream(
|
||||
let Some(manager) = session.manager.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Ok(_connection_permit) = connection_permits.try_acquire_owned() else {
|
||||
manager.record_stream_rejected();
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream_identity.id),
|
||||
Some("connection_limit_after_first_byte"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(handshake_permit) = manager.try_stream_handshake() else {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
|
||||
@@ -206,6 +206,58 @@ async fn open_without_data_does_not_start_the_inner_handshake_timeout() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn silent_open_does_not_consume_generation_connection_capacity() {
|
||||
let runtime = test_runtime(WebCarrier::Https, 1);
|
||||
let available = runtime.generation.max_connections.available_permits();
|
||||
|
||||
assert_eq!(runtime.process_frame(1, 1, FrameType::Open, &[]), Ok(1));
|
||||
settle_tasks().await;
|
||||
|
||||
assert_eq!(
|
||||
runtime.generation.max_connections.available_permits(),
|
||||
available
|
||||
);
|
||||
|
||||
assert_eq!(runtime.process_frame(1, 2, FrameType::Data, &[0x5a]), Ok(2));
|
||||
settle_tasks().await;
|
||||
assert_eq!(
|
||||
runtime.generation.max_connections.available_permits(),
|
||||
available - 1
|
||||
);
|
||||
|
||||
runtime.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn first_inner_byte_fails_closed_when_connection_capacity_is_exhausted() {
|
||||
let runtime = test_runtime(WebCarrier::Https, 1);
|
||||
let capacity = runtime.generation.max_connections.available_permits();
|
||||
let permit = runtime
|
||||
.generation
|
||||
.max_connections
|
||||
.clone()
|
||||
.try_acquire_many_owned(capacity as u32)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(runtime.process_frame(1, 1, FrameType::Open, &[]), Ok(1));
|
||||
settle_tasks().await;
|
||||
assert!(runtime.session.state.lock().streams.contains_key(&1));
|
||||
|
||||
assert_eq!(runtime.process_frame(1, 2, FrameType::Data, &[0x5a]), Ok(2));
|
||||
settle_tasks().await;
|
||||
|
||||
assert!(!runtime.session.state.lock().streams.contains_key(&1));
|
||||
assert_eq!(runtime.generation.max_connections.available_permits(), 0);
|
||||
|
||||
drop(permit);
|
||||
assert_eq!(
|
||||
runtime.generation.max_connections.available_permits(),
|
||||
capacity
|
||||
);
|
||||
runtime.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_first_inner_byte_starts_the_handshake_timeout() {
|
||||
let runtime = test_runtime(WebCarrier::Https, 1);
|
||||
|
||||
@@ -206,6 +206,7 @@ impl WebSession {
|
||||
self.client_ip,
|
||||
self.profile_key,
|
||||
&self.profile.host,
|
||||
Duration::from_secs(self.timeouts.bootstrap_lifetime_secs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user