Bounded Debugging + Websocket Carriers + Carriers Negotiation

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-26 17:00:20 +03:00
parent 43cd84aaa5
commit 923c79796a
52 changed files with 3450 additions and 980 deletions
+143 -136
View File
@@ -1,6 +1,5 @@
use base64::Engine as _;
use crate::config::WebCarrier;
use crate::crypto::SecureRandom;
/// Browser security policy for the transient Telegram Desktop bridge page.
@@ -14,14 +13,17 @@ pub(crate) struct BridgePage {
pub(crate) content_security_policy: String,
}
/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce.
/// Renders the bounded WEB carrier-negotiation bridge with a fresh CSP nonce.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
host: &str,
bootstrap: &str,
batch_limit: usize,
queue_limit: usize,
queue_items: usize,
carrier: WebCarrier,
negotiation_enabled: bool,
candidate_count: usize,
carrier_deadlines: [u64; 4],
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
@@ -34,7 +36,19 @@ pub(crate) fn render(
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
.replace("__QUEUE_ITEMS__", &queue_items.to_string())
.replace("__CARRIER__", carrier.as_str());
.replace(
"__NEGOTIATION_ENABLED__",
if negotiation_enabled { "true" } else { "false" },
)
.replace("__CANDIDATE_COUNT__", &candidate_count.to_string())
.replace(
"__CARRIER_DEADLINES__",
&carrier_deadlines
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(","),
);
BridgePage {
body,
content_security_policy: format!(
@@ -54,13 +68,18 @@ const DOCUMENT: &str = r##"<!doctype html>
<script nonce="__NONCE__">
(()=>{
'use strict';
const relayOrigin='https://__HOST__',bootstrap="__BOOTSTRAP__",carrier='__CARRIER__';
const bootstrap="__BOOTSTRAP__";
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
const effectiveDeadlines=candidateDeadlines.slice(0,candidateCount-1).concat(candidateDeadlines[3]);
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
const 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='',createStarted=false,socket=null,socketReady=false;
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false,socket=null,socketReady=false,carrier='';
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
let helloFrame=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false;
let negotiationStartedAt=0,carrierTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false;
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
@@ -69,6 +88,7 @@ const options=(method,token,body,headers,signal,keepalive)=>({
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
});
const attemptHeaders=()=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(carrierAttempt)},carrierFailure?{'X-Carrier-Failure':carrierFailure}:{}):{};
function reserve(data,lane){
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;
@@ -99,6 +119,27 @@ function splitFrames(value){
}
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){
@@ -145,21 +186,77 @@ async function request(path,makeOptions){
}
}
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
async function createSession(first){
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
function armCarrierDeadline(epoch){
if(!negotiationStartedAt||epoch!==attemptEpoch)return;
if(carrierTimer)clearTimeout(carrierTimer);
const remaining=negotiationStartedAt+effectiveDeadlines[carrierAttempt-1]*1000-Date.now();
carrierTimer=setTimeout(()=>advanceCarrier('timeout',epoch),Math.max(0,remaining));
}
function resetCandidate(){
if(pollController)pollController.abort();pollController=null;
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;
for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;upPending.length=0;upSequence=1;downCursor='0';upRunning=false;
sessionToken='';carrier='';candidateRunning=false;
}
function advanceCarrier(reason,epoch){
if(closed||carrierCommitted||epoch!==attemptEpoch||switching)return;
switching=true;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
if(attemptController)attemptController.abort();attemptController=null;
resetCandidate();
if(carrierAttempt>=candidateCount||Date.now()>=negotiationStartedAt+effectiveDeadlines[effectiveDeadlines.length-1]*1000){switching=false;fail();return}
carrierAttempt++;carrierFailure=reason;attemptEpoch++;const nextEpoch=attemptEpoch;switching=false;
status('reconnecting');armCarrierDeadline(nextEpoch);createSession(nextEpoch);
}
function maybeStartCandidate(){
let probe;try{probe=findProbe()}catch(error){fail();return}
if(!probe||closed||carrierCommitted)return;
if(negotiationEnabled&&!negotiationStartedAt){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}
if(!sessionToken||candidateRunning)return;
candidateRunning=true;const epoch=attemptEpoch;
if(carrier==='https')probeHttp(probe,null,epoch);
else if(carrier==='https-lanes')probeHttp(probe,probe.id,epoch);
else if(carrier==='websocket')openCandidateSocket(probe,null,epoch);
else if(carrier==='websocket-lanes')openCandidateSocket(probe,probe.id,epoch);
else advanceCarrier('protocol',epoch);
}
async function createSession(epoch){
attemptController=new AbortController();
try{
status('connecting');
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session creation rejected');
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0';
if(!sessionToken)throw new Error('missing session token');
if(closed){deleteSession();return}
const welcome=await response.arrayBuffer();
port.postMessage(welcome,[welcome]);status('connected');
if(carrier==='https-lanes')ensureLane(0);
if(carrier==='websocket')openSocket();
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
if(carrier==='https')poll();else if(carrier==='https-lanes')pollLane(lanes.get(0));
}catch(error){fail()}
const response=await request('/api/v1/session',()=>options('POST',bootstrap,helloFrame,attemptHeaders(),attemptController.signal));
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
if(response.status!==200){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('http',epoch);else fail();return}
const selected=response.headers.get('X-Carrier-Mode')||'',echo=response.headers.get('X-Carrier-Attempt')||'';
if(!knownCarrier(selected)||(negotiationEnabled?echo!==String(carrierAttempt):echo!=='')){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
const token=response.headers.get('X-Session-Token')||'',cursor=response.headers.get('X-Down-Cursor')||'';
if(!token||cursor!=='0'){await response.arrayBuffer();if(negotiationStartedAt)advanceCarrier('protocol',epoch);else fail();return}
const welcome=await response.arrayBuffer();if(closed||epoch!==attemptEpoch)return;
carrier=selected;sessionToken=token;downCursor=cursor;
if(!welcomeSent){welcomeSent=true;port.postMessage(welcome,[welcome])}
maybeStartCandidate();
}catch(error){if(closed||epoch!==attemptEpoch)return;if(negotiationStartedAt)advanceCarrier('network',epoch);else fail()}
}
async function probeHttp(probe,laneID,epoch){
try{
const headers={'X-Up-Seq':'1'};if(laneID!==null)headers['X-Lane-ID']=String(laneID);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,probe.data,headers,attemptController.signal));
if(closed||epoch!==attemptEpoch){await response.arrayBuffer();return}
if(response.status!==204){await response.arrayBuffer();advanceCarrier('http',epoch);return}
if(response.headers.get('X-Up-Ack')!=='1'){advanceCarrier('protocol',epoch);return}
if(laneID===null)upSequence=2;else ensureLane(laneID).sequence=2;
commitCarrier(probe,epoch);
}catch(error){if(!closed&&epoch===attemptEpoch)advanceCarrier('network',epoch)}
}
function commitCarrier(probe,epoch){
if(closed||carrierCommitted||epoch!==attemptEpoch)return;
try{consumeProbe(probe)}catch(error){fail();return}
carrierCommitted=true;candidateRunning=false;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;
status('connected');
if(carrier==='https')poll();
else if(carrier==='https-lanes'){const control=ensureLane(0);pollLane(control);const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
}
function queueCarrier(data){
try{
@@ -181,15 +278,30 @@ async function runUp(){
}catch(error){fail()}
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
}
function openSocket(){
if(socket||closed)return;socket=new WebSocket(socketURL(),'tproxy-v1.'+sessionToken);socket.binaryType='arraybuffer';
socket.onopen=()=>{if(closed)return;socketReady=true;status('connected');runSocketUp()};
socket.onmessage=event=>{
if(closed||!(event.data instanceof ArrayBuffer)){fail();return}
try{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}catch(error){fail();return}
function openCandidateSocket(probe,laneID,epoch){
const protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+sessionToken:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+sessionToken+'.'+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');
};
socket.onerror=()=>{};socket.onclose=()=>{socketReady=false;if(!closed)fail()};
next.onerror=()=>{};
next.onclose=()=>{
if(epoch!==attemptEpoch||closed)return;
if(!carrierCommitted){advanceCarrier(opened?'network':'upgrade',epoch);return}
if(lane){lane.ready=false;lane.socket=null;finishLane(lane,true)}else{socketReady=false;fail()}
};
}
function queueSocket(data){if(!reserve(data,null)){fail();return}upPending.push(data);runSocketUp()}
async function waitSocket(next,size,limit){
@@ -306,7 +418,7 @@ function deleteSession(){
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
}
function close(notifyServer){
if(closed)return;closed=true;if(pollController)pollController.abort();
if(closed)return;closed=true;if(carrierTimer)clearTimeout(carrierTimer);if(attemptController)attemptController.abort();if(pollController)pollController.abort();
if(socket)socket.close();for(const lane of lanes.values()){if(lane.controller)lane.controller.abort();if(lane.socket)lane.socket.close()}
if(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();
@@ -315,8 +427,8 @@ function activatePort(nextPort){
initialized=true;port=nextPort;
port.onmessage=message=>{
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;createSession(message.data)}
else if(!sessionToken){if(!reserve(message.data,null)){fail();return}pending.push(message.data)}
if(!createStarted){createStarted=true;helloFrame=message.data;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);
};
@@ -348,111 +460,6 @@ addEventListener('pagehide',()=>close(true),{once:true});
</html>
"##;
// Rendered wire-contract tests remain separate from the embedded document.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendered_page_contains_no_template_markers_or_capability() {
let page = render(
"proxy.example.com",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::HttpsLanes,
&SecureRandom::new(),
);
assert!(!page.body.contains("__"));
assert!(!page.body.contains("bridge="));
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("carrier='https-lanes'"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page.body.contains("const when=Date.parse(header)"));
assert!(page.body.contains("},{once:false});"));
assert!(
page.body
.contains("if(!sessionToken)throw new Error('missing session token')")
);
assert!(!page.body.contains("welcomeBytes"));
assert!(
page.body
.contains("for(const value of splitFrames(data))if(value.id!==lane.id)")
);
assert!(
page.body
.contains("let frames;try{frames=splitFrames(value)}catch(error){fail();return}")
);
assert!(
page.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*")
);
}
#[test]
fn rendered_page_is_parseable_by_ios_native_carrier() {
let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let page = render(
"proxy.example.com",
bootstrap,
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::Https,
&SecureRandom::new(),
);
let accepted_shapes = [
format!("const bootstrap=\"{bootstrap}\""),
format!("const bootstrap='{bootstrap}'"),
format!("bootstrap=\"{bootstrap}\""),
];
assert!(
accepted_shapes
.iter()
.any(|shape| page.body.contains(shape)),
"the iOS native carrier cannot parse a comma-declared single-quoted bootstrap"
);
}
#[test]
fn rendered_page_advertises_exact_websocket_carriers() {
let websocket = render(
"proxy.example.com",
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::Websocket,
&SecureRandom::new(),
);
assert!(websocket.body.contains("carrier='websocket'"));
assert!(
websocket
.body
.contains("new WebSocket(socketURL(),'tproxy-v1.'+sessionToken)")
);
assert!(
websocket
.content_security_policy
.contains("connect-src 'self' wss://proxy.example.com")
);
let lanes = render(
"proxy.example.com",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::WebsocketLanes,
&SecureRandom::new(),
);
assert!(lanes.body.contains("carrier='websocket-lanes'"));
assert!(
lanes
.body
.contains("'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id)")
);
assert!(!lanes.body.contains("__"));
}
}
mod tests;