diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 6229bb9..1f1a90e 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -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", diff --git a/src/config/load/validate_web/timeouts.rs b/src/config/load/validate_web/timeouts.rs index dd4b989..4c9877c 100644 --- a/src/config/load/validate_web/timeouts.rs +++ b/src/config/load/validate_web/timeouts.rs @@ -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]"); } diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index c12919a..a223666 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -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] diff --git a/src/config/types/web.rs b/src/config/types/web.rs index 863b533..42ef24f 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -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(), diff --git a/src/config/types/web/defaults.rs b/src/config/types/web/defaults.rs index f85b44d..42db74f 100644 --- a/src/config/types/web/defaults.rs +++ b/src/config/types/web/defaults.rs @@ -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); diff --git a/src/metrics.rs b/src/metrics.rs index 9bd36df..fb35b84 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -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")); diff --git a/src/web/bridge.rs b/src/web/bridge.rs index c262351..7f3759a 100644 --- a/src/web/bridge.rs +++ b/src/web/bridge.rs @@ -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##" - - - - -Connection - - - - - -"##; +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)] diff --git a/src/web/bridge/document.html b/src/web/bridge/document.html new file mode 100644 index 0000000..a7c25b5 --- /dev/null +++ b/src/web/bridge/document.html @@ -0,0 +1,13 @@ + + + + + +Connection + + + + + diff --git a/src/web/bridge/runtime.js b/src/web/bridge/runtime.js new file mode 100644 index 0000000..f684017 --- /dev/null +++ b/src/web/bridge/runtime.js @@ -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(offset1048576||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=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=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;indexright-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.startbatchLimit||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{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}); +})(); diff --git a/src/web/bridge/tests.rs b/src/web/bridge/tests.rs index 58fa517..ddab40f 100644 --- a/src/web/bridge/tests.rs +++ b/src/web/bridge/tests.rs @@ -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)" diff --git a/src/web/http.rs b/src/web/http.rs index c7f1946..9066df5 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -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)) => { diff --git a/src/web/http/body.rs b/src/web/http/body.rs index 7550cda..38b4b64 100644 --- a/src/web/http/body.rs +++ b/src/web/http/body.rs @@ -114,6 +114,7 @@ pub(super) enum CollectBodyError { pub(super) async fn collect_body( request: Request, runtime: &WebProcessRuntime, + body_timeout: Duration, limit: usize, allow_empty: bool, ) -> Result { @@ -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(), _ => { diff --git a/src/web/http/down.rs b/src/web/http/down.rs index 4796aaa..a95a36d 100644 --- a/src/web/http/down.rs +++ b/src/web/http/down.rs @@ -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)) => { diff --git a/src/web/http/negotiation_tests.rs b/src/web/http/negotiation_tests.rs index 2ce9244..65e49ca 100644 --- a/src/web/http/negotiation_tests.rs +++ b/src/web/http/negotiation_tests.rs @@ -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; diff --git a/src/web/http/request.rs b/src/web/http/request.rs index 4c677da..a0a1897 100644 --- a/src/web/http/request.rs +++ b/src/web/http/request.rs @@ -140,6 +140,11 @@ pub(super) fn carrier_request(request: &Request, host: &str) -> Option { 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(request: &Request, host: &str) -> Option { + 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(request: &Request, 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; diff --git a/src/web/http/request/tests.rs b/src/web/http/request/tests.rs new file mode 100644 index 0000000..f1b536e --- /dev/null +++ b/src/web/http/request/tests.rs @@ -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) + ); +} diff --git a/src/web/http/session.rs b/src/web/http/session.rs index cd3aa94..9712e1f 100644 --- a/src/web/http/session.rs +++ b/src/web/http/session.rs @@ -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)) => { diff --git a/src/web/http/session_policy_tests.rs b/src/web/http/session_policy_tests.rs new file mode 100644 index 0000000..795ca8a --- /dev/null +++ b/src/web/http/session_policy_tests.rs @@ -0,0 +1,102 @@ +use super::*; + +async fn request_with_body_delay( + listener: &TcpListener, + runtime: &Arc, + head: Vec, + body: &[u8], + delay: std::time::Duration, +) -> Vec { + 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; +} diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs index f1493e8..2be00ab 100644 --- a/src/web/http/tests.rs +++ b/src/web/http/tests.rs @@ -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]; diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs index e5c26fc..cdfdb4b 100644 --- a/src/web/manager/credentials.rs +++ b/src/web/manager/credentials.rs @@ -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)> { + ) -> Option<(u64, Arc, Option)> { 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. diff --git a/src/web/manager/learning.rs b/src/web/manager/learning.rs deleted file mode 100644 index 636c07f..0000000 --- a/src/web/manager/learning.rs +++ /dev/null @@ -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, - 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, [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::>(); - 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); - } -} diff --git a/src/web/manager/lifecycle.rs b/src/web/manager/lifecycle.rs index 259f87c..3832d78 100644 --- a/src/web/manager/lifecycle.rs +++ b/src/web/manager/lifecycle.rs @@ -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 diff --git a/src/web/manager/negotiation.rs b/src/web/manager/negotiation.rs index 13b3321..27252bd 100644 --- a/src/web/manager/negotiation.rs +++ b/src/web/manager/negotiation.rs @@ -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 { 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::from_bits(self.0 & ceiling.0) + } } /// Immutable metadata attached to one session-creation attempt. diff --git a/src/web/manager/session_creation.rs b/src/web/manager/session_creation.rs index 6207a2e..0c41b5f 100644 --- a/src/web/manager/session_creation.rs +++ b/src/web/manager/session_creation.rs @@ -376,178 +376,7 @@ impl WebProcessRuntime { ); Ok(result) } - - fn replace_session( - self: &Arc, - bootstrap_hash: TokenHash, - client_ip: IpAddr, - replacement: Replacement, - ) -> std::result::Result { - 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; diff --git a/src/web/manager/session_creation/replacement.rs b/src/web/manager/session_creation/replacement.rs new file mode 100644 index 0000000..b7483db --- /dev/null +++ b/src/web/manager/session_creation/replacement.rs @@ -0,0 +1,177 @@ +use super::*; + +impl WebProcessRuntime { + pub(super) fn replace_session( + self: &Arc, + bootstrap_hash: TokenHash, + client_ip: IpAddr, + replacement: Replacement, + ) -> std::result::Result { + 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) + } +} diff --git a/src/web/session/backend.rs b/src/web/session/backend.rs index c751b2e..bd3d20f 100644 --- a/src/web/session/backend.rs +++ b/src/web/session/backend.rs @@ -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, + connection_permits: Arc, 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, diff --git a/src/web/session/backend_tests.rs b/src/web/session/backend_tests.rs index 077ca85..6c9d5a3 100644 --- a/src/web/session/backend_tests.rs +++ b/src/web/session/backend_tests.rs @@ -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); diff --git a/src/web/session/lifecycle.rs b/src/web/session/lifecycle.rs index bde6f1a..29c6d8b 100644 --- a/src/web/session/lifecycle.rs +++ b/src/web/session/lifecycle.rs @@ -206,6 +206,7 @@ impl WebSession { self.client_ip, self.profile_key, &self.profile.host, + Duration::from_secs(self.timeouts.bootstrap_lifetime_secs), ); } }