Bounded Debugging + Websocket Carriers + Carriers Negotiation

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-26 17:00:20 +03:00
parent 43cd84aaa5
commit 923c79796a
52 changed files with 3450 additions and 980 deletions
+10 -130
View File
@@ -3,19 +3,14 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
use super::state::{
Bootstrap, allow_rate, decrement_map, evict_oldest_unused_bootstrap, matching_profile,
new_unique_token, profile_key, remove_expired_locked,
};
use super::{
BootstrapResult, CreateResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime,
Bootstrap, allow_rate, evict_oldest_unused_bootstrap, matching_profile, new_unique_token,
remove_expired_locked,
};
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, WebProcessRuntime};
use crate::config::WebRuntimeProfile;
use crate::web::frame;
use crate::web::session::WebSession;
impl WebProcessRuntime {
@@ -78,6 +73,13 @@ impl WebProcessRuntime {
body_digest: [0; TOKEN_BYTES],
session_token: Zeroizing::new(String::new()),
session: None,
carrier_request: None,
carrier_candidates: Arc::from([]),
carrier_scores: [0; 4],
carrier_attempt: 0,
carrier_transitioning: false,
carrier_committed: false,
session_client_ip: None,
used: false,
},
);
@@ -117,128 +119,6 @@ impl WebProcessRuntime {
.map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile)))
}
/// Creates a session exactly once or replays the original successful result.
pub(crate) fn create_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
host: &str,
client_ip: IpAddr,
body: &[u8],
) -> std::result::Result<CreateResult, ManagerError> {
if !frame::validate_hello(body, &self.limits) {
return Err(ManagerError::Protocol);
}
let body_digest: TokenHash = Sha256::digest(body).into();
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
return Err(ManagerError::Authentication);
};
if entry.profile.host != host || now > entry.expires_at {
return Err(ManagerError::Authentication);
}
if entry.used {
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
if !digest_matches {
return Err(ManagerError::Authentication);
}
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
let result = CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
};
let identity = session.trace_identity();
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
crate::web::trace::TraceLifecycleEvent::SessionReplayed,
None,
None,
);
return Ok(result);
}
let trace_session_id = entry.trace_session_id;
let issued_profile = Arc::clone(&entry.profile);
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &issued_profile))
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if state.sessions.len() >= self.limits.max_sessions_global
|| state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
>= self.limits.max_sessions_per_ip
|| state
.sessions_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
>= profile.max_sessions
|| !allow_rate(
&mut state.session_rate,
now,
self.limits.new_sessions_per_minute,
self.limits.new_sessions_burst,
)
{
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
trace_session_id,
profile,
profile_key,
self.limits.clone(),
config.web.timeouts.clone(),
);
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.used = true;
entry.body_digest = body_digest;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
let issuance_ip = entry.issuance_ip;
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.sessions_created.fetch_add(1, Ordering::Relaxed);
let identity = session.trace_identity();
let result = CreateResult {
token: session_token,
carrier: session.carrier(),
};
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
crate::web::trace::TraceLifecycleEvent::SessionCreated,
None,
None,
);
Ok(result)
}
/// Resolves an authenticated session token.
pub(crate) fn get_session(
&self,
+346
View File
@@ -0,0 +1,346 @@
/// Telemt Carrier Selection and Failure Dampening - Copyright 2077
/// anhand des Kundenverhaltens Rückschlüsse gegen DSGVO ziehen...?!
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use super::negotiation::{CarrierClientClass, CarrierLearningContext};
use super::ProfileKey;
use crate::config::WebCarrier;
const PROFILE_WEIGHT: i16 = 4;
const USER_AGENT_WEIGHT: i16 = 4;
const IP_WEIGHT: i16 = 1;
const SCORE_MIN: i8 = -8;
const SCORE_MAX: i8 = 8;
const PROFILE_MIN_OUTCOMES: u8 = 8;
const PROFILE_MIN_COHORTS: usize = 4;
const COHORT_CONTEXT: &[u8] = b"telemt-web-carrier-cohort-v1\0";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum EvidenceKey {
Profile(ProfileKey),
UserAgent(ProfileKey, CarrierClientClass, [u8; 32]),
Ip(ProfileKey, IpAddr),
}
struct Evidence {
created_at: Instant,
lifetime: Duration,
scores: [i8; 4],
outcomes: u8,
cohorts: [Option<[u8; 32]>; PROFILE_MIN_COHORTS],
}
impl Evidence {
fn new(created_at: Instant, lifetime: Duration) -> Self {
Self {
created_at,
lifetime,
scores: [0; 4],
outcomes: 0,
cohorts: [None; PROFILE_MIN_COHORTS],
}
}
fn update(&mut self, carrier: WebCarrier, delta: i8, cohort: Option<[u8; 32]>) {
let score = &mut self.scores[carrier.index()];
*score = score.saturating_add(delta).clamp(SCORE_MIN, SCORE_MAX);
self.outcomes = self.outcomes.saturating_add(1).min(PROFILE_MIN_OUTCOMES);
if let Some(cohort) = cohort
&& !self.cohorts.contains(&Some(cohort))
&& let Some(slot) = self.cohorts.iter_mut().find(|slot| slot.is_none())
{
*slot = Some(cohort);
}
}
}
/// Process-local bounded fixed-window carrier evidence store.
pub(super) struct CarrierLearning {
entries: HashMap<EvidenceKey, Evidence>,
capacity: usize,
}
impl CarrierLearning {
/// Creates an empty store under the restart-owned capacity ceiling.
pub(super) fn new(capacity: usize) -> Self {
Self {
entries: HashMap::with_capacity(capacity),
capacity,
}
}
/// Ranks supported configured candidates using only unexpired evidence.
pub(super) fn rank(
&mut self,
now: Instant,
configured: &[WebCarrier],
request: super::CarrierRequest,
profile_key: ProfileKey,
client_ip: IpAddr,
) -> (Vec<WebCarrier>, [i16; 4]) {
self.prune(now);
let mut scores = [0i16; 4];
let profile = self.entries.get(&EvidenceKey::Profile(profile_key));
let profile_ready = profile.is_some_and(|entry| {
entry.outcomes >= PROFILE_MIN_OUTCOMES
&& entry.cohorts.iter().flatten().count() >= PROFILE_MIN_COHORTS
});
let user_agent = self.entries.get(&EvidenceKey::UserAgent(
profile_key,
request.class(),
request.user_agent_hash(),
));
let ip = self.entries.get(&EvidenceKey::Ip(profile_key, client_ip));
for carrier in WebCarrier::ALL {
let index = carrier.index();
if profile_ready {
scores[index] += i16::from(profile.map_or(0, |entry| entry.scores[index]))
* PROFILE_WEIGHT;
}
scores[index] += i16::from(user_agent.map_or(0, |entry| entry.scores[index]))
* USER_AGENT_WEIGHT;
scores[index] +=
i16::from(ip.map_or(0, |entry| entry.scores[index])) * IP_WEIGHT;
}
let mut ranked = configured
.iter()
.copied()
.filter(|carrier| request.supports(*carrier))
.collect::<Vec<_>>();
ranked.sort_by_key(|carrier| std::cmp::Reverse(scores[carrier.index()]));
(ranked, scores)
}
/// Records one committed success or one server-accepted supersession failure.
pub(super) fn record(
&mut self,
now: Instant,
lifetime: Duration,
context: CarrierLearningContext,
carrier: WebCarrier,
success: bool,
) {
self.prune(now);
let delta = if success { 1 } else { -1 };
let cohort = cohort_hash(context);
self.update(
EvidenceKey::Profile(context.profile_key),
now,
lifetime,
carrier,
delta,
Some(cohort),
);
self.update(
EvidenceKey::UserAgent(
context.profile_key,
context.class,
context.user_agent_hash,
),
now,
lifetime,
carrier,
delta,
None,
);
self.update(
EvidenceKey::Ip(context.profile_key, context.client_ip),
now,
lifetime,
carrier,
delta,
None,
);
}
/// Removes fixed-window entries after their creation-time expiry.
pub(super) fn prune(&mut self, now: Instant) {
self.entries.retain(|_, entry| {
now.saturating_duration_since(entry.created_at) <= entry.lifetime
});
}
fn update(
&mut self,
key: EvidenceKey,
now: Instant,
lifetime: Duration,
carrier: WebCarrier,
delta: i8,
cohort: Option<[u8; 32]>,
) {
if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
let oldest = self
.entries
.iter()
.min_by_key(|(_, entry)| entry.created_at)
.map(|(key, _)| *key);
if let Some(oldest) = oldest {
self.entries.remove(&oldest);
}
}
self.entries
.entry(key)
.or_insert_with(|| Evidence::new(now, lifetime))
.update(carrier, delta, cohort);
}
}
fn cohort_hash(context: CarrierLearningContext) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update(COHORT_CONTEXT);
digest.update(context.profile_key);
digest.update([match context.class {
CarrierClientClass::Legacy => 0,
CarrierClientClass::Bridge => 1,
CarrierClientClass::BrowserHint => 2,
}]);
digest.update(context.user_agent_hash);
match context.client_ip {
IpAddr::V4(address) => {
digest.update([4]);
digest.update(address.octets());
}
IpAddr::V6(address) => {
digest.update([6]);
digest.update(address.octets());
}
}
digest.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::web::manager::{CarrierCapabilities, CarrierRequest};
fn request(hash: u8) -> CarrierRequest {
CarrierRequest::automatic(
CarrierClientClass::Bridge,
CarrierCapabilities::all(),
1,
None,
[hash; 32],
)
}
#[test]
fn evidence_is_bounded_and_expires_without_sliding() {
let start = Instant::now();
let mut learning = CarrierLearning::new(3);
let context = CarrierLearningContext {
profile_key: [1; 32],
client_ip: "192.0.2.1".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [2; 32],
};
learning.record(
start,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
assert_eq!(learning.entries.len(), 3);
learning.record(
start + Duration::from_secs(5),
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
learning.prune(start + Duration::from_secs(11));
assert!(learning.entries.is_empty());
}
#[test]
fn user_agent_and_ip_evidence_rank_stably() {
let now = Instant::now();
let mut learning = CarrierLearning::new(16);
let context = CarrierLearningContext {
profile_key: [1; 32],
client_ip: "192.0.2.1".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [2; 32],
};
learning.record(
now,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
let (ranked, scores) = learning.rank(
now,
&[WebCarrier::Https, WebCarrier::Websocket],
request(2),
context.profile_key,
context.client_ip,
);
assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
assert_eq!(scores[WebCarrier::Websocket.index()], 5);
}
#[test]
fn profile_evidence_requires_outcome_and_cohort_thresholds() {
let now = Instant::now();
let profile_key = [1; 32];
let configured = [WebCarrier::Https, WebCarrier::Websocket];
let unrelated_ip = "198.51.100.10".parse().unwrap();
let mut learning = CarrierLearning::new(64);
for cohort in 1..=3u8 {
let context = CarrierLearningContext {
profile_key,
client_ip: IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, cohort)),
class: CarrierClientClass::Bridge,
user_agent_hash: [cohort; 32],
};
for _ in 0..2 {
learning.record(
now,
Duration::from_secs(10),
context,
WebCarrier::Websocket,
true,
);
}
}
let (ranked, _) = learning.rank(
now,
&configured,
request(99),
profile_key,
unrelated_ip,
);
assert_eq!(ranked, configured);
let fourth = CarrierLearningContext {
profile_key,
client_ip: "192.0.2.4".parse().unwrap(),
class: CarrierClientClass::Bridge,
user_agent_hash: [4; 32],
};
for _ in 0..2 {
learning.record(
now,
Duration::from_secs(10),
fourth,
WebCarrier::Websocket,
true,
);
}
let (ranked, scores) = learning.rank(
now,
&configured,
request(99),
profile_key,
unrelated_ip,
);
assert_eq!(ranked, [WebCarrier::Websocket, WebCarrier::Https]);
assert_eq!(scores[WebCarrier::Websocket.index()], 32);
}
}
+11 -21
View File
@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
use tracing::info;
use super::state::{ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked};
use super::state::{
decrement_map, remember_closed_token_locked, remove_bootstrap_locked, remove_expired_locked,
};
use super::{ProfileKey, TokenHash, WebProcessRuntime};
impl WebProcessRuntime {
@@ -22,33 +24,20 @@ impl WebProcessRuntime {
}
decrement_map(&mut state.sessions_per_ip, &client_ip);
decrement_map(&mut state.sessions_per_profile, &profile_key);
let expiry = Instant::now()
+ Duration::from_secs(
remember_closed_token_locked(
&mut state,
hash,
profile_host,
Duration::from_secs(
self.active_runtime
.load()
.config()
.web
.timeouts
.bootstrap_lifetime_secs,
);
state.closed_tokens.insert(
hash,
ClosedToken {
expires_at: expiry,
host: profile_host.to_string(),
},
),
self.limits.max_sessions_global.saturating_mul(16),
);
while state.closed_tokens.len() > self.limits.max_sessions_global.saturating_mul(16) {
let Some(oldest) = state
.closed_tokens
.iter()
.min_by_key(|(_, closed)| closed.expires_at)
.map(|(hash, _)| *hash)
else {
break;
};
state.closed_tokens.remove(&oldest);
}
let bootstrap_hashes = state
.bootstraps
.iter()
@@ -124,6 +113,7 @@ impl WebProcessRuntime {
pub(super) fn cleanup(&self) {
self.cleanup_websockets();
let now = Instant::now();
self.learning.lock().prune(now);
let sessions = {
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
+187
View File
@@ -0,0 +1,187 @@
use std::net::IpAddr;
use crate::config::WebCarrier;
/// Stable client classification used only for carrier negotiation and learning.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum CarrierClientClass {
/// A client that did not present server-bridge negotiation metadata.
Legacy,
/// The generated bridge presented the explicit capability marker.
Bridge,
/// Strict same-origin browser metadata survived while the marker did not.
BrowserHint,
}
impl CarrierClientClass {
/// Returns the non-sensitive token exposed by WEB debug lifecycle records.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Bridge => "bridge",
Self::BrowserHint => "browser-hint",
}
}
}
/// Canonical carrier failure category reported by the generated bridge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CarrierFailure {
/// The cumulative attempt deadline elapsed.
Timeout,
/// The browser observed a network failure.
Network,
/// A WebSocket upgrade or post-upgrade acknowledgement failed.
Upgrade,
/// The carrier returned an unexpected HTTP result.
Http,
/// The carrier violated its response or framing contract.
Protocol,
}
impl CarrierFailure {
/// Parses one canonical bridge failure token.
pub(crate) const fn parse(value: &str) -> Option<Self> {
match value.as_bytes() {
b"timeout" => Some(Self::Timeout),
b"network" => Some(Self::Network),
b"upgrade" => Some(Self::Upgrade),
b"http" => Some(Self::Http),
b"protocol" => Some(Self::Protocol),
_ => None,
}
}
/// Returns the canonical non-sensitive failure token.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Network => "network",
Self::Upgrade => "upgrade",
Self::Http => "http",
Self::Protocol => "protocol",
}
}
}
/// Fixed carrier capability set sent by the generated bridge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierCapabilities(u8);
impl CarrierCapabilities {
/// Returns a set containing every carrier implemented by the generated bridge.
pub(crate) const fn all() -> Self {
Self(0b1111)
}
/// Builds a set from a validated bit representation.
pub(crate) const fn from_bits(bits: u8) -> Option<Self> {
if bits != 0 && bits & !0b1111 == 0 {
Some(Self(bits))
} else {
None
}
}
/// Returns whether the bridge can run one carrier.
pub(crate) const fn contains(self, carrier: WebCarrier) -> bool {
self.0 & (1 << carrier.index()) != 0
}
}
/// Immutable metadata attached to one session-creation attempt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierRequest {
class: CarrierClientClass,
capabilities: Option<CarrierCapabilities>,
attempt: Option<u8>,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
}
impl CarrierRequest {
/// Constructs legacy metadata without enabling negotiation or learning.
pub(crate) const fn legacy(user_agent_hash: [u8; 32]) -> Self {
Self {
class: CarrierClientClass::Legacy,
capabilities: None,
attempt: None,
failure: None,
user_agent_hash,
}
}
/// Constructs validated automatic-negotiation metadata.
pub(crate) const fn automatic(
class: CarrierClientClass,
capabilities: CarrierCapabilities,
attempt: u8,
failure: Option<CarrierFailure>,
user_agent_hash: [u8; 32],
) -> Self {
Self {
class,
capabilities: Some(capabilities),
attempt: Some(attempt),
failure,
user_agent_hash,
}
}
/// Returns whether this request participates in server-side negotiation.
pub(crate) const fn is_automatic(self) -> bool {
self.capabilities.is_some()
}
/// Returns the canonical attempt number when negotiation is active.
pub(crate) const fn attempt(self) -> Option<u8> {
self.attempt
}
/// Returns the reported reason for advancing from the previous candidate.
pub(crate) const fn failure(self) -> Option<CarrierFailure> {
self.failure
}
/// Returns whether a carrier is supported by this request.
pub(crate) const fn supports(self, carrier: WebCarrier) -> bool {
match self.capabilities {
Some(capabilities) => capabilities.contains(carrier),
None => false,
}
}
/// Returns the stable non-sensitive client class.
pub(crate) const fn class(self) -> CarrierClientClass {
self.class
}
/// Returns the normalized User-Agent digest.
pub(crate) const fn user_agent_hash(self) -> [u8; 32] {
self.user_agent_hash
}
/// Checks the capability identity frozen across sequential attempts.
pub(crate) fn matches_client(self, other: Self) -> bool {
self.class == other.class
&& self.capabilities_bits() == other.capabilities_bits()
&& self.user_agent_hash == other.user_agent_hash
}
fn capabilities_bits(self) -> Option<u8> {
self.capabilities.map(|capabilities| capabilities.0)
}
}
/// Secret-independent evidence owner frozen into an automatic session.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CarrierLearningContext {
/// Stable profile namespace.
pub(crate) profile_key: super::ProfileKey,
/// Effective client address from the trusted L7 boundary.
pub(crate) client_ip: IpAddr,
/// Client-class namespace for normalized User-Agent evidence.
pub(crate) class: CarrierClientClass,
/// Domain-separated normalized User-Agent digest.
pub(crate) user_agent_hash: [u8; 32],
}
+505
View File
@@ -0,0 +1,505 @@
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
use super::state::{
ManagerState, allow_rate, decrement_map, matching_profile, new_unique_token, profile_key,
remember_closed_token_locked, remove_expired_locked,
};
use super::{
CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash,
WebProcessRuntime,
};
use crate::config::{WebCarrier, WebRuntimeProfile, WebTimeoutsConfig};
use crate::web::frame;
use crate::web::session::WebSession;
use crate::web::trace::{TraceIdentity, TraceLifecycleEvent};
struct Replacement {
old_session: Arc<WebSession>,
profile: Arc<WebRuntimeProfile>,
profile_key: super::ProfileKey,
trace_session_id: u64,
attempt: u8,
carrier: WebCarrier,
request: CarrierRequest,
scores: [i16; 4],
}
impl WebProcessRuntime {
/// Creates, replays, or atomically supersedes one pre-commit carrier session.
pub(crate) fn create_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
host: &str,
client_ip: IpAddr,
body: &[u8],
carrier_request: CarrierRequest,
) -> std::result::Result<CreateResult, ManagerError> {
if !frame::validate_hello(body, &self.limits) {
return Err(ManagerError::Protocol);
}
let body_digest: TokenHash = Sha256::digest(body).into();
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
return Err(ManagerError::Authentication);
};
if entry.profile.host != host || now > entry.expires_at {
return Err(ManagerError::Authentication);
}
if entry.used {
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
let client_matches = entry.session_client_ip == Some(client_ip);
let request_matches = entry
.carrier_request
.is_some_and(|current| current.matches_client(carrier_request));
if !digest_matches || !client_matches || !request_matches {
return Err(ManagerError::Authentication);
}
if entry.carrier_transitioning {
return Err(ManagerError::Concurrent);
}
if carrier_request.attempt() == Some(entry.carrier_attempt)
|| (!carrier_request.is_automatic() && entry.carrier_attempt == 1)
{
let session = entry.session.as_ref().ok_or(ManagerError::Authentication)?;
let result = CreateResult {
token: entry.session_token.as_str().to_owned(),
carrier: session.carrier(),
attempt: carrier_request.attempt(),
};
let identity = session.trace_identity();
drop(state);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionReplayed,
None,
None,
);
return Ok(result);
}
let next_attempt = entry.carrier_attempt.saturating_add(1);
if !carrier_request.is_automatic()
|| carrier_request.attempt() != Some(next_attempt)
|| entry.carrier_committed
{
return Err(ManagerError::Protocol);
}
let Some(carrier) = entry
.carrier_candidates
.get(usize::from(next_attempt - 1))
.copied()
else {
return Err(ManagerError::Protocol);
};
let old_session = entry.session.clone().ok_or(ManagerError::Authentication)?;
let replacement = Replacement {
profile: Arc::clone(&entry.profile),
profile_key: old_session.profile_key(),
trace_session_id: entry.trace_session_id,
old_session,
attempt: next_attempt,
carrier,
request: carrier_request,
scores: entry.carrier_scores,
};
state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?
.carrier_transitioning = true;
drop(state);
return self.replace_session(
bootstrap_hash,
client_ip,
replacement,
&config.web.timeouts,
);
}
if (carrier_request.is_automatic() && carrier_request.attempt() != Some(1))
|| (!carrier_request.is_automatic() && carrier_request.attempt().is_some())
{
return Err(ManagerError::Protocol);
}
let trace_session_id = entry.trace_session_id;
let issued_profile = Arc::clone(&entry.profile);
if state.closed || !config.web.enabled {
return Err(ManagerError::Closed);
}
let profile = config
.web
.runtime
.as_ref()
.and_then(|runtime| matching_profile(runtime, &issued_profile))
.filter(|profile| generation.proxy_shared.is_user_enabled(&profile.user))
.ok_or(ManagerError::Authentication)?;
let profile_key = profile_key(&profile);
if carrier_request.is_automatic() && !profile.carrier_negotiation_enabled {
return Err(ManagerError::Protocol);
}
let (candidates, scores) = if carrier_request.is_automatic() && profile.carrier_learning {
self.learning.lock().rank(
now,
&profile.carriers,
carrier_request,
profile_key,
client_ip,
)
} else if carrier_request.is_automatic() {
(
profile
.carriers
.iter()
.copied()
.filter(|carrier| carrier_request.supports(*carrier))
.collect(),
[0; 4],
)
} else {
(vec![profile.carrier], [0; 4])
};
let Some(carrier) = candidates.first().copied() else {
return Err(ManagerError::Protocol);
};
if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) {
return Err(ManagerError::Limit);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
return Err(ManagerError::Limit);
};
let learning_context = (carrier_request.is_automatic() && profile.carrier_learning)
.then_some(CarrierLearningContext {
profile_key,
client_ip,
class: carrier_request.class(),
user_agent_hash: carrier_request.user_agent_hash(),
});
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
trace_session_id,
Arc::clone(&profile),
profile_key,
carrier,
1,
bootstrap_hash,
learning_context,
self.limits.clone(),
config.web.timeouts.clone(),
);
state.sessions.insert(session_hash, Arc::clone(&session));
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.used = true;
entry.body_digest = body_digest;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
entry.carrier_request = Some(carrier_request);
entry.carrier_candidates = candidates.into();
entry.carrier_scores = scores;
entry.carrier_attempt = 1;
entry.session_client_ip = Some(client_ip);
let issuance_ip = entry.issuance_ip;
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
self.sessions_created.fetch_add(1, Ordering::Relaxed);
let identity = session.trace_identity();
let result = CreateResult {
token: session_token,
carrier,
attempt: carrier_request.attempt(),
};
drop(state);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierClassified,
carrier_request.class().as_str(),
carrier,
1,
scores,
None,
);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierSelected,
carrier_request.class().as_str(),
carrier,
1,
scores,
None,
);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionCreated,
None,
None,
);
Ok(result)
}
fn replace_session(
self: &Arc<Self>,
bootstrap_hash: TokenHash,
client_ip: IpAddr,
replacement: Replacement,
timeouts: &WebTimeoutsConfig,
) -> std::result::Result<CreateResult, ManagerError> {
if !replacement.old_session.begin_carrier_supersede() {
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Protocol);
}
let generation = self.active_generation();
let config = generation.config();
let now = Instant::now();
let mut state = self.state.lock();
remove_expired_locked(&mut state, now);
let valid = state.bootstraps.get(&bootstrap_hash).is_some_and(|entry| {
entry.carrier_transitioning
&& entry.carrier_attempt.saturating_add(1) == replacement.attempt
&& entry
.session
.as_ref()
.is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session))
}) && state
.sessions
.get(&replacement.old_session.token_hash())
.is_some_and(|session| Arc::ptr_eq(session, &replacement.old_session));
if !valid
|| state.closed
|| !config.web.enabled
|| !generation
.proxy_shared
.is_user_enabled(&replacement.profile.user)
{
drop(state);
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Closed);
}
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
self.limit_hits.fetch_add(1, Ordering::Relaxed);
drop(state);
self.cancel_replacement(bootstrap_hash, &replacement.old_session);
return Err(ManagerError::Limit);
};
let learning_context = replacement.profile.carrier_learning.then_some(
CarrierLearningContext {
profile_key: replacement.profile_key,
client_ip,
class: replacement.request.class(),
user_agent_hash: replacement.request.user_agent_hash(),
},
);
let session = WebSession::new(
Arc::downgrade(self),
session_hash,
client_ip,
replacement.trace_session_id,
Arc::clone(&replacement.profile),
replacement.profile_key,
replacement.carrier,
replacement.attempt,
bootstrap_hash,
learning_context,
self.limits.clone(),
timeouts.clone(),
);
let old_hash = replacement.old_session.token_hash();
state.sessions.remove(&old_hash);
remember_closed_token_locked(
&mut state,
old_hash,
&replacement.profile.host,
Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
self.limits.max_sessions_global.saturating_mul(16),
);
state.sessions.insert(session_hash, Arc::clone(&session));
let entry = state
.bootstraps
.get_mut(&bootstrap_hash)
.ok_or(ManagerError::Authentication)?;
entry.session_token = Zeroizing::new(session_token.clone());
entry.session = Some(Arc::clone(&session));
entry.carrier_request = Some(replacement.request);
entry.carrier_attempt = replacement.attempt;
entry.carrier_transitioning = false;
entry.carrier_committed = false;
self.sessions_created.fetch_add(1, Ordering::Relaxed);
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
let result = CreateResult {
token: session_token,
carrier: replacement.carrier,
attempt: Some(replacement.attempt),
};
let identity = session.trace_identity();
let old_identity = replacement.old_session.trace_identity();
drop(state);
replacement.old_session.finish_carrier_supersede();
if let Some(context) = learning_context {
self.record_carrier_outcome(context, replacement.old_session.carrier(), false);
}
self.trace.record_carrier_lifecycle(
client_ip,
old_identity.clone(),
TraceLifecycleEvent::CarrierFailed,
replacement.request.class().as_str(),
replacement.old_session.carrier(),
replacement.attempt - 1,
replacement.scores,
replacement.request.failure().map(|failure| failure.as_str()),
);
self.trace.record_carrier_lifecycle(
client_ip,
old_identity,
TraceLifecycleEvent::CarrierSuperseded,
replacement.request.class().as_str(),
replacement.old_session.carrier(),
replacement.attempt - 1,
replacement.scores,
replacement.request.failure().map(|failure| failure.as_str()),
);
self.trace.record_carrier_lifecycle(
client_ip,
identity.clone(),
TraceLifecycleEvent::CarrierSelected,
replacement.request.class().as_str(),
replacement.carrier,
replacement.attempt,
replacement.scores,
None,
);
self.trace.record_lifecycle(
None,
Some(client_ip),
identity,
TraceLifecycleEvent::SessionCreated,
None,
replacement.request.failure().map(|failure| failure.as_str()),
);
Ok(result)
}
fn cancel_replacement(&self, bootstrap_hash: TokenHash, old_session: &Arc<WebSession>) {
old_session.cancel_carrier_supersede();
let mut state = self.state.lock();
if let Some(entry) = state.bootstraps.get_mut(&bootstrap_hash)
&& entry
.session
.as_ref()
.is_some_and(|session| Arc::ptr_eq(session, old_session))
{
entry.carrier_transitioning = false;
}
}
/// Commits learning only after one accepted OPEN or DATA batch.
#[allow(clippy::too_many_arguments)]
pub(crate) fn carrier_committed(
&self,
bootstrap_hash: TokenHash,
session_hash: TokenHash,
attempt: u8,
carrier: WebCarrier,
learning_context: Option<CarrierLearningContext>,
client_ip: IpAddr,
identity: TraceIdentity,
) {
let mut state = self.state.lock();
let scores = state.bootstraps.get_mut(&bootstrap_hash).and_then(|entry| {
if entry.carrier_attempt == attempt
&& entry
.session
.as_ref()
.is_some_and(|session| session.token_hash() == session_hash)
{
entry.carrier_committed = true;
Some(entry.carrier_scores)
} else {
None
}
});
drop(state);
let Some(scores) = scores else { return };
if let Some(context) = learning_context {
self.record_carrier_outcome(context, carrier, true);
}
self.trace.record_carrier_lifecycle(
client_ip,
identity,
TraceLifecycleEvent::CarrierCommitted,
learning_context
.map_or("legacy", |context| context.class.as_str()),
carrier,
attempt,
scores,
None,
);
}
fn record_carrier_outcome(
&self,
context: CarrierLearningContext,
carrier: WebCarrier,
success: bool,
) {
let generation = self.active_generation();
if !generation.config().web.carrier_learning {
return;
}
let lifetime = Duration::from_secs(
generation.config().web.timeouts.carrier_learning_secs,
);
self.learning
.lock()
.record(Instant::now(), lifetime, context, carrier, success);
}
}
fn admit_initial(
runtime: &WebProcessRuntime,
state: &mut ManagerState,
now: Instant,
client_ip: IpAddr,
profile_key: super::ProfileKey,
profile: &WebRuntimeProfile,
) -> bool {
let admitted = state.sessions.len() < runtime.limits.max_sessions_global
&& state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
< runtime.limits.max_sessions_per_ip
&& state
.sessions_per_profile
.get(&profile_key)
.copied()
.unwrap_or(0)
< profile.max_sessions
&& allow_rate(
&mut state.session_rate,
now,
runtime.limits.new_sessions_per_minute,
runtime.limits.new_sessions_burst,
);
if !admitted {
runtime.limit_hits.fetch_add(1, Ordering::Relaxed);
}
admitted
}
+50 -3
View File
@@ -1,14 +1,14 @@
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use base64::Engine as _;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use super::{ProfileKey, TOKEN_BYTES, TokenHash};
use crate::config::{WebRuntimeConfig, WebRuntimeProfile};
use super::{CarrierRequest, ProfileKey, TOKEN_BYTES, TokenHash};
use crate::config::{WebCarrier, WebRuntimeConfig, WebRuntimeProfile};
use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::WebSession;
@@ -32,6 +32,20 @@ pub(super) struct Bootstrap {
pub(super) session_token: Zeroizing<String>,
/// Created session retained while retry replay remains valid.
pub(super) session: Option<Arc<WebSession>>,
/// Metadata that defines exact attempt replay and candidate advancement.
pub(super) carrier_request: Option<CarrierRequest>,
/// Learning-ranked carrier order frozen by the first automatic attempt.
pub(super) carrier_candidates: Arc<[WebCarrier]>,
/// Weighted learning scores captured when the candidate order was frozen.
pub(super) carrier_scores: [i16; 4],
/// Current one-based carrier attempt, or zero before session creation.
pub(super) carrier_attempt: u8,
/// Prevents concurrent retries from replacing the same attempt twice.
pub(super) carrier_transitioning: bool,
/// Records the first accepted OPEN or DATA transition exactly once.
pub(super) carrier_committed: bool,
/// Effective address frozen by the first session-creation request.
pub(super) session_client_ip: Option<IpAddr>,
/// Distinguishes unused issuance quota from completed creation replay state.
pub(super) used: bool,
}
@@ -135,6 +149,11 @@ pub(super) fn matching_profile(
&& profile.user == expected.user
&& profile.secret_mode == expected.secret_mode
&& profile.carrier == expected.carrier
&& profile.carrier_negotiation_enabled == expected.carrier_negotiation_enabled
&& profile.carrier_learning == expected.carrier_learning
&& profile.carriers == expected.carriers
&& profile.carrier_negotiation_deadlines_secs
== expected.carrier_negotiation_deadlines_secs
&& profile.capability == expected.capability
&& profile.key_fingerprint == expected.key_fingerprint
})
@@ -198,6 +217,34 @@ pub(super) fn remove_bootstrap_locked(state: &mut ManagerState, hash: TokenHash)
}
}
/// Retains one bounded host-bound marker for an invalidated session credential.
pub(super) fn remember_closed_token_locked(
state: &mut ManagerState,
hash: TokenHash,
host: &str,
lifetime: Duration,
capacity: usize,
) {
state.closed_tokens.insert(
hash,
ClosedToken {
expires_at: Instant::now() + lifetime,
host: host.to_string(),
},
);
while state.closed_tokens.len() > capacity {
let Some(oldest) = state
.closed_tokens
.iter()
.min_by_key(|(_, closed)| closed.expires_at)
.map(|(hash, _)| *hash)
else {
break;
};
state.closed_tokens.remove(&oldest);
}
}
/// Decrements one counted owner and removes its map entry at zero.
pub(super) fn decrement_map<K, Q>(values: &mut HashMap<K, usize>, key: &Q)
where
+1
View File
@@ -98,6 +98,7 @@ impl Drop for WebSocketConnection {
}
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn admit(
runtime: &Arc<WebProcessRuntime>,
owner: ProfileKey,