WEB Carrier: https-lanes

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-23 09:04:53 +03:00
parent 79ad4cb541
commit 90c0d65e1b
18 changed files with 1063 additions and 74 deletions
+1
View File
@@ -62,6 +62,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
public_addr: vhost.public_addr,
user: profile.user.clone(),
secret_mode: profile.secret_mode,
carrier: config.web.carrier,
capability,
max_sessions: profile
.max_sessions
+1 -1
View File
@@ -259,7 +259,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"web_trusted_proxy_cidrs",
];
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "limits", "timeouts", "vhosts"];
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "carrier", "limits", "timeouts", "vhosts"];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_header_bytes",
+5
View File
@@ -59,6 +59,11 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
}
validate_limits(&config.web.limits)?;
if config.web.carrier == WebCarrier::HttpsLanes
&& config.web.limits.max_http_handlers < 2
{
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2");
}
validate_timeouts(&config.web.timeouts)?;
validate_vhosts(config)?;
Ok(())
+16 -4
View File
@@ -14,6 +14,7 @@ web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
carrier = "https-lanes"
[[web.vhosts]]
host = "Proxy.Example.COM"
@@ -42,11 +43,22 @@ fn web_config_builds_canonical_runtime_snapshot() {
assert_eq!(vhost.profiles.len(), 1);
assert_eq!(vhost.profiles[0].user, "alice");
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
assert_eq!(vhost.profiles[0].carrier, WebCarrier::HttpsLanes);
assert_eq!(vhost.profiles[0].max_sessions, 4);
assert_eq!(vhost.profiles[0].max_streams, 64);
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
}
#[test]
fn https_lanes_requires_separate_poll_and_control_handler_capacity() {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_handlers = 1\nmax_body_readers = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web.carrier=https-lanes requires"));
}
#[test]
fn web_listener_requires_an_explicit_trusted_proxy() {
let invalid = WEB_CONFIG.replace(
@@ -60,8 +72,8 @@ fn web_listener_requires_an_explicit_trusted_proxy() {
#[test]
fn web_queue_limits_preserve_control_and_uplink_progress() {
let invalid = WEB_CONFIG.replace(
"[web]\nenabled = true",
"[web]\nenabled = true\n\n[web.limits]\ncontrol_bytes_per_session = 1",
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\n\n[web.limits]\ncontrol_bytes_per_session = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("control reserves must cover bounded control frames"));
@@ -70,9 +82,9 @@ fn web_queue_limits_preserve_control_and_uplink_progress() {
#[test]
fn web_semaphore_limits_are_rejected_before_runtime_construction() {
let invalid = WEB_CONFIG.replace(
"[web]\nenabled = true",
"carrier = \"https-lanes\"",
&format!(
"[web]\nenabled = true\n\n[web.limits]\nmax_http_connections = {}",
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_connections = {}",
tokio::sync::Semaphore::MAX_PERMITS + 1,
),
);
+1 -1
View File
@@ -49,7 +49,7 @@ pub use server::{
};
#[allow(unused_imports)]
pub use web::{
WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebCarrier, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebTimeoutsConfig, WebVhostConfig,
};
pub(crate) use web::{
+26
View File
@@ -16,6 +16,27 @@ pub enum WebSecretMode {
Dd,
}
/// HTTP carrier selected for newly issued WEB bridge sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
Https,
/// Give every logical stream independent HTTPS sequencing and polling state.
HttpsLanes,
}
impl WebCarrier {
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
}
}
}
/// One access user explicitly exposed through a WEB virtual host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebProfileConfig {
@@ -273,6 +294,9 @@ pub struct WebConfig {
/// Enables issuance of new WEB bridge and session credentials.
#[serde(default)]
pub enabled: bool,
/// Carrier selected for newly issued WEB bridge sessions.
#[serde(default)]
pub carrier: WebCarrier,
/// Hard process and protocol limits.
#[serde(default)]
pub limits: WebLimitsConfig,
@@ -320,6 +344,8 @@ pub(crate) struct WebRuntimeProfile {
pub(crate) user: String,
/// Client secret representation and inner protocol policy.
pub(crate) secret_mode: WebSecretMode,
/// Carrier frozen into bridge and session state at issuance time.
pub(crate) carrier: WebCarrier,
/// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32],
/// Per-profile live session ceiling.
+93 -22
View File
@@ -1,5 +1,6 @@
use base64::Engine as _;
use crate::config::WebCarrier;
use crate::crypto::SecureRandom;
/// Browser security policy for the transient Telegram Desktop bridge page.
@@ -13,13 +14,14 @@ pub(crate) struct BridgePage {
pub(crate) content_security_policy: String,
}
/// Renders the HTTPS-only WEB carrier bridge with a fresh CSP nonce.
/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce.
pub(crate) fn render(
host: &str,
bootstrap: &str,
batch_limit: usize,
queue_limit: usize,
queue_items: usize,
carrier: WebCarrier,
rng: &SecureRandom,
) -> BridgePage {
let mut nonce = [0u8; 18];
@@ -31,7 +33,8 @@ pub(crate) fn render(
.replace("__BOOTSTRAP__", bootstrap)
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
.replace("__QUEUE_ITEMS__", &queue_items.to_string());
.replace("__QUEUE_ITEMS__", &queue_items.to_string())
.replace("__CARRIER__", carrier.as_str());
BridgePage {
body,
content_security_policy: format!(
@@ -51,24 +54,26 @@ const DOCUMENT: &str = r##"<!doctype html>
<script nonce="__NONCE__">
(()=>{
'use strict';
const relayOrigin='https://__HOST__',bootstrap='__BOOTSTRAP__';
const relayOrigin='https://__HOST__',bootstrap='__BOOTSTRAP__',carrier='__CARRIER__';
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
history.replaceState(null,'',location.pathname);
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false;
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
const pending=[],upPending=[];
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
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||{})
});
function reserve(data){
function reserve(data,lane){
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes||queuedItems>=queueItemLimit)return false;
queuedBytes+=data.byteLength;queuedItems++;return true;
if(lane&&(data.byteLength>laneQueueLimit-lane.bytes||lane.items>=laneItemLimit))return false;
queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
}
function release(bytes,items){queuedBytes-=bytes;queuedItems-=items}
function release(bytes,items,lane){queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}}
function frameBound(value,maxFrames,maxBytes){
const view=new DataView(value);let offset=0,frames=0;
while(offset<value.byteLength){
@@ -85,19 +90,20 @@ function splitFrames(value){
const view=new DataView(value),result=[];let offset=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8||result.length>=4096)throw new Error('invalid frame batch');
const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
const size=view.getUint32(offset+4),end=offset+8+size;
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
result.push(offset===0&&end===value.byteLength?value:value.slice(offset,end));offset=end;
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 joinPending(values){
function joinPending(values,lane){
let total=0,count=0,frames=0;
while(count<values.length){
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
if(count===0&&!whole){
const head=new Uint8Array(values[0],0,bound.bytes).slice();
values[0]=values[0].slice(bound.bytes);queuedItems++;
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
return {body:head.buffer,total:bound.bytes,count:1};
}
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
@@ -136,7 +142,7 @@ async function createSession(first){
try{
status('connecting');
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
if(response.status!==200||response.headers.get('X-Carrier-Mode')!=='https')throw new Error('session rejected');
if(response.status!==200||response.headers.get('X-Carrier-Mode')!==carrier)throw new Error('session rejected');
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0';
if(!/^[A-Za-z0-9_-]{43}$/.test(sessionToken)||downCursor!=='0')throw new Error('invalid session metadata');
if(closed){deleteSession();return}
@@ -144,19 +150,23 @@ async function createSession(first){
const welcomeBytes=new Uint8Array(welcome);
if(welcomeBytes.length!==8||welcomeBytes[0]!==17||welcomeBytes.slice(1).some(value=>value!==0))throw new Error('invalid welcome');
port.postMessage(welcome,[welcome]);status('connected');
for(const data of pending.splice(0)){release(data.byteLength,1);queueUp(data)}
poll();
if(carrier==='https-lanes')ensureLane(0);
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
if(carrier==='https')poll();else pollLane(lanes.get(0));
}catch(error){fail()}
}
function queueUp(data){if(!reserve(data)){fail();return}upPending.push(data);runUp()}
function queueCarrier(data){
try{if(carrier==='https')queueUp(data);else for(const value of splitFrames(data))queueLane(value)}catch(error){fail()}
}
function queueUp(data){if(!reserve(data,null)){fail();return}upPending.push(data);runUp()}
async function runUp(){
if(upRunning)return;upRunning=true;
try{
while(!closed&&sessionToken&&upPending.length){
const batch=joinPending(upPending),sequence=String(upSequence);
const batch=joinPending(upPending,null),sequence=String(upSequence);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence}));
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
release(batch.total,batch.count);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++;
release(batch.total,batch.count,null);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++;
}
}catch(error){fail()}
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
@@ -175,20 +185,78 @@ async function poll(){
}catch(error){if(!closed)fail();return}
}
}
function ensureLane(id){
let lane=lanes.get(id);
if(!lane){lane={id,sequence:1,cursor:'0',pending:[],bytes:0,items:0,running:false,polling:false,controller:null};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 finishLane(lane){
if(lanes.get(lane.id)!==lane)return;
if(lane.bytes||lane.items)release(lane.bytes,lane.items,lane);
lane.pending.length=0;lanes.delete(lane.id);rememberLaneClosed(lane.id);
}
function queueLane(value){
let lane=lanes.get(value.id);
if(!lane&&(value.type===2||value.type===3||value.type===4))return;
if(!lane&&closedLanes.has(value.id))throw new Error('closed lane was reused');
if(!lane&&value.type!==1)throw new Error('lane did not begin with OPEN');
lane=lane||ensureLane(value.id);
if(!reserve(value.data,lane)){fail();return}
lane.pending.push(value.data);runLaneUp(lane);
}
async function runLaneUp(lane){
if(lane.running)return;lane.running=true;
try{
while(!closed&&sessionToken&&lane.pending.length){
const batch=joinPending(lane.pending,lane),sequence=String(lane.sequence),laneID=String(lane.id);
const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID}));
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('lane uplink rejected');
release(batch.total,batch.count,lane);port.postMessage({t:'traffic',up:batch.total,down:0});lane.sequence++;
if(!lane.polling)pollLane(lane);
}
}catch(error){fail()}
finally{lane.running=false;if(!closed&&sessionToken&&lane.pending.length)runLaneUp(lane)}
}
async function pollLane(lane){
if(!lane||lane.polling)return;lane.polling=true;
try{
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
if(response.status===204){
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane);return}
status('connected');continue;
}
if(response.status!==200)throw new Error('lane downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
if(!next||!data.byteLength||splitFrames(data).some(value=>value.id!==lane.id))throw new Error('invalid lane downlink response');
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(){
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
}
function close(notifyServer){
if(closed)return;closed=true;if(pollController)pollController.abort();if(notifyServer)deleteSession();
pending.length=0;upPending.length=0;queuedBytes=0;queuedItems=0;if(port)port.close();
if(closed)return;closed=true;if(pollController)pollController.abort();
for(const lane of lanes.values())if(lane.controller)lane.controller.abort();
if(notifyServer)deleteSession();pending.length=0;upPending.length=0;
for(const lane of lanes.values())lane.pending.length=0;lanes.clear();queuedBytes=0;queuedItems=0;if(port)port.close();
}
function activatePort(nextPort){
initialized=true;port=nextPort;
port.onmessage=message=>{
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;createSession(message.data)}
else if(!sessionToken){if(!reserve(message.data)){fail();return}pending.push(message.data)}
else queueUp(message.data);
else if(!sessionToken){if(!reserve(message.data,null)){fail();return}pending.push(message.data)}
else queueCarrier(message.data);
}else if(message.data&&message.data.t==='close')close(true);
};
port.start();status('connecting');
@@ -204,7 +272,7 @@ addEventListener('message',event=>{
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){for(const item of splitFrames(value))androidBridge.postMessage(item)}else androidBridge.postMessage(JSON.stringify(value));
if(value instanceof ArrayBuffer){for(const item of splitFrames(value))androidBridge.postMessage(item.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}));
@@ -228,11 +296,14 @@ mod tests {
2 * 1024 * 1024,
32 * 1024 * 1024,
16 * 1024,
WebCarrier::HttpsLanes,
&SecureRandom::new(),
);
assert!(!page.body.contains("__"));
assert!(!page.body.contains("bridge="));
assert!(page.body.contains("X-Up-Seq"));
assert!(page.body.contains("carrier='https-lanes'"));
assert!(page.body.contains("X-Lane-ID"));
assert!(page
.content_security_policy
.contains("frame-ancestors http://127.0.0.1:*"));
+2
View File
@@ -4,6 +4,8 @@ use crate::config::WebLimitsConfig;
/// Fixed WEB frame header size.
pub(crate) const HEADER_BYTES: usize = 8;
/// Largest stream identifier representable by the WEB frame header.
pub(crate) const MAX_STREAM_ID: u32 = 0x00ff_ffff;
/// Initial bidirectional stream credit.
pub(crate) const INITIAL_STREAM_WINDOW: u32 = 4 * 1024 * 1024;
/// Maximum data chunk emitted by the server.
+51 -12
View File
@@ -18,7 +18,7 @@ use parking_lot::Mutex;
use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
use crate::config::{WebClientIpSource, WebRuntimeVhost};
use crate::config::{WebCarrier, WebClientIpSource, WebRuntimeVhost};
use crate::web::bridge;
use crate::web::frame::{self, FrameType};
use crate::web::manager::{ManagerError, WebProcessRuntime};
@@ -183,18 +183,19 @@ async fn handle_root(
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
let carrier = profile.carrier;
let Ok(bootstrap) = runtime.issue_bootstrap(profile, client_ip) else {
strip_query(&mut request);
return serve_decoy(request, vhost, true, &runtime).await;
};
let generation = runtime.active_generation();
let config = generation.config();
let page = bridge::render(
&vhost.host,
&bootstrap,
config.web.limits.carrier_batch_bytes,
config.web.limits.pending_bytes_per_session,
config.web.limits.pending_items_per_session,
generation.config().web.limits.carrier_batch_bytes,
generation.config().web.limits.pending_bytes_per_session,
generation.config().web.limits.pending_items_per_session,
carrier,
&generation.rng,
);
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
@@ -238,10 +239,7 @@ async fn handle_api(
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
) -> HttpResponse {
if request.uri().query().is_some()
|| request.headers().contains_key(header::COOKIE)
|| request.headers().contains_key("x-lane-id")
{
if request.uri().query().is_some() || request.headers().contains_key(header::COOKIE) {
return serve_decoy(request, vhost, true, &runtime).await;
}
let Some(client_ip) = client_ip(
@@ -272,6 +270,9 @@ async fn handle_session(
token_hash: crate::web::manager::TokenHash,
client_ip: IpAddr,
) -> HttpResponse {
if request.headers().contains_key("x-lane-id") {
return serve_decoy(request, vhost, true, &runtime).await;
}
if request.method() == Method::DELETE {
if request.headers().contains_key(header::CONTENT_TYPE) {
return serve_decoy(request, vhost, true, &runtime).await;
@@ -321,7 +322,7 @@ async fn handle_session(
);
response.headers_mut().insert(
HeaderName::from_static("x-carrier-mode"),
HeaderValue::from_static("https"),
HeaderValue::from_static(result.carrier.as_str()),
);
response.headers_mut().insert(
HeaderName::from_static("x-down-cursor"),
@@ -352,6 +353,9 @@ async fn handle_up(
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let limit = runtime.active_generation().config().web.limits.max_body_bytes;
let CollectedBody {
request,
@@ -364,7 +368,11 @@ async fn handle_up(
return serve_decoy(request, vhost, true, &runtime).await;
}
};
match session.process_up(sequence, &body) {
let result = match lane_id {
Some(lane_id) => session.process_up_lane(lane_id, sequence, &body),
None => session.process_up(sequence, &body),
};
match result {
Ok(ack) => {
let mut response = carrier_empty(StatusCode::NO_CONTENT);
insert_header(
@@ -396,6 +404,9 @@ async fn handle_down(
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
return serve_decoy(request, vhost, true, &runtime).await;
};
let CollectedBody {
request,
body,
@@ -410,7 +421,19 @@ async fn handle_down(
if !body.is_empty() {
return serve_decoy(request, vhost, true, &runtime).await;
}
match session.poll_down(cursor).await {
let _lane_poll = if lane_id.is_some() {
let Some(permit) = runtime.try_lane_poll() else {
return service_unavailable();
};
Some(permit)
} else {
None
};
let result = match lane_id {
Some(lane_id) => session.poll_down_lane(lane_id, cursor).await,
None => session.poll_down(cursor).await,
};
match result {
Ok(result) if result.body.is_empty() => {
let mut response = carrier_empty(StatusCode::NO_CONTENT);
insert_header(
@@ -418,6 +441,12 @@ async fn handle_down(
HeaderName::from_static("x-down-cursor"),
&result.next_cursor.to_string(),
);
if result.lane_closed {
response.headers_mut().insert(
HeaderName::from_static("x-lane-closed"),
HeaderValue::from_static("1"),
);
}
response
}
Ok(result) => {
@@ -437,6 +466,16 @@ async fn handle_down(
}
}
fn carrier_lane<B>(request: &Request<B>, carrier: WebCarrier) -> Option<Option<u32>> {
match carrier {
WebCarrier::Https => (!request.headers().contains_key("x-lane-id")).then_some(None),
WebCarrier::HttpsLanes => canonical_u64_header(request, "x-lane-id")
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value <= frame::MAX_STREAM_ID)
.map(Some),
}
}
fn carrier_headers(response: &mut HttpResponse) {
response.headers_mut().insert(
header::CONTENT_TYPE,
+85 -4
View File
@@ -10,19 +10,20 @@ use tokio_util::sync::CancellationToken;
use super::serve_connection;
use crate::config::{
ProxyConfig, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
ProxyConfig, WebCarrier, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
};
use crate::maestro::generation::test_runtime_generation;
use crate::web::frame::{self, FrameType};
use crate::web::manager::WebProcessRuntime;
fn runtime_config(capability: [u8; 32]) -> ProxyConfig {
fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier,
capability,
max_sessions: 4,
max_streams: 16,
@@ -60,6 +61,7 @@ fn runtime_config(capability: [u8; 32]) -> ProxyConfig {
);
let mut config = ProxyConfig::default();
config.web.enabled = true;
config.web.carrier = carrier;
config.web.limits.max_bootstraps_per_ip = 1;
config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
@@ -115,7 +117,7 @@ fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
#[tokio::test]
async fn https_carrier_bootstraps_and_closes_one_session() {
let capability = [7u8; 32];
let generation = test_runtime_generation(1, runtime_config(capability));
let generation = test_runtime_generation(1, runtime_config(capability, WebCarrier::Https));
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();
@@ -171,13 +173,17 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
let session = response_header(create_headers, "x-session-token");
assert_eq!(session.len(), 43);
let replacement = test_runtime_generation(2, runtime_config(capability));
let replacement = test_runtime_generation(
2,
runtime_config(capability, WebCarrier::HttpsLanes),
);
active_runtime.store(Arc::clone(&replacement));
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let retry_response = request(&listener, &runtime, create_retry).await;
let (retry_headers, retry_body) = split_response(&retry_response);
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(retry_headers, "x-session-token"), session);
assert_eq!(response_header(retry_headers, "x-carrier-mode"), "https");
assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[]));
let next_root = format!(
@@ -187,6 +193,9 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
let next_root_response = request(&listener, &runtime, next_root).await;
let (_, next_root_body) = split_response(&next_root_response);
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
assert!(next_root_body
.windows(21)
.any(|value| value == b"carrier='https-lanes'"));
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"
@@ -204,3 +213,75 @@ async fn https_carrier_bootstraps_and_closes_one_session() {
replacement.stop_sessions().await;
replacement.stop_background_tasks().await;
}
#[tokio::test]
async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() {
let capability = [9u8; 32];
let generation = test_runtime_generation(
1,
runtime_config(capability, WebCarrier::HttpsLanes),
);
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
let runtime = WebProcessRuntime::start(active_runtime);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let root = format!(
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
)
.into_bytes();
let root_response = request(&listener, &runtime, root).await;
let (_, root_body) = split_response(&root_response);
let root_body = std::str::from_utf8(root_body).unwrap();
assert!(root_body.contains("carrier='https-lanes'"));
let bootstrap = root_body
.split_once("bootstrap='")
.and_then(|(_, suffix)| suffix.split_once('\''))
.map(|(token, _)| token)
.unwrap();
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut create = format!(
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
create.extend_from_slice(&hello);
let create_response = request(&listener, &runtime, create).await;
let (create_headers, _) = split_response(&create_response);
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https-lanes");
let session = response_header(create_headers, "x-session-token").to_string();
let pong = frame::encode(FrameType::Pong, 0, &[]);
let mut uplink = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nX-Lane-ID: 0\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
uplink.extend_from_slice(&pong);
let uplink_response = request(&listener, &runtime, uplink).await;
let (uplink_headers, _) = split_response(&uplink_response);
assert!(uplink_headers.starts_with(b"HTTP/1.1 204"));
assert_eq!(response_header(uplink_headers, "x-up-ack"), "1");
let mut missing_lane = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 2\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
missing_lane.extend_from_slice(&pong);
let missing_lane_response = request(&listener, &runtime, missing_lane).await;
assert!(!missing_lane_response.starts_with(b"HTTP/1.1 204"));
let mut aliased_lane = format!(
"POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 2\r\nX-Lane-ID: 00\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
pong.len()
)
.into_bytes();
aliased_lane.extend_from_slice(&pong);
let aliased_lane_response = request(&listener, &runtime, aliased_lane).await;
assert!(!aliased_lane_response.starts_with(b"HTTP/1.1 204"));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+20 -4
View File
@@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use zeroize::Zeroizing;
use crate::config::{WebLimitsConfig, WebRuntimeProfile};
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile};
use crate::maestro::generation::RuntimeGeneration;
use crate::web::frame;
use crate::web::session::WebSession;
@@ -59,6 +59,8 @@ pub(crate) enum ManagerError {
pub(crate) struct CreateResult {
/// Opaque bearer token for the created or replayed session.
pub(crate) token: String,
/// Carrier frozen into the created or replayed session.
pub(crate) carrier: WebCarrier,
}
/// Process-owned bounded WEB credential, session, and memory coordinator.
@@ -68,6 +70,7 @@ pub(crate) struct WebProcessRuntime {
state: Mutex<ManagerState>,
http_connections: Arc<Semaphore>,
http_handlers: Arc<Semaphore>,
lane_polls: Arc<Semaphore>,
body_readers: Arc<Semaphore>,
body_bytes: Arc<Semaphore>,
stream_handshakes: Arc<Semaphore>,
@@ -94,6 +97,7 @@ impl WebProcessRuntime {
active_runtime,
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)),
lane_polls: Arc::new(Semaphore::new((limits.max_http_handlers / 2).max(1))),
body_readers: Arc::new(Semaphore::new(limits.max_body_readers)),
body_bytes: Arc::new(Semaphore::new(limits.max_body_bytes_global)),
stream_handshakes: Arc::new(Semaphore::new(limits.max_stream_handshakes)),
@@ -154,6 +158,15 @@ impl WebProcessRuntime {
permit
}
/// Reserves one parked lane poll without exhausting all HTTP handlers.
pub(crate) fn try_lane_poll(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.lane_polls).try_acquire_owned().ok();
if permit.is_none() {
self.record_limit_hit();
}
permit
}
/// Reserves one logical stream in the inner MTProxy handshake phase.
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
let permit = Arc::clone(&self.stream_handshakes)
@@ -307,11 +320,13 @@ impl WebProcessRuntime {
if !digest_matches {
return Err(ManagerError::Authentication);
}
if entry.session.is_none() {
return Err(ManagerError::Authentication);
}
let session = entry
.session
.as_ref()
.ok_or(ManagerError::Authentication)?;
return Ok(CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
});
}
if entry.generation_id != generation.id {
@@ -379,6 +394,7 @@ impl WebProcessRuntime {
self.sessions_created.fetch_add(1, Ordering::Relaxed);
Ok(CreateResult {
token: session_token,
carrier: session.carrier(),
})
}
+1
View File
@@ -130,6 +130,7 @@ pub(super) fn matching_profile(
&& profile.public_addr == expected.public_addr
&& profile.user == expected.user
&& profile.secret_mode == expected.secret_mode
&& profile.carrier == expected.carrier
&& profile.capability == expected.capability
})
.cloned()
+57 -3
View File
@@ -12,7 +12,7 @@ use tokio::io::ReadBuf;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
use crate::web::frame::{self, FrameType};
use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
@@ -20,6 +20,8 @@ use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
mod backend;
// Downlink queues own cursor replay, flow control, and memory reservations.
mod downlink;
// Lane carrier state isolates request sequencing and downlink replay per logical stream.
mod lanes;
// Uplink batches own exactly-once sequencing and client-frame validation.
mod uplink;
@@ -64,6 +66,34 @@ struct DownBatch {
control_items: usize,
}
struct CarrierLane {
pending_frames: VecDeque<QueuedFrame>,
pending_windows: HashMap<u32, usize>,
unacked: Option<DownBatch>,
down_cursor: u64,
down_epoch: u64,
last_up_sequence: u64,
last_up_digest: TokenHash,
up_active: bool,
notify: Arc<Notify>,
}
impl CarrierLane {
fn new() -> Self {
Self {
pending_frames: VecDeque::new(),
pending_windows: HashMap::new(),
unacked: None,
down_cursor: 0,
down_epoch: 0,
last_up_sequence: 0,
last_up_digest: [0; 32],
up_active: false,
notify: Arc::new(Notify::new()),
}
}
}
struct SessionState {
streams: HashMap<u32, StreamState>,
active_peer_ports: HashSet<u16>,
@@ -76,6 +106,7 @@ struct SessionState {
down_epoch: u64,
last_up_sequence: u64,
last_up_digest: TokenHash,
carrier_lanes: HashMap<u32, CarrierLane>,
pending_bytes: usize,
pending_items: usize,
pending_control_bytes: usize,
@@ -108,6 +139,8 @@ pub(crate) struct PollResult {
pub(crate) body: Bytes,
/// Cursor the client must present on its next downlink request.
pub(crate) next_cursor: u64,
/// Indicates that a drained non-zero lane no longer needs polling.
pub(crate) lane_closed: bool,
}
impl WebSession {
@@ -122,6 +155,10 @@ impl WebSession {
limits: WebLimitsConfig,
timeouts: WebTimeoutsConfig,
) -> Arc<Self> {
let mut carrier_lanes = HashMap::new();
if profile.carrier == WebCarrier::HttpsLanes {
carrier_lanes.insert(0, CarrierLane::new());
}
Arc::new(Self {
manager,
token_hash,
@@ -142,6 +179,7 @@ impl WebSession {
down_epoch: 0,
last_up_sequence: 0,
last_up_digest: [0; 32],
carrier_lanes,
pending_bytes: 0,
pending_items: 0,
pending_control_bytes: 0,
@@ -168,6 +206,11 @@ impl WebSession {
self.profile.host == host
}
/// Returns the immutable carrier selected when this session was created.
pub(crate) fn carrier(&self) -> WebCarrier {
self.profile.carrier
}
/// Closes carrier state while relay tasks retain their admission until exit.
pub(crate) fn close(&self) {
let (data_bytes, data_items, control_bytes, control_items) = {
@@ -188,6 +231,10 @@ impl WebSession {
state.pending_frames.clear();
state.pending_windows.clear();
state.unacked = None;
for lane in state.carrier_lanes.values() {
lane.notify.notify_waiters();
}
state.carrier_lanes.clear();
let control_bytes = state.pending_control_bytes;
let control_items = state.pending_control_items;
let data_bytes = state.pending_bytes.saturating_sub(control_bytes);
@@ -199,7 +246,9 @@ impl WebSession {
(data_bytes, data_items, control_bytes, control_items)
};
self.cancel.cancel();
if self.carrier() == WebCarrier::Https {
self.down_notify.notify_waiters();
}
if let Some(manager) = self.manager.upgrade() {
manager.release_pending(data_bytes, data_items, false);
manager.release_pending(control_bytes, control_items, true);
@@ -311,7 +360,9 @@ impl WebSession {
stream.send_credit -= count as u64;
state.last_activity = Instant::now();
drop(state);
if self.carrier() == WebCarrier::Https {
self.down_notify.notify_waiters();
}
Poll::Ready(Ok(count))
}
@@ -342,14 +393,17 @@ fn inbound_queue_cost(queue: &VecDeque<InboundChunk>) -> (usize, usize) {
(bytes, queue.len())
}
fn remember_closed(state: &mut SessionState, stream_id: u32, limit: usize) {
fn remember_closed(state: &mut SessionState, stream_id: u32, limit: usize) -> Option<u32> {
if !state.closed_streams.insert(stream_id) {
return;
return None;
}
state.closed_order.push_back(stream_id);
let mut evicted = None;
while state.closed_order.len() > limit {
if let Some(oldest) = state.closed_order.pop_front() {
state.closed_streams.remove(&oldest);
evicted = Some(oldest);
}
}
evicted
}
+4 -6
View File
@@ -7,7 +7,7 @@ use crate::web::frame::FrameType;
use crate::web::stream::WebLogicalStream;
use crate::proxy::shared_state::ConntrackClosePolicy;
use super::{WebSession, inbound_queue_cost, remember_closed};
use super::{WebSession, inbound_queue_cost};
impl WebSession {
/// Starts one owned inner handshake and relay task for an admitted stream.
@@ -65,11 +65,7 @@ impl WebSession {
let queued = state.streams.remove(&stream_id).map(|stream| {
let (bytes, items) = inbound_queue_cost(&stream.inbound);
self.release_locked(&mut state, bytes, items, false);
remember_closed(
&mut state,
stream_id,
self.limits.max_tombstones_per_session,
);
self.remember_closed_locked(&mut state, stream_id);
self.queue_control_locked(&mut state, FrameType::Close, stream_id, &[])
});
(queued, reserved)
@@ -88,10 +84,12 @@ impl WebSession {
if !queued {
self.close();
}
if self.carrier() == crate::config::WebCarrier::Https {
self.down_notify.notify_waiters();
}
}
}
}
struct StreamCompletion {
session: Arc<WebSession>,
+29
View File
@@ -6,12 +6,16 @@ use super::{
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
WebSession,
};
use crate::config::WebCarrier;
use crate::web::frame::{self, FrameType};
use crate::web::manager::ManagerError;
impl WebSession {
/// Polls pending downlink frames with cursor replay and newest-poll-wins semantics.
pub(crate) async fn poll_down(&self, cursor: u64) -> Result<PollResult, ManagerError> {
if self.carrier() != WebCarrier::Https {
return Err(ManagerError::Protocol);
}
let epoch = {
let mut state = self.state.lock();
if state.closed {
@@ -23,6 +27,7 @@ impl WebSession {
return Ok(PollResult {
body: unacked.body.clone(),
next_cursor: unacked.next_cursor,
lane_closed: false,
});
}
if cursor != unacked.next_cursor {
@@ -51,6 +56,7 @@ impl WebSession {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
});
}
if !state.pending_frames.is_empty() {
@@ -65,6 +71,7 @@ impl WebSession {
let result = PollResult {
body: batch.body.clone(),
next_cursor: batch.next_cursor,
lane_closed: false,
};
if let Some(manager) = self.manager.upgrade() {
manager.record_down(result.body.len());
@@ -89,6 +96,7 @@ impl WebSession {
Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
})
}
}
@@ -210,6 +218,14 @@ impl WebSession {
if amount == 0 {
return true;
}
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_control_locked(
state,
FrameType::Window,
stream_id,
&frame::window_payload(amount),
);
}
if let Some(index) = state.pending_windows.get(&stream_id).copied()
&& let Some(queued) = state.pending_frames.get_mut(index)
{
@@ -251,6 +267,9 @@ impl WebSession {
stream_id: u32,
payload: &[u8],
) -> bool {
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_frame_locked(state, FrameType::Data, stream_id, payload, false);
}
let can_coalesce = state.pending_frames.back().is_some_and(|last| {
last.frame_type == FrameType::Data
&& last.stream_id == stream_id
@@ -281,6 +300,15 @@ impl WebSession {
payload: &[u8],
control: bool,
) -> bool {
if self.carrier() == WebCarrier::HttpsLanes {
return self.queue_lane_frame_locked(
state,
frame_type,
stream_id,
payload,
control,
);
}
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
let class = if control {
PendingClass::Control
@@ -408,6 +436,7 @@ mod tests {
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https,
capability: [0; 32],
max_sessions: 1,
max_streams: 1,
+525
View File
@@ -0,0 +1,525 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::{BufMut, Bytes, BytesMut};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use super::uplink::{inbound_reservation, validate_batch};
use super::{
CarrierLane, DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame,
SessionState, WebSession, remember_closed,
};
use crate::config::{WebCarrier, WebLimitsConfig};
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
impl WebSession {
/// Applies one exactly-once uplink batch to an independent HTTPS lane.
pub(crate) fn process_up_lane(
self: &Arc<Self>,
lane_id: u32,
sequence: u64,
body: &[u8],
) -> Result<u64, ManagerError> {
if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID {
return Err(ManagerError::Protocol);
}
let frames = match frame::parse_all(body, &self.limits) {
Ok(frames) => frames,
Err(_) => {
self.close();
return Err(ManagerError::Protocol);
}
};
if frames.iter().copied().any(|value| {
value.stream_id != lane_id || frame::validate_client_shape(value).is_err()
}) {
self.close();
return Err(ManagerError::Protocol);
}
let digest: TokenHash = Sha256::digest(body).into();
let mut opened = Vec::new();
let result = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
state.last_activity = Instant::now();
if !state.carrier_lanes.contains_key(&lane_id) {
if lane_id != 0
&& frames.first().is_some_and(|value| value.frame_type != FrameType::Open)
&& only_late_frames(&frames)
{
return Ok(sequence);
}
if lane_id == 0
|| frames.first().is_none_or(|value| value.frame_type != FrameType::Open)
{
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
state.carrier_lanes.insert(lane_id, CarrierLane::new());
}
let lane = state
.carrier_lanes
.get_mut(&lane_id)
.ok_or(ManagerError::Protocol)?;
if sequence == lane.last_up_sequence && sequence != 0 {
return if bool::from(lane.last_up_digest.ct_eq(&digest)) {
Ok(sequence)
} else {
drop(state);
self.close();
Err(ManagerError::Protocol)
};
}
if sequence == 0 || sequence != lane.last_up_sequence.saturating_add(1) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
if lane.up_active {
return Err(ManagerError::Concurrent);
}
lane.up_active = true;
if !validate_batch(&state, &frames) {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
if !self.reserve_locked(
&mut state,
reserve_bytes,
reserve_items,
PendingClass::Uplink,
) {
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
lane.up_active = false;
}
return Err(ManagerError::Backpressure);
}
let mut unused_bytes = reserve_bytes;
let mut unused_items = reserve_items;
let applied = self.apply_batch_locked(
&mut state,
&frames,
&mut opened,
&mut unused_bytes,
&mut unused_items,
);
self.release_locked(&mut state, unused_bytes, unused_items, false);
if let Some(lane) = state.carrier_lanes.get_mut(&lane_id) {
lane.up_active = false;
if applied {
lane.last_up_sequence = sequence;
lane.last_up_digest = digest;
}
}
applied.then_some(sequence).ok_or(ManagerError::Closed)
};
if matches!(result, Err(ManagerError::Backpressure)) {
return result;
}
if result.is_err() {
self.close();
for (_, peer_port) in opened {
self.release_stream_reservation(peer_port);
}
return result;
}
for (stream_id, peer_port) in opened {
self.spawn_stream(stream_id, peer_port);
}
if let Some(manager) = self.manager.upgrade() {
manager.record_up(body.len());
}
result
}
/// Polls one lane with independent cursor replay and newest-poll-wins semantics.
pub(crate) async fn poll_down_lane(
&self,
lane_id: u32,
cursor: u64,
) -> Result<PollResult, ManagerError> {
if self.carrier() != WebCarrier::HttpsLanes || lane_id > frame::MAX_STREAM_ID {
return Err(ManagerError::Protocol);
}
let (epoch, notify) = {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
let acknowledged = {
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
return Err(ManagerError::Protocol);
};
if let Some(unacked) = &lane.unacked {
if cursor == unacked.base_cursor {
return Ok(PollResult {
body: unacked.body.clone(),
next_cursor: unacked.next_cursor,
lane_closed: false,
});
}
if cursor != unacked.next_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
lane.unacked.take()
} else {
if cursor != lane.down_cursor {
drop(state);
self.close();
return Err(ManagerError::Protocol);
}
None
}
};
if let Some(batch) = acknowledged {
self.release_locked(&mut state, batch.data_bytes, batch.data_items, false);
self.release_locked(
&mut state,
batch.control_bytes,
batch.control_items,
true,
);
if let Some(stream) = state.streams.get_mut(&lane_id)
&& let Some(waker) = stream.write_waker.take()
{
waker.wake();
}
}
state.last_activity = Instant::now();
let lane = state
.carrier_lanes
.get_mut(&lane_id)
.ok_or(ManagerError::Protocol)?;
lane.down_epoch = lane.down_epoch.wrapping_add(1).max(1);
(lane.down_epoch, Arc::clone(&lane.notify))
};
notify.notify_waiters();
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
let poll = async {
loop {
let notified = notify.notified();
{
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
let Some(lane) = state.carrier_lanes.get_mut(&lane_id) else {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
};
if lane.down_epoch != epoch {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
});
}
if !lane.pending_frames.is_empty() {
let batch = match take_lane_down_batch(&self.limits, lane, cursor) {
Ok(batch) => batch,
Err(error) => {
drop(state);
self.close();
return Err(error);
}
};
let result = PollResult {
body: batch.body.clone(),
next_cursor: batch.next_cursor,
lane_closed: false,
};
lane.unacked = Some(batch);
drop(state);
if let Some(manager) = self.manager.upgrade() {
manager.record_down(result.body.len());
}
return Ok(result);
}
if lane_id != 0
&& !state.streams.contains_key(&lane_id)
&& state.closed_streams.contains(&lane_id)
{
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
}
notified.await;
}
};
match tokio::time::timeout(deadline, poll).await {
Ok(result) => result,
Err(_) => {
let mut state = self.state.lock();
if state.closed {
return Err(ManagerError::Closed);
}
if !state.carrier_lanes.contains_key(&lane_id) {
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
if lane_id != 0
&& !state.streams.contains_key(&lane_id)
&& state.closed_streams.contains(&lane_id)
{
return Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: true,
});
}
if state
.carrier_lanes
.get(&lane_id)
.is_some_and(|lane| lane.down_epoch == epoch)
{
state.last_activity = Instant::now();
}
Ok(PollResult {
body: Bytes::new(),
next_cursor: cursor,
lane_closed: false,
})
}
}
}
pub(super) fn queue_lane_frame_locked(
&self,
state: &mut SessionState,
frame_type: FrameType,
stream_id: u32,
payload: &[u8],
control: bool,
) -> bool {
if !state.carrier_lanes.contains_key(&stream_id) {
return false;
}
if frame_type == FrameType::Window {
let coalesced = state.carrier_lanes.get(&stream_id).and_then(|lane| {
let index = lane.pending_windows.get(&stream_id).copied()?;
let queued = lane.pending_frames.get(index)?;
let previous = u32::from_be_bytes(
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.try_into()
.unwrap_or([0; 4]),
);
previous
.checked_add(frame::window_amount(payload).unwrap_or(0))
.map(|total| (index, total))
});
if let Some((index, total)) = coalesced
&& let Some(lane) = state.carrier_lanes.get_mut(&stream_id)
&& let Some(queued) = lane.pending_frames.get_mut(index)
{
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
.copy_from_slice(&total.to_be_bytes());
lane.notify.notify_waiters();
return true;
}
}
let can_coalesce = frame_type == FrameType::Data
&& state
.carrier_lanes
.get(&stream_id)
.and_then(|lane| lane.pending_frames.back())
.is_some_and(|last| {
last.frame_type == FrameType::Data
&& last.stream_id == stream_id
&& last.encoded.len() - frame::HEADER_BYTES + payload.len()
<= self.limits.max_frame_payload_bytes
});
if can_coalesce {
if !self.reserve_locked(state, payload.len(), 0, PendingClass::Downlink) {
return false;
}
let Some(lane) = state.carrier_lanes.get_mut(&stream_id) else {
self.release_locked(state, payload.len(), 0, false);
return false;
};
let Some(last) = lane.pending_frames.back_mut() else {
self.release_locked(state, payload.len(), 0, false);
return false;
};
last.encoded.extend_from_slice(payload);
last.cost += payload.len();
let payload_len = (last.encoded.len() - frame::HEADER_BYTES) as u32;
last.encoded[4..8].copy_from_slice(&payload_len.to_be_bytes());
lane.notify.notify_waiters();
return true;
}
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
let class = if control {
PendingClass::Control
} else {
PendingClass::Downlink
};
if !self.reserve_locked(state, cost, 1, class) {
return false;
}
let mut encoded = BytesMut::with_capacity(frame::HEADER_BYTES + payload.len());
encoded.put_u8(frame_type as u8);
encoded.put_u8((stream_id >> 16) as u8);
encoded.put_u8((stream_id >> 8) as u8);
encoded.put_u8(stream_id as u8);
encoded.put_u32(payload.len() as u32);
encoded.extend_from_slice(payload);
let Some(lane) = state.carrier_lanes.get_mut(&stream_id) else {
self.release_locked(state, cost, 1, control);
return false;
};
let index = lane.pending_frames.len();
lane.pending_frames.push_back(QueuedFrame {
encoded,
frame_type,
stream_id,
control,
cost,
});
if frame_type == FrameType::Window {
lane.pending_windows.insert(stream_id, index);
}
lane.notify.notify_waiters();
true
}
pub(super) fn remember_closed_locked(&self, state: &mut SessionState, stream_id: u32) {
let evicted = remember_closed(
state,
stream_id,
self.limits.max_tombstones_per_session,
);
if self.carrier() != WebCarrier::HttpsLanes {
return;
}
if let Some(evicted) = evicted {
self.release_lane_locked(state, evicted);
}
if let Some(lane) = state.carrier_lanes.get(&stream_id) {
lane.notify.notify_waiters();
}
}
fn release_lane_locked(&self, state: &mut SessionState, lane_id: u32) {
let Some(mut lane) = state.carrier_lanes.remove(&lane_id) else {
return;
};
lane.notify.notify_waiters();
let mut data_bytes = 0usize;
let mut data_items = 0usize;
let mut control_bytes = 0usize;
let mut control_items = 0usize;
for queued in lane.pending_frames.drain(..) {
if queued.control {
control_bytes = control_bytes.saturating_add(queued.cost);
control_items = control_items.saturating_add(1);
} else {
data_bytes = data_bytes.saturating_add(queued.cost);
data_items = data_items.saturating_add(1);
}
}
if let Some(batch) = lane.unacked.take() {
data_bytes = data_bytes.saturating_add(batch.data_bytes);
data_items = data_items.saturating_add(batch.data_items);
control_bytes = control_bytes.saturating_add(batch.control_bytes);
control_items = control_items.saturating_add(batch.control_items);
}
self.release_locked(state, data_bytes, data_items, false);
self.release_locked(state, control_bytes, control_items, true);
}
}
fn only_late_frames(frames: &[Frame<'_>]) -> bool {
frames.iter().all(|value| {
matches!(
value.frame_type,
FrameType::Data | FrameType::Window | FrameType::Close
)
})
}
fn take_lane_down_batch(
limits: &WebLimitsConfig,
lane: &mut CarrierLane,
cursor: u64,
) -> Result<DownBatch, ManagerError> {
let next_cursor = lane
.down_cursor
.checked_add(1)
.ok_or(ManagerError::Protocol)?;
let mut count = 0usize;
let mut body_len = 0usize;
for queued in &lane.pending_frames {
if count >= limits.max_frames_per_body
|| (count != 0
&& body_len.saturating_add(queued.encoded.len()) > limits.carrier_batch_bytes)
{
break;
}
body_len += queued.encoded.len();
count += 1;
}
let mut body = BytesMut::with_capacity(body_len);
let mut data_bytes = 0usize;
let mut data_items = 0usize;
let mut control_bytes = 0usize;
let mut control_items = 0usize;
for index in 0..count {
let Some(queued) = lane.pending_frames.get(index) else {
break;
};
if queued.frame_type == FrameType::Window
&& lane.pending_windows.get(&queued.stream_id) == Some(&index)
{
lane.pending_windows.remove(&queued.stream_id);
}
}
for _ in 0..count {
let Some(queued) = lane.pending_frames.pop_front() else {
break;
};
body.extend_from_slice(&queued.encoded);
if queued.control {
control_bytes += queued.cost;
control_items += 1;
} else {
data_bytes += queued.cost;
data_items += 1;
}
}
for index in lane.pending_windows.values_mut() {
*index = index.saturating_sub(count);
}
lane.down_cursor = next_cursor;
Ok(DownBatch {
body: body.freeze(),
base_cursor: cursor,
next_cursor,
data_bytes,
data_items,
control_bytes,
control_items,
})
}
// Lane-specific protocol, replay, and lifecycle tests.
#[cfg(test)]
mod tests;
+129
View File
@@ -0,0 +1,129 @@
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::BytesMut;
use super::*;
use crate::config::{
WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
};
use crate::web::manager::WebProcessRuntime;
fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
let profile = Arc::new(WebRuntimeProfile {
host: "proxy.example.com".to_string(),
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::HttpsLanes,
capability: [0; 32],
max_sessions: 1,
max_streams: 2,
max_streams_per_session: 2,
});
WebSession::new(
std::sync::Weak::<WebProcessRuntime>::new(),
[1; 32],
"192.0.2.10".parse().unwrap(),
profile,
[2; 32],
limits,
WebTimeoutsConfig::default(),
)
}
fn session() -> Arc<WebSession> {
session_with_limits(WebLimitsConfig::default())
}
#[test]
fn lane_uplink_sequences_are_independent_and_exactly_once() {
let session = session();
{
let mut state = session.state.lock();
for lane_id in [51, 52] {
state.carrier_lanes.insert(lane_id, CarrierLane::new());
state.closed_streams.insert(lane_id);
}
}
let first = frame::encode(FrameType::Data, 51, b"first");
let second = frame::encode(FrameType::Data, 52, b"second");
assert_eq!(session.process_up_lane(51, 1, &first), Ok(1));
assert_eq!(session.process_up_lane(52, 1, &second), Ok(1));
assert_eq!(session.process_up_lane(51, 1, &first), Ok(1));
assert_eq!(session.state.lock().carrier_lanes[&52].last_up_sequence, 1);
}
#[test]
fn cross_lane_frame_is_fatal_to_https_lane_session() {
let session = session();
let body = frame::encode(FrameType::Data, 52, b"wrong lane");
assert_eq!(
session.process_up_lane(51, 1, &body),
Err(ManagerError::Protocol)
);
assert!(session.state.lock().closed);
}
#[tokio::test]
async fn drained_closed_lane_replays_then_signals_completion() {
let session = session();
{
let mut state = session.state.lock();
state.carrier_lanes.insert(7, CarrierLane::new());
state.closed_streams.insert(7);
let lane = state.carrier_lanes.get_mut(&7).unwrap();
let encoded = frame::encode(FrameType::Close, 7, &[]);
lane.pending_frames.push_back(QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 7,
control: true,
cost: frame::HEADER_BYTES + QUEUE_ITEM_COST,
});
}
let first = session.poll_down_lane(7, 0).await.unwrap();
let replay = session.poll_down_lane(7, 0).await.unwrap();
assert_eq!(first.body, replay.body);
assert!(!replay.lane_closed);
let finished = session.poll_down_lane(7, 1).await.unwrap();
assert!(finished.body.is_empty());
assert!(finished.lane_closed);
}
#[test]
fn tombstone_eviction_releases_lane_budget_and_accepts_late_frames() {
let limits = WebLimitsConfig {
max_tombstones_per_session: 1,
..WebLimitsConfig::default()
};
let session = session_with_limits(limits);
{
let mut state = session.state.lock();
state.carrier_lanes.insert(7, CarrierLane::new());
let encoded = frame::encode(FrameType::Close, 7, &[]);
let cost = encoded.len() + QUEUE_ITEM_COST;
state.carrier_lanes.get_mut(&7).unwrap().pending_frames.push_back(
QueuedFrame {
encoded: BytesMut::from(encoded.as_ref()),
frame_type: FrameType::Close,
stream_id: 7,
control: true,
cost,
},
);
state.pending_bytes = cost;
state.pending_items = 1;
state.pending_control_bytes = cost;
state.pending_control_items = 1;
session.remember_closed_locked(&mut state, 7);
state.carrier_lanes.insert(8, CarrierLane::new());
session.remember_closed_locked(&mut state, 8);
assert!(!state.carrier_lanes.contains_key(&7));
assert_eq!(state.pending_bytes, 0);
assert_eq!(state.pending_items, 0);
}
let late = frame::encode(FrameType::Data, 7, b"late");
assert_eq!(session.process_up_lane(7, 7, &late), Ok(7));
assert!(!session.state.lock().closed);
}
+14 -14
View File
@@ -9,8 +9,9 @@ use subtle::ConstantTimeEq;
use super::{
InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionState, StreamState, WebSession,
inbound_queue_cost, remember_closed,
inbound_queue_cost,
};
use crate::config::WebCarrier;
use crate::web::frame::{self, Frame, FrameType};
use crate::web::manager::{ManagerError, TokenHash};
@@ -21,6 +22,9 @@ impl WebSession {
sequence: u64,
body: &[u8],
) -> Result<u64, ManagerError> {
if self.carrier() != WebCarrier::Https {
return Err(ManagerError::Protocol);
}
if self
.up_active
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -117,7 +121,7 @@ impl WebSession {
result
}
fn apply_batch_locked(
pub(super) fn apply_batch_locked(
&self,
state: &mut SessionState,
frames: &[Frame<'_>],
@@ -133,11 +137,7 @@ impl WebSession {
match value.frame_type {
FrameType::Open => {
let Some(peer_port) = self.reserve_stream_locked(state) else {
remember_closed(
state,
value.stream_id,
self.limits.max_tombstones_per_session,
);
self.remember_closed_locked(state, value.stream_id);
if !self.queue_control_locked(
state,
FrameType::Close,
@@ -195,11 +195,7 @@ impl WebSession {
};
let (bytes, items) = inbound_queue_cost(&stream.inbound);
self.release_locked(state, bytes, items, false);
remember_closed(
state,
value.stream_id,
self.limits.max_tombstones_per_session,
);
self.remember_closed_locked(state, value.stream_id);
if let Some(waker) = stream.read_waker {
waker.wake();
}
@@ -246,7 +242,7 @@ impl Drop for UplinkGuard<'_> {
}
}
fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool {
pub(super) fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool {
let mut live = state
.streams
.iter()
@@ -312,7 +308,10 @@ fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool {
true
}
fn inbound_reservation(state: &SessionState, frames: &[Frame<'_>]) -> (usize, usize) {
pub(super) fn inbound_reservation(
state: &SessionState,
frames: &[Frame<'_>],
) -> (usize, usize) {
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
let mut bytes = 0usize;
let mut items = 0usize;
@@ -350,6 +349,7 @@ mod tests {
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(),
secret_mode: WebSecretMode::Plain,
carrier: WebCarrier::Https,
capability: [0; 32],
max_sessions: 1,
max_streams: 1,