mirror of
https://github.com/telemt/telemt.git
synced 2026-09-12 05:24:08 +03:00
WEB Debug + Trace
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
+147
-163
@@ -6,8 +6,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::combinators::UnsyncBoxBody;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
@@ -18,7 +17,7 @@ use parking_lot::Mutex;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::{WebCarrier, WebClientIpSource, WebRuntimeVhost};
|
||||
use crate::config::{WebClientIpSource, WebRuntimeVhost};
|
||||
use crate::web::bridge;
|
||||
use crate::web::frame::{self, FrameType};
|
||||
use crate::web::manager::{ManagerError, WebProcessRuntime};
|
||||
@@ -29,18 +28,34 @@ mod activity;
|
||||
mod body;
|
||||
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
||||
mod decoy;
|
||||
// Downlink long-poll handling remains isolated from request routing.
|
||||
mod down;
|
||||
// Canonical request parsing rejects ambiguous credentials before routing.
|
||||
mod request;
|
||||
// Carrier response construction and lane-header helpers are shared by handlers.
|
||||
mod response;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
// Enabled-debug integration coverage remains separate from carrier behavior tests.
|
||||
#[cfg(test)]
|
||||
#[path = "http/trace_tests.rs"]
|
||||
mod trace_tests;
|
||||
|
||||
use activity::{ActivityBody, RequestActivity};
|
||||
use body::{CollectBodyError, CollectedBody, collect_body};
|
||||
use body::{CollectBodyError, CollectedBody, RequestBody, collect_body};
|
||||
use decoy::serve_decoy;
|
||||
use down::handle_down;
|
||||
use request::{
|
||||
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
|
||||
canonical_u64_header, client_ip, compatible_cookie_header, match_profile,
|
||||
};
|
||||
use response::{
|
||||
bad_gateway, carrier_empty, carrier_headers, carrier_lane, full_response, generic_not_found,
|
||||
insert_header, service_unavailable,
|
||||
};
|
||||
use crate::web::trace::{
|
||||
HttpTraceExchange, TraceDirection, TraceLifecycleEvent, TraceRoute,
|
||||
};
|
||||
|
||||
type BoxError = Box<dyn Error + Send + Sync>;
|
||||
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
|
||||
@@ -65,13 +80,18 @@ pub(crate) async fn serve_connection(
|
||||
let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs);
|
||||
let last_activity = Arc::new(Mutex::new(Instant::now()));
|
||||
let service_last_activity = Arc::clone(&last_activity);
|
||||
let service = service_fn(move |request| {
|
||||
let service = service_fn(move |mut request| {
|
||||
let runtime = Arc::clone(&runtime);
|
||||
let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs);
|
||||
let last_activity = Arc::clone(&service_last_activity);
|
||||
let client_ip_source = client_ip_source;
|
||||
async move {
|
||||
let activity = RequestActivity::begin(last_activity);
|
||||
let trace = runtime.trace().begin_http(&request, peer.ip());
|
||||
if let Some(trace) = &trace {
|
||||
request.extensions_mut().insert(Arc::clone(trace));
|
||||
}
|
||||
let request = request.map(|body| RequestBody::new(body, trace.clone()));
|
||||
let response = if let Some(_handler_permit) = runtime.try_http_handler() {
|
||||
handle_request(
|
||||
request,
|
||||
@@ -84,7 +104,11 @@ pub(crate) async fn serve_connection(
|
||||
} else {
|
||||
service_unavailable()
|
||||
};
|
||||
let response = response.map(|body| ActivityBody::new(body, activity).boxed_unsync());
|
||||
if let Some(trace) = &trace {
|
||||
trace.response_ready(&response);
|
||||
}
|
||||
let response = response
|
||||
.map(|body| ActivityBody::new(body, activity, trace).boxed_unsync());
|
||||
Ok::<_, Infallible>(response)
|
||||
}
|
||||
});
|
||||
@@ -115,12 +139,18 @@ pub(crate) async fn serve_connection(
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
request: Request<Incoming>,
|
||||
request: Request<RequestBody>,
|
||||
peer: SocketAddr,
|
||||
client_ip_source: WebClientIpSource,
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
) -> HttpResponse {
|
||||
set_trace_route(&request, TraceRoute::Decoy);
|
||||
if let Some(trace) = request_trace(&request)
|
||||
&& let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs)
|
||||
{
|
||||
trace.set_effective_ip(client_ip);
|
||||
}
|
||||
let generation = runtime.active_generation();
|
||||
let config = generation.config();
|
||||
let Some(web_runtime) = config.web.runtime.as_ref() else {
|
||||
@@ -159,7 +189,7 @@ async fn handle_request(
|
||||
}
|
||||
|
||||
async fn handle_root(
|
||||
mut request: Request<Incoming>,
|
||||
mut request: Request<RequestBody>,
|
||||
peer: SocketAddr,
|
||||
client_ip_source: WebClientIpSource,
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
@@ -175,15 +205,34 @@ async fn handle_root(
|
||||
strip_query(&mut request);
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.set_route(TraceRoute::Bridge);
|
||||
trace.set_effective_ip(client_ip);
|
||||
}
|
||||
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 bootstrap = match runtime.issue_bootstrap(Arc::clone(&profile), client_ip) {
|
||||
Ok(bootstrap) => bootstrap,
|
||||
Err(error) => {
|
||||
runtime.trace().record_profile_lifecycle(
|
||||
client_ip,
|
||||
None,
|
||||
&profile,
|
||||
TraceLifecycleEvent::BootstrapRejected,
|
||||
None,
|
||||
Some(manager_error_reason(error)),
|
||||
);
|
||||
strip_query(&mut request);
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.bind_profile(&profile, bootstrap.trace_session_id);
|
||||
trace.register_redaction(bootstrap.token.as_bytes());
|
||||
}
|
||||
let generation = runtime.active_generation();
|
||||
let page = bridge::render(
|
||||
&vhost.host,
|
||||
&bootstrap,
|
||||
&bootstrap.token,
|
||||
generation.config().web.limits.carrier_batch_bytes,
|
||||
generation.config().web.limits.pending_bytes_per_session,
|
||||
generation.config().web.limits.pending_items_per_session,
|
||||
@@ -224,7 +273,7 @@ async fn handle_root(
|
||||
}
|
||||
|
||||
async fn handle_api(
|
||||
request: Request<Incoming>,
|
||||
request: Request<RequestBody>,
|
||||
peer: SocketAddr,
|
||||
client_ip_source: WebClientIpSource,
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
@@ -237,6 +286,9 @@ async fn handle_api(
|
||||
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.set_effective_ip(client_ip);
|
||||
}
|
||||
let Some(token_hash) = bearer_token_hash(&request) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
@@ -249,7 +301,7 @@ async fn handle_api(
|
||||
}
|
||||
|
||||
async fn handle_session(
|
||||
request: Request<Incoming>,
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
token_hash: crate::web::manager::TokenHash,
|
||||
@@ -262,6 +314,12 @@ async fn handle_session(
|
||||
if request.headers().contains_key(header::CONTENT_TYPE) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
if let Some(trace) = request_trace(&request)
|
||||
&& let Ok(session) = runtime.get_session(token_hash, &vhost.host)
|
||||
{
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_identity(session.trace_identity());
|
||||
}
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
@@ -281,8 +339,14 @@ async fn handle_session(
|
||||
if request.method() != Method::POST || !binary_content_type(&request) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
if !runtime.has_bootstrap(token_hash, &vhost.host) {
|
||||
let Some((trace_session_id, profile)) =
|
||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_profile(&profile, trace_session_id);
|
||||
}
|
||||
let CollectedBody {
|
||||
request,
|
||||
@@ -295,9 +359,24 @@ async fn handle_session(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.record_frames(
|
||||
TraceDirection::Request,
|
||||
&body,
|
||||
&runtime.active_generation().config().web.limits,
|
||||
);
|
||||
}
|
||||
match runtime.create_session(token_hash, &vhost.host, client_ip, &body) {
|
||||
Ok(result) => {
|
||||
let welcome = frame::encode(FrameType::Welcome, 0, &[]);
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.register_redaction(result.token.as_bytes());
|
||||
trace.record_frames(
|
||||
TraceDirection::Response,
|
||||
&welcome,
|
||||
&runtime.active_generation().config().web.limits,
|
||||
);
|
||||
}
|
||||
let mut response = full_response(StatusCode::OK, welcome);
|
||||
carrier_headers(&mut response);
|
||||
insert_header(
|
||||
@@ -315,15 +394,33 @@ async fn handle_session(
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent) => {
|
||||
Err(error @ (ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent)) => {
|
||||
runtime.trace().record_profile_lifecycle(
|
||||
client_ip,
|
||||
Some(trace_session_id),
|
||||
&profile,
|
||||
TraceLifecycleEvent::SessionRejected,
|
||||
None,
|
||||
Some(manager_error_reason(error)),
|
||||
);
|
||||
service_unavailable()
|
||||
}
|
||||
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||
Err(error) => {
|
||||
runtime.trace().record_profile_lifecycle(
|
||||
client_ip,
|
||||
Some(trace_session_id),
|
||||
&profile,
|
||||
TraceLifecycleEvent::SessionRejected,
|
||||
None,
|
||||
Some(manager_error_reason(error)),
|
||||
);
|
||||
serve_decoy(request, vhost, true, &runtime).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_up(
|
||||
request: Request<Incoming>,
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
token_hash: crate::web::manager::TokenHash,
|
||||
@@ -338,6 +435,10 @@ async fn handle_up(
|
||||
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.set_route(TraceRoute::Uplink);
|
||||
trace.bind_identity(session.trace_identity());
|
||||
}
|
||||
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
@@ -358,6 +459,13 @@ async fn handle_up(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.record_frames(
|
||||
TraceDirection::Request,
|
||||
&body,
|
||||
&runtime.active_generation().config().web.limits,
|
||||
);
|
||||
}
|
||||
let result = match lane_id {
|
||||
Some(lane_id) => session.process_up_lane(lane_id, sequence, &body),
|
||||
None => session.process_up(sequence, &body),
|
||||
@@ -379,151 +487,6 @@ async fn handle_up(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_down(
|
||||
request: Request<Incoming>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
token_hash: crate::web::manager::TokenHash,
|
||||
) -> HttpResponse {
|
||||
if request.method() != Method::POST || request.headers().contains_key(header::CONTENT_TYPE) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
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,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if !body.is_empty() {
|
||||
return serve_decoy(request, vhost, true, &runtime).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(
|
||||
&mut response,
|
||||
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) => {
|
||||
let mut response = full_response(StatusCode::OK, result.body);
|
||||
carrier_headers(&mut response);
|
||||
insert_header(
|
||||
&mut response,
|
||||
HeaderName::from_static("x-down-cursor"),
|
||||
&result.next_cursor.to_string(),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(ManagerError::Concurrent | ManagerError::Backpressure | ManagerError::Limit) => {
|
||||
service_unavailable()
|
||||
}
|
||||
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
fn carrier_empty(status: StatusCode) -> HttpResponse {
|
||||
let mut response = empty_response(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
fn service_unavailable() -> HttpResponse {
|
||||
let mut response = carrier_empty(StatusCode::SERVICE_UNAVAILABLE);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
|
||||
response
|
||||
}
|
||||
|
||||
fn bad_gateway() -> HttpResponse {
|
||||
full_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Bytes::from_static(b"site unavailable\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn generic_not_found() -> HttpResponse {
|
||||
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
||||
}
|
||||
|
||||
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||
let length = body.len();
|
||||
let body = Full::new(body)
|
||||
.map_err(|never| -> BoxError { match never {} })
|
||||
.boxed_unsync();
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
insert_header(&mut response, header::CONTENT_LENGTH, &length.to_string());
|
||||
response
|
||||
}
|
||||
|
||||
fn empty_response(status: StatusCode) -> HttpResponse {
|
||||
full_response(status, Bytes::new())
|
||||
}
|
||||
|
||||
fn insert_header(response: &mut HttpResponse, name: HeaderName, value: &str) {
|
||||
if let Ok(value) = HeaderValue::from_str(value) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_query<B>(request: &mut Request<B>) {
|
||||
if request.uri().query().is_some()
|
||||
&& let Ok(uri) = request.uri().path().parse()
|
||||
@@ -531,3 +494,24 @@ fn strip_query<B>(request: &mut Request<B>) {
|
||||
*request.uri_mut() = uri;
|
||||
}
|
||||
}
|
||||
|
||||
fn request_trace<B>(request: &Request<B>) -> Option<&Arc<HttpTraceExchange>> {
|
||||
request.extensions().get::<Arc<HttpTraceExchange>>()
|
||||
}
|
||||
|
||||
fn set_trace_route<B>(request: &Request<B>, route: TraceRoute) {
|
||||
if let Some(trace) = request_trace(request) {
|
||||
trace.set_route(route);
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_error_reason(error: ManagerError) -> &'static str {
|
||||
match error {
|
||||
ManagerError::Authentication => "authentication",
|
||||
ManagerError::Backpressure => "backpressure",
|
||||
ManagerError::Limit => "limit",
|
||||
ManagerError::Protocol => "protocol",
|
||||
ManagerError::Concurrent => "concurrent",
|
||||
ManagerError::Closed => "closed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use hyper::body::{Body, Frame, SizeHint};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::{BoxError, HttpBody};
|
||||
use crate::web::trace::{HttpTraceExchange, TraceBodyState, TraceDirection};
|
||||
|
||||
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
|
||||
pub(super) struct RequestActivity {
|
||||
@@ -32,12 +33,33 @@ impl Drop for RequestActivity {
|
||||
pub(super) struct ActivityBody {
|
||||
inner: HttpBody,
|
||||
activity: RequestActivity,
|
||||
trace: Option<Arc<HttpTraceExchange>>,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
impl ActivityBody {
|
||||
/// Binds one response body to its request activity guard.
|
||||
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
|
||||
Self { inner, activity }
|
||||
pub(super) fn new(
|
||||
inner: HttpBody,
|
||||
activity: RequestActivity,
|
||||
trace: Option<Arc<HttpTraceExchange>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
activity,
|
||||
trace,
|
||||
terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, state: TraceBodyState) {
|
||||
if self.terminal {
|
||||
return;
|
||||
}
|
||||
self.terminal = true;
|
||||
if let Some(trace) = &self.trace {
|
||||
trace.body_finished(TraceDirection::Response, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +72,21 @@ impl Body for ActivityBody {
|
||||
context: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||
let result = Pin::new(&mut self.inner).poll_frame(context);
|
||||
match &result {
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
if let Some(data) = frame.data_ref()
|
||||
&& let Some(trace) = &self.trace
|
||||
{
|
||||
trace.body_data(TraceDirection::Response, data);
|
||||
}
|
||||
if self.inner.is_end_stream() {
|
||||
self.finish(TraceBodyState::Complete);
|
||||
}
|
||||
}
|
||||
Poll::Ready(Some(Err(_))) => self.finish(TraceBodyState::Error),
|
||||
Poll::Ready(None) => self.finish(TraceBodyState::Complete),
|
||||
Poll::Pending => {}
|
||||
}
|
||||
if result.is_ready() {
|
||||
*self.activity.last_activity.lock() = Instant::now();
|
||||
}
|
||||
@@ -64,3 +101,9 @@ impl Body for ActivityBody {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ActivityBody {
|
||||
fn drop(&mut self) {
|
||||
self.finish(TraceBodyState::Aborted);
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -1,11 +1,87 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Empty, Limited};
|
||||
use hyper::Request;
|
||||
use hyper::body::{Body as _, Incoming};
|
||||
use hyper::body::{Body, Frame, Incoming, SizeHint};
|
||||
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
use crate::web::trace::{
|
||||
HttpTraceExchange, TraceBodyState, TraceDirection,
|
||||
};
|
||||
|
||||
/// Incoming request body wrapper that observes frames without changing streaming semantics.
|
||||
pub(super) struct RequestBody {
|
||||
inner: Incoming,
|
||||
trace: Option<Arc<HttpTraceExchange>>,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
impl RequestBody {
|
||||
/// Wraps one Hyper request body with optional enabled-only capture state.
|
||||
pub(super) fn new(inner: Incoming, trace: Option<Arc<HttpTraceExchange>>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
trace,
|
||||
terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, state: TraceBodyState) {
|
||||
if self.terminal {
|
||||
return;
|
||||
}
|
||||
self.terminal = true;
|
||||
if let Some(trace) = &self.trace {
|
||||
trace.body_finished(TraceDirection::Request, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Body for RequestBody {
|
||||
type Data = Bytes;
|
||||
type Error = hyper::Error;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
context: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||
let result = Pin::new(&mut self.inner).poll_frame(context);
|
||||
match &result {
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
if let Some(data) = frame.data_ref()
|
||||
&& let Some(trace) = &self.trace
|
||||
{
|
||||
trace.body_data(TraceDirection::Request, data);
|
||||
}
|
||||
if self.inner.is_end_stream() {
|
||||
self.finish(TraceBodyState::Complete);
|
||||
}
|
||||
}
|
||||
Poll::Ready(Some(Err(_))) => self.finish(TraceBodyState::Error),
|
||||
Poll::Ready(None) => self.finish(TraceBodyState::Complete),
|
||||
Poll::Pending => {}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.inner.is_end_stream()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RequestBody {
|
||||
fn drop(&mut self) {
|
||||
self.finish(TraceBodyState::Aborted);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collected carrier request retaining its process-wide body reservation.
|
||||
pub(super) struct CollectedBody {
|
||||
@@ -29,7 +105,7 @@ pub(super) enum CollectBodyError {
|
||||
|
||||
/// Collects one bounded carrier body under reader, byte, and deadline ownership.
|
||||
pub(super) async fn collect_body(
|
||||
request: Request<Incoming>,
|
||||
request: Request<RequestBody>,
|
||||
runtime: &WebProcessRuntime,
|
||||
limit: usize,
|
||||
allow_empty: bool,
|
||||
|
||||
@@ -28,6 +28,7 @@ where
|
||||
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
||||
B::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
super::set_trace_route(&request, crate::web::trace::TraceRoute::Decoy);
|
||||
if sanitize_transport {
|
||||
sanitize_transport_request(&mut request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Request, StatusCode};
|
||||
|
||||
use super::body::{CollectBodyError, CollectedBody, RequestBody, collect_body};
|
||||
use super::decoy::serve_decoy;
|
||||
use super::request::canonical_u64_header;
|
||||
use super::{
|
||||
HttpResponse, carrier_empty, carrier_headers, carrier_lane, full_response, insert_header,
|
||||
request_trace, service_unavailable,
|
||||
};
|
||||
use crate::config::WebRuntimeVhost;
|
||||
use crate::web::manager::{ManagerError, TokenHash, WebProcessRuntime};
|
||||
use crate::web::trace::{TraceDirection, TraceRoute};
|
||||
|
||||
/// Handles one authenticated long-poll downlink exchange.
|
||||
pub(super) async fn handle_down(
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
token_hash: TokenHash,
|
||||
) -> HttpResponse {
|
||||
if request.method() != hyper::Method::POST
|
||||
|| request.headers().contains_key(header::CONTENT_TYPE)
|
||||
{
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.set_route(TraceRoute::Downlink);
|
||||
trace.bind_identity(session.trace_identity());
|
||||
}
|
||||
let Some(lane_id) = carrier_lane(&request, session.carrier()) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if !body.is_empty() {
|
||||
return serve_decoy(request, vhost, true, &runtime).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(
|
||||
&mut response,
|
||||
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) => {
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.record_frames(
|
||||
TraceDirection::Response,
|
||||
&result.body,
|
||||
&runtime.active_generation().config().web.limits,
|
||||
);
|
||||
}
|
||||
let mut response = full_response(StatusCode::OK, result.body);
|
||||
carrier_headers(&mut response);
|
||||
insert_header(
|
||||
&mut response,
|
||||
HeaderName::from_static("x-down-cursor"),
|
||||
&result.next_cursor.to_string(),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(ManagerError::Concurrent | ManagerError::Backpressure | ManagerError::Limit) => {
|
||||
service_unavailable()
|
||||
}
|
||||
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
|
||||
use super::request::canonical_u64_header;
|
||||
use super::{BoxError, HttpResponse};
|
||||
use crate::config::WebCarrier;
|
||||
use crate::web::frame;
|
||||
|
||||
/// Validates and resolves the optional carrier lane header.
|
||||
pub(super) 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies common binary carrier response headers.
|
||||
pub(super) fn carrier_headers(response: &mut HttpResponse) {
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
/// Builds an empty no-store carrier response.
|
||||
pub(super) fn carrier_empty(status: StatusCode) -> HttpResponse {
|
||||
let mut response = empty_response(status);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Builds the bounded retryable carrier saturation response.
|
||||
pub(super) fn service_unavailable() -> HttpResponse {
|
||||
let mut response = carrier_empty(StatusCode::SERVICE_UNAVAILABLE);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Builds the ordinary decoy upstream failure response.
|
||||
pub(super) fn bad_gateway() -> HttpResponse {
|
||||
full_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Bytes::from_static(b"site unavailable\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the ordinary unmatched-host response.
|
||||
pub(super) fn generic_not_found() -> HttpResponse {
|
||||
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
||||
}
|
||||
|
||||
/// Builds one in-memory response with an exact content length.
|
||||
pub(super) fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||
let length = body.len();
|
||||
let body = Full::new(body)
|
||||
.map_err(|never| -> BoxError { match never {} })
|
||||
.boxed_unsync();
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
insert_header(&mut response, header::CONTENT_LENGTH, &length.to_string());
|
||||
response
|
||||
}
|
||||
|
||||
fn empty_response(status: StatusCode) -> HttpResponse {
|
||||
full_response(status, Bytes::new())
|
||||
}
|
||||
|
||||
/// Inserts one validated dynamic response header value.
|
||||
pub(super) fn insert_header(response: &mut HttpResponse, name: HeaderName, value: &str) {
|
||||
if let Ok(value) = HeaderValue::from_str(value) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use crate::maestro::generation::test_runtime_generation;
|
||||
use crate::web::frame::{self, FrameType};
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
|
||||
fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
|
||||
pub(super) 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(),
|
||||
@@ -25,6 +25,7 @@ fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
carrier,
|
||||
capability,
|
||||
key_fingerprint: "0000000000000000".to_string(),
|
||||
max_sessions: 4,
|
||||
max_streams: 16,
|
||||
max_streams_per_session: 4,
|
||||
@@ -71,7 +72,7 @@ fn runtime_config(capability: [u8; 32], carrier: WebCarrier) -> ProxyConfig {
|
||||
config
|
||||
}
|
||||
|
||||
async fn request(
|
||||
pub(super) async fn request(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
request: Vec<u8>,
|
||||
@@ -97,7 +98,7 @@ async fn request(
|
||||
response
|
||||
}
|
||||
|
||||
fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
|
||||
pub(super) fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use base64::Engine as _;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use super::tests::{request, runtime_config, split_response};
|
||||
use crate::config::{WebCarrier, WebDebugBodyCapture};
|
||||
use crate::maestro::generation::test_runtime_generation;
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
use crate::web::trace::{TraceBodyState, TraceRecordKind, TraceRoute};
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_debug_records_bridge_request_response_without_credentials() {
|
||||
let capability = [18u8; 32];
|
||||
let mut config = runtime_config(capability, WebCarrier::Https);
|
||||
config.web.debug.enabled = true;
|
||||
config.web.debug.body_capture = WebDebugBodyCapture::Prefix;
|
||||
config.web.debug.body_prefix_bytes = 4096;
|
||||
let generation = test_runtime_generation(1, config);
|
||||
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.50\r\nUser-Agent: debug-client/1\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
|
||||
let response = request(&listener, &runtime, root).await;
|
||||
let (_, body) = split_response(&response);
|
||||
let body = std::str::from_utf8(body).unwrap();
|
||||
let bootstrap = body
|
||||
.split_once("bootstrap=\"")
|
||||
.and_then(|(_, suffix)| suffix.split_once('"'))
|
||||
.map(|(token, _)| token)
|
||||
.unwrap();
|
||||
let records = runtime
|
||||
.trace()
|
||||
.snapshot_matching(|record| matches!(&record.kind, TraceRecordKind::Http(_)));
|
||||
assert_eq!(records.len(), 1);
|
||||
let record = &records[0].record;
|
||||
assert_eq!(record.effective_ip, Some("192.0.2.50".parse().unwrap()));
|
||||
assert_eq!(record.user_agent.as_deref(), Some("debug-client/1"));
|
||||
assert!(record.identity.session_id.is_some());
|
||||
assert_eq!(record.identity.user.as_deref(), Some("alice"));
|
||||
let TraceRecordKind::Http(http) = &record.kind else {
|
||||
panic!("expected HTTP debug record");
|
||||
};
|
||||
assert_eq!(http.method, "GET");
|
||||
assert_eq!(http.path, "/");
|
||||
assert_eq!(http.route, TraceRoute::Bridge);
|
||||
assert_eq!(http.status, Some(200));
|
||||
assert_eq!(
|
||||
http.response_body.as_ref().unwrap().state,
|
||||
TraceBodyState::Complete
|
||||
);
|
||||
let captured_response = &http.response_body.as_ref().unwrap().captured;
|
||||
assert!(
|
||||
!captured_response
|
||||
.windows(bootstrap.len())
|
||||
.any(|value| value == bootstrap.as_bytes())
|
||||
);
|
||||
assert!(
|
||||
captured_response
|
||||
.windows(bootstrap.len())
|
||||
.any(|value| value.iter().all(|byte| *byte == b'*'))
|
||||
);
|
||||
assert!(http.timings.as_ref().unwrap().response_ready_us.is_some());
|
||||
assert!(http.timings.as_ref().unwrap().response_body_us.is_some());
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
+34
-223
@@ -1,34 +1,27 @@
|
||||
use std::future::Future;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::{WebCarrier, WebLimitsConfig, WebRuntimeProfile};
|
||||
use crate::config::{WebCarrier, WebLimitsConfig};
|
||||
use crate::maestro::generation::RuntimeGeneration;
|
||||
use crate::web::frame;
|
||||
use crate::web::session::WebSession;
|
||||
use crate::web::trace::WebTraceStore;
|
||||
|
||||
// Credential maps, quotas, and token-bucket helpers remain private to the manager.
|
||||
mod state;
|
||||
// Bootstrap credentials and idempotent session creation are isolated from queue accounting.
|
||||
mod credentials;
|
||||
// Stream admission and synthetic tuple ownership are process-scoped.
|
||||
mod admission;
|
||||
// Shutdown and expiry work remain outside request-path coordination.
|
||||
mod lifecycle;
|
||||
use state::{
|
||||
Bootstrap, ManagerState, allow_rate, control_item_reserve, decrement_map,
|
||||
evict_oldest_unused_bootstrap, matching_profile, new_unique_token, profile_key,
|
||||
remove_expired_locked,
|
||||
};
|
||||
use state::{ManagerState, control_item_reserve};
|
||||
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
const CLEANUP_INTERVAL: Duration = Duration::from_secs(1);
|
||||
@@ -63,9 +56,18 @@ pub(crate) struct CreateResult {
|
||||
pub(crate) carrier: WebCarrier,
|
||||
}
|
||||
|
||||
/// Successful bridge bootstrap issuance result.
|
||||
pub(crate) struct BootstrapResult {
|
||||
/// Opaque one-use bootstrap credential.
|
||||
pub(crate) token: String,
|
||||
/// Process-unique non-secret trace identifier.
|
||||
pub(crate) trace_session_id: u64,
|
||||
}
|
||||
|
||||
/// Process-owned bounded WEB credential, session, and memory coordinator.
|
||||
pub(crate) struct WebProcessRuntime {
|
||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||
trace: Arc<WebTraceStore>,
|
||||
limits: WebLimitsConfig,
|
||||
state: Mutex<ManagerState>,
|
||||
http_connections: Arc<Semaphore>,
|
||||
@@ -89,10 +91,22 @@ pub(crate) struct WebProcessRuntime {
|
||||
|
||||
impl WebProcessRuntime {
|
||||
/// Starts one process-scoped manager using immutable allocation ceilings.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> {
|
||||
let config = active_runtime.load().config();
|
||||
let trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits);
|
||||
Self::start_with_trace(active_runtime, trace)
|
||||
}
|
||||
|
||||
/// Starts one process-scoped manager with a shared API-visible trace store.
|
||||
pub(crate) fn start_with_trace(
|
||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||
trace: Arc<WebTraceStore>,
|
||||
) -> Arc<Self> {
|
||||
let limits = active_runtime.load().config().web.limits.clone();
|
||||
let runtime = Arc::new(Self {
|
||||
active_runtime,
|
||||
trace,
|
||||
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))),
|
||||
@@ -125,6 +139,8 @@ impl WebProcessRuntime {
|
||||
let Some(runtime) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let policy = runtime.active_generation().config().web.debug.clone();
|
||||
runtime.trace.apply_policy(&policy);
|
||||
runtime.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +154,11 @@ impl WebProcessRuntime {
|
||||
self.active_runtime.load_full()
|
||||
}
|
||||
|
||||
/// Returns the process-owned WEB debug trace store.
|
||||
pub(crate) fn trace(&self) -> &Arc<WebTraceStore> {
|
||||
&self.trace
|
||||
}
|
||||
|
||||
/// Reserves one accepted HTTP connection.
|
||||
pub(crate) fn try_http_connection(&self) -> Option<OwnedSemaphorePermit> {
|
||||
let permit = Arc::clone(&self.http_connections).try_acquire_owned().ok();
|
||||
@@ -211,216 +232,6 @@ impl WebProcessRuntime {
|
||||
Some((reader, body))
|
||||
}
|
||||
|
||||
/// Issues a one-use bootstrap credential for an active compatible profile.
|
||||
pub(crate) fn issue_bootstrap(
|
||||
&self,
|
||||
profile: Arc<WebRuntimeProfile>,
|
||||
client_ip: IpAddr,
|
||||
) -> std::result::Result<String, ManagerError> {
|
||||
let generation = self.active_generation();
|
||||
let config = generation.config();
|
||||
let profile = config
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| matching_profile(runtime, &profile))
|
||||
.ok_or(ManagerError::Authentication)?;
|
||||
if !config.web.enabled || !generation.proxy_shared.is_user_enabled(&profile.user) {
|
||||
return Err(ManagerError::Closed);
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock();
|
||||
remove_expired_locked(&mut state, now);
|
||||
if state.closed
|
||||
|| state
|
||||
.bootstraps_per_ip
|
||||
.get(&client_ip)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
>= self.limits.max_bootstraps_per_ip
|
||||
|| !allow_rate(
|
||||
&mut state.bootstrap_rate,
|
||||
now,
|
||||
self.limits.new_bootstraps_per_minute,
|
||||
self.limits.new_bootstraps_burst,
|
||||
)
|
||||
{
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
}
|
||||
if state.bootstraps.len() >= self.limits.max_bootstraps_global
|
||||
&& !evict_oldest_unused_bootstrap(&mut state)
|
||||
{
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
}
|
||||
let Some((token, hash)) = new_unique_token(&generation, &state) else {
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
};
|
||||
state.bootstraps.insert(
|
||||
hash,
|
||||
Bootstrap {
|
||||
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
|
||||
issued_at: now,
|
||||
issuance_ip: client_ip,
|
||||
profile,
|
||||
body_digest: [0; TOKEN_BYTES],
|
||||
session_token: Zeroizing::new(String::new()),
|
||||
session: None,
|
||||
used: false,
|
||||
},
|
||||
);
|
||||
*state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Checks whether a bootstrap token is live before reading a request body.
|
||||
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let state = self.state.lock();
|
||||
state
|
||||
.bootstraps
|
||||
.get(&hash)
|
||||
.is_some_and(|entry| entry.profile.host == host && now <= entry.expires_at)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
return Ok(CreateResult {
|
||||
token: entry.session_token.as_str().to_owned(),
|
||||
carrier: session.carrier(),
|
||||
});
|
||||
}
|
||||
if state.closed || !config.web.enabled {
|
||||
return Err(ManagerError::Closed);
|
||||
}
|
||||
let profile = config
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| matching_profile(runtime, &entry.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,
|
||||
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);
|
||||
Ok(CreateResult {
|
||||
token: session_token,
|
||||
carrier: session.carrier(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves an authenticated session token.
|
||||
pub(crate) fn get_session(
|
||||
&self,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> std::result::Result<Arc<WebSession>, ManagerError> {
|
||||
self.state
|
||||
.lock()
|
||||
.sessions
|
||||
.get(&hash)
|
||||
.cloned()
|
||||
.filter(|session| session.matches_host(host))
|
||||
.ok_or(ManagerError::Authentication)
|
||||
}
|
||||
|
||||
/// Closes a live token and accepts bounded tombstone retries.
|
||||
pub(crate) fn close_token(
|
||||
&self,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> std::result::Result<(), ManagerError> {
|
||||
let state = self.state.lock();
|
||||
let session = state
|
||||
.sessions
|
||||
.get(&hash)
|
||||
.filter(|session| session.matches_host(host))
|
||||
.cloned();
|
||||
let closed = state
|
||||
.closed_tokens
|
||||
.get(&hash)
|
||||
.is_some_and(|closed| closed.host == host);
|
||||
drop(state);
|
||||
if let Some(session) = session {
|
||||
session.close();
|
||||
return Ok(());
|
||||
}
|
||||
closed.then_some(()).ok_or(ManagerError::Authentication)
|
||||
}
|
||||
|
||||
/// Reserves bounded process-wide queue capacity for data or control traffic.
|
||||
pub(crate) fn try_reserve_pending(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
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::{
|
||||
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,
|
||||
};
|
||||
use crate::config::WebRuntimeProfile;
|
||||
use crate::web::frame;
|
||||
use crate::web::session::WebSession;
|
||||
|
||||
impl WebProcessRuntime {
|
||||
/// Issues a one-use bootstrap credential for an active compatible profile.
|
||||
pub(crate) fn issue_bootstrap(
|
||||
&self,
|
||||
profile: Arc<WebRuntimeProfile>,
|
||||
client_ip: IpAddr,
|
||||
) -> std::result::Result<BootstrapResult, ManagerError> {
|
||||
let generation = self.active_generation();
|
||||
let config = generation.config();
|
||||
let profile = config
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| matching_profile(runtime, &profile))
|
||||
.ok_or(ManagerError::Authentication)?;
|
||||
if !config.web.enabled || !generation.proxy_shared.is_user_enabled(&profile.user) {
|
||||
return Err(ManagerError::Closed);
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut state = self.state.lock();
|
||||
remove_expired_locked(&mut state, now);
|
||||
if state.closed
|
||||
|| state
|
||||
.bootstraps_per_ip
|
||||
.get(&client_ip)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
>= self.limits.max_bootstraps_per_ip
|
||||
|| !allow_rate(
|
||||
&mut state.bootstrap_rate,
|
||||
now,
|
||||
self.limits.new_bootstraps_per_minute,
|
||||
self.limits.new_bootstraps_burst,
|
||||
)
|
||||
{
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
}
|
||||
if state.bootstraps.len() >= self.limits.max_bootstraps_global
|
||||
&& !evict_oldest_unused_bootstrap(&mut state)
|
||||
{
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
}
|
||||
let Some((token, hash)) = new_unique_token(&generation, &state) else {
|
||||
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(ManagerError::Limit);
|
||||
};
|
||||
let trace_session_id = self.trace.next_session_id();
|
||||
state.bootstraps.insert(
|
||||
hash,
|
||||
Bootstrap {
|
||||
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
|
||||
issued_at: now,
|
||||
issuance_ip: client_ip,
|
||||
profile,
|
||||
trace_session_id,
|
||||
body_digest: [0; TOKEN_BYTES],
|
||||
session_token: Zeroizing::new(String::new()),
|
||||
session: None,
|
||||
used: false,
|
||||
},
|
||||
);
|
||||
*state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1;
|
||||
let profile = state
|
||||
.bootstraps
|
||||
.get(&hash)
|
||||
.map(|entry| Arc::clone(&entry.profile))
|
||||
.ok_or(ManagerError::Closed)?;
|
||||
drop(state);
|
||||
self.trace.record_profile_lifecycle(
|
||||
client_ip,
|
||||
Some(trace_session_id),
|
||||
&profile,
|
||||
crate::web::trace::TraceLifecycleEvent::BridgeIssued,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Ok(BootstrapResult {
|
||||
token,
|
||||
trace_session_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves non-secret bootstrap trace identity without exposing its credential.
|
||||
pub(crate) fn bootstrap_trace_identity(
|
||||
&self,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> Option<(u64, Arc<WebRuntimeProfile>)> {
|
||||
let now = Instant::now();
|
||||
self.state
|
||||
.lock()
|
||||
.bootstraps
|
||||
.get(&hash)
|
||||
.filter(|entry| entry.profile.host == host && now <= entry.expires_at)
|
||||
.map(|entry| (entry.trace_session_id, Arc::clone(&entry.profile)))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> std::result::Result<Arc<WebSession>, ManagerError> {
|
||||
self.state
|
||||
.lock()
|
||||
.sessions
|
||||
.get(&hash)
|
||||
.cloned()
|
||||
.filter(|session| session.matches_host(host))
|
||||
.ok_or(ManagerError::Authentication)
|
||||
}
|
||||
|
||||
/// Closes a live token and accepts bounded tombstone retries.
|
||||
pub(crate) fn close_token(
|
||||
&self,
|
||||
hash: TokenHash,
|
||||
host: &str,
|
||||
) -> std::result::Result<(), ManagerError> {
|
||||
let state = self.state.lock();
|
||||
let session = state
|
||||
.sessions
|
||||
.get(&hash)
|
||||
.filter(|session| session.matches_host(host))
|
||||
.cloned();
|
||||
let closed = state
|
||||
.closed_tokens
|
||||
.get(&hash)
|
||||
.is_some_and(|closed| closed.host == host);
|
||||
drop(state);
|
||||
if let Some(session) = session {
|
||||
session.close();
|
||||
return Ok(());
|
||||
}
|
||||
closed.then_some(()).ok_or(ManagerError::Authentication)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ pub(super) struct Bootstrap {
|
||||
pub(super) issuance_ip: IpAddr,
|
||||
/// Immutable profile selected during capability validation.
|
||||
pub(super) profile: Arc<WebRuntimeProfile>,
|
||||
/// Process-unique non-secret identifier shared by bootstrap and session traces.
|
||||
pub(super) trace_session_id: u64,
|
||||
/// Digest of the accepted HELLO body for idempotent retry matching.
|
||||
pub(super) body_digest: TokenHash,
|
||||
/// Zeroizing copy returned only for an exact session-creation retry.
|
||||
@@ -130,6 +132,7 @@ pub(super) fn matching_profile(
|
||||
&& profile.secret_mode == expected.secret_mode
|
||||
&& profile.carrier == expected.carrier
|
||||
&& profile.capability == expected.capability
|
||||
&& profile.key_fingerprint == expected.key_fingerprint
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
@@ -12,3 +12,5 @@ pub(crate) mod manager;
|
||||
pub(crate) mod session;
|
||||
/// AsyncRead and AsyncWrite adapter for one logical MTProxy stream.
|
||||
pub(crate) mod stream;
|
||||
/// Process-owned bounded WEB debugging records and capture lifecycle.
|
||||
pub(crate) mod trace;
|
||||
|
||||
@@ -120,6 +120,7 @@ pub(crate) struct WebSession {
|
||||
manager: std::sync::Weak<WebProcessRuntime>,
|
||||
token_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
trace_session_id: u64,
|
||||
profile: Arc<WebRuntimeProfile>,
|
||||
profile_key: ProfileKey,
|
||||
limits: WebLimitsConfig,
|
||||
@@ -150,6 +151,7 @@ impl WebSession {
|
||||
manager: std::sync::Weak<WebProcessRuntime>,
|
||||
token_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
trace_session_id: u64,
|
||||
profile: Arc<WebRuntimeProfile>,
|
||||
profile_key: ProfileKey,
|
||||
limits: WebLimitsConfig,
|
||||
@@ -163,6 +165,7 @@ impl WebSession {
|
||||
manager,
|
||||
token_hash,
|
||||
client_ip,
|
||||
trace_session_id,
|
||||
profile,
|
||||
profile_key,
|
||||
limits,
|
||||
@@ -211,6 +214,30 @@ impl WebSession {
|
||||
self.profile.carrier
|
||||
}
|
||||
|
||||
/// Returns a cloned non-secret identity only for enabled debug capture.
|
||||
pub(crate) fn trace_identity(&self) -> crate::web::trace::TraceIdentity {
|
||||
crate::web::trace::TraceIdentity::from_profile(self.trace_session_id, &self.profile)
|
||||
}
|
||||
|
||||
/// Records one typed lifecycle event without exposing session credentials.
|
||||
pub(super) fn trace_lifecycle(
|
||||
&self,
|
||||
event: crate::web::trace::TraceLifecycleEvent,
|
||||
stream_id: Option<u32>,
|
||||
reason: Option<&'static str>,
|
||||
) {
|
||||
if let Some(manager) = self.manager.upgrade() {
|
||||
manager.trace().record_profile_lifecycle(
|
||||
self.client_ip,
|
||||
Some(self.trace_session_id),
|
||||
&self.profile,
|
||||
event,
|
||||
stream_id,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) = {
|
||||
@@ -253,6 +280,11 @@ impl WebSession {
|
||||
manager.release_pending(data_bytes, data_items, false);
|
||||
manager.release_pending(control_bytes, control_items, true);
|
||||
if !self.finished.swap(true, Ordering::AcqRel) {
|
||||
self.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::SessionClosed,
|
||||
None,
|
||||
Some("closed"),
|
||||
);
|
||||
manager.session_finished(
|
||||
self.token_hash,
|
||||
self.client_ip,
|
||||
|
||||
@@ -22,11 +22,21 @@ impl WebSession {
|
||||
};
|
||||
let generation = manager.active_generation();
|
||||
if !*generation.admission_rx.borrow() {
|
||||
self.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream_id),
|
||||
Some("admission_closed"),
|
||||
);
|
||||
self.stream_finished(stream_id, peer_port);
|
||||
return;
|
||||
}
|
||||
let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else {
|
||||
manager.record_stream_rejected();
|
||||
self.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream_id),
|
||||
Some("connection_limit"),
|
||||
);
|
||||
self.stream_finished(stream_id, peer_port);
|
||||
return;
|
||||
};
|
||||
@@ -42,11 +52,17 @@ impl WebSession {
|
||||
stream_id,
|
||||
peer_port,
|
||||
};
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamAdmitted,
|
||||
Some(stream_id),
|
||||
None,
|
||||
);
|
||||
let stream = WebLogicalStream::new(Arc::clone(&session), stream_id);
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {}
|
||||
_ = run_stream(
|
||||
Arc::clone(&session),
|
||||
stream_id,
|
||||
stream,
|
||||
deps,
|
||||
replay_checker,
|
||||
@@ -55,6 +71,11 @@ impl WebSession {
|
||||
}
|
||||
});
|
||||
if !spawned {
|
||||
self.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream_id),
|
||||
Some("generation_closed"),
|
||||
);
|
||||
self.tasks_live.fetch_sub(1, Ordering::AcqRel);
|
||||
self.stream_finished(stream_id, peer_port);
|
||||
self.tasks_done.notify_waiters();
|
||||
@@ -100,6 +121,11 @@ struct StreamCompletion {
|
||||
|
||||
impl Drop for StreamCompletion {
|
||||
fn drop(&mut self) {
|
||||
self.session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamClosed,
|
||||
Some(self.stream_id),
|
||||
None,
|
||||
);
|
||||
self.session.stream_finished(self.stream_id, self.peer_port);
|
||||
if self.session.tasks_live.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
self.session.tasks_done.notify_waiters();
|
||||
@@ -109,6 +135,7 @@ impl Drop for StreamCompletion {
|
||||
|
||||
async fn run_stream(
|
||||
session: Arc<WebSession>,
|
||||
stream_id: u32,
|
||||
stream: WebLogicalStream,
|
||||
deps: crate::proxy::authenticated::ClientRuntimeDeps,
|
||||
replay_checker: Arc<crate::stats::ReplayChecker>,
|
||||
@@ -129,14 +156,29 @@ async fn run_stream(
|
||||
// first byte. Session and stream quotas bound this idle phase without
|
||||
// consuming the process-wide active-handshake budget.
|
||||
if reader.read_exact(&mut handshake[..1]).await.is_err() {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::HandshakeIo,
|
||||
Some(stream_id),
|
||||
Some("first_byte_io"),
|
||||
);
|
||||
deps.stats
|
||||
.increment_connects_bad_with_class("web_mtproto_handshake_io");
|
||||
return;
|
||||
}
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamFirstByte,
|
||||
Some(stream_id),
|
||||
None,
|
||||
);
|
||||
let Some(manager) = session.manager.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(handshake_permit) = manager.try_stream_handshake() else {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::StreamRejected,
|
||||
Some(stream_id),
|
||||
Some("handshake_limit"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let handshake_result = tokio::time::timeout(
|
||||
@@ -163,6 +205,11 @@ async fn run_stream(
|
||||
drop(handshake_permit);
|
||||
let (reader, writer, success) = match handshake_result {
|
||||
Err(_) => {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::HandshakeTimeout,
|
||||
Some(stream_id),
|
||||
Some("timeout"),
|
||||
);
|
||||
deps.stats
|
||||
.increment_connects_bad_with_class("web_mtproto_handshake_timeout");
|
||||
deps.stats.increment_handshake_timeouts();
|
||||
@@ -170,20 +217,40 @@ async fn run_stream(
|
||||
return;
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::HandshakeIo,
|
||||
Some(stream_id),
|
||||
Some("io"),
|
||||
);
|
||||
deps.stats
|
||||
.increment_connects_bad_with_class("web_mtproto_handshake_io");
|
||||
return;
|
||||
}
|
||||
Ok(Ok(crate::error::HandshakeResult::Success((reader, writer, success)))) => {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::HandshakeSucceeded,
|
||||
Some(stream_id),
|
||||
None,
|
||||
);
|
||||
(reader, writer, success)
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::HandshakeRejected,
|
||||
Some(stream_id),
|
||||
Some("bad_client"),
|
||||
);
|
||||
deps.stats
|
||||
.increment_connects_bad_with_class("web_mtproto_bad_client");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = run_authenticated(
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::RelayStarted,
|
||||
Some(stream_id),
|
||||
None,
|
||||
);
|
||||
let relay_result = run_authenticated(
|
||||
reader,
|
||||
writer,
|
||||
success,
|
||||
@@ -193,4 +260,9 @@ async fn run_stream(
|
||||
ConntrackClosePolicy::Suppress,
|
||||
)
|
||||
.await;
|
||||
session.trace_lifecycle(
|
||||
crate::web::trace::TraceLifecycleEvent::RelayEnded,
|
||||
Some(stream_id),
|
||||
Some(if relay_result.is_ok() { "completed" } else { "error" }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ fn test_runtime_with_dc(
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
carrier,
|
||||
capability: [7; 32],
|
||||
key_fingerprint: "0000000000000000".to_string(),
|
||||
max_sessions: 4,
|
||||
max_streams: 16,
|
||||
max_streams_per_session: 4,
|
||||
@@ -117,6 +118,7 @@ fn test_runtime_with_dc(
|
||||
Arc::downgrade(&manager),
|
||||
[8; 32],
|
||||
"192.0.2.10".parse().unwrap(),
|
||||
1,
|
||||
profile,
|
||||
[7; 32],
|
||||
limits,
|
||||
|
||||
@@ -420,6 +420,7 @@ mod tests {
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
carrier: WebCarrier::Https,
|
||||
capability: [0; 32],
|
||||
key_fingerprint: "0000000000000000".to_string(),
|
||||
max_sessions: 1,
|
||||
max_streams: 1,
|
||||
max_streams_per_session: 1,
|
||||
@@ -428,6 +429,7 @@ mod tests {
|
||||
std::sync::Weak::<WebProcessRuntime>::new(),
|
||||
[1; 32],
|
||||
"192.0.2.10".parse().unwrap(),
|
||||
1,
|
||||
profile,
|
||||
[2; 32],
|
||||
WebLimitsConfig::default(),
|
||||
|
||||
@@ -15,6 +15,7 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
carrier: WebCarrier::HttpsLanes,
|
||||
capability: [0; 32],
|
||||
key_fingerprint: "0000000000000000".to_string(),
|
||||
max_sessions: 1,
|
||||
max_streams: 2,
|
||||
max_streams_per_session: 2,
|
||||
@@ -23,6 +24,7 @@ fn session_with_limits(limits: WebLimitsConfig) -> Arc<WebSession> {
|
||||
std::sync::Weak::<WebProcessRuntime>::new(),
|
||||
[1; 32],
|
||||
"192.0.2.10".parse().unwrap(),
|
||||
1,
|
||||
profile,
|
||||
[2; 32],
|
||||
limits,
|
||||
|
||||
@@ -342,6 +342,7 @@ mod tests {
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
carrier: WebCarrier::Https,
|
||||
capability: [0; 32],
|
||||
key_fingerprint: "0000000000000000".to_string(),
|
||||
max_sessions: 1,
|
||||
max_streams: 1,
|
||||
max_streams_per_session: 1,
|
||||
@@ -350,6 +351,7 @@ mod tests {
|
||||
std::sync::Weak::<WebProcessRuntime>::new(),
|
||||
[1; 32],
|
||||
"192.0.2.10".parse().unwrap(),
|
||||
1,
|
||||
profile,
|
||||
[2; 32],
|
||||
WebLimitsConfig::default(),
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::sanitize::{
|
||||
bounded_text, capture_limit, frame_error_name, frame_type_name, request_dynamic_bytes,
|
||||
response_dynamic_bytes, sanitized_headers, scrub_body, sensitive_values,
|
||||
};
|
||||
use super::store::{WebTraceStore, epoch_millis};
|
||||
use super::types::{
|
||||
TraceBodySnapshot, TraceBodyState, TraceDirection, TraceFrame, TraceHeader, TraceHttpRecord,
|
||||
TraceIdentity, TraceRecord, TraceRecordKind, TraceRoute, TraceTimings,
|
||||
};
|
||||
use crate::config::{WebDebugBodyCapture, WebDebugConfig, WebLimitsConfig, WebRuntimeProfile};
|
||||
use crate::web::frame::{self, FrameType};
|
||||
|
||||
const USER_AGENT_MAX_BYTES: usize = 512;
|
||||
|
||||
#[derive(Default)]
|
||||
struct BodyCapture {
|
||||
observed_bytes: u64,
|
||||
captured: Vec<u8>,
|
||||
truncated: bool,
|
||||
state: Option<TraceBodyState>,
|
||||
}
|
||||
|
||||
struct ExchangeState {
|
||||
method: String,
|
||||
path: String,
|
||||
route: TraceRoute,
|
||||
peer_ip: IpAddr,
|
||||
effective_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
identity: TraceIdentity,
|
||||
request_headers: Vec<TraceHeader>,
|
||||
response_headers: Vec<TraceHeader>,
|
||||
request_body: BodyCapture,
|
||||
response_body: BodyCapture,
|
||||
status: Option<u16>,
|
||||
frames: Vec<TraceFrame>,
|
||||
timings: TraceTimings,
|
||||
redactions: Vec<Zeroizing<Vec<u8>>>,
|
||||
body_capture_blocked: bool,
|
||||
}
|
||||
|
||||
/// One in-flight request-to-response capture with process-wide byte leases.
|
||||
pub(crate) struct HttpTraceExchange {
|
||||
store: Arc<WebTraceStore>,
|
||||
epoch: u64,
|
||||
policy: Arc<WebDebugConfig>,
|
||||
started: Instant,
|
||||
started_epoch_millis: u64,
|
||||
state: Mutex<ExchangeState>,
|
||||
reserved: AtomicUsize,
|
||||
committed: AtomicBool,
|
||||
}
|
||||
|
||||
impl HttpTraceExchange {
|
||||
/// Creates one enabled exchange after the store reserved its base record lease.
|
||||
pub(super) fn new<B>(
|
||||
store: Arc<WebTraceStore>,
|
||||
epoch: u64,
|
||||
policy: Arc<WebDebugConfig>,
|
||||
request: &hyper::Request<B>,
|
||||
peer_ip: IpAddr,
|
||||
base_reservation: usize,
|
||||
) -> Arc<Self> {
|
||||
let started = Instant::now();
|
||||
let started_epoch_millis = epoch_millis();
|
||||
let dynamic = request_dynamic_bytes(request, &policy);
|
||||
let dynamic_reserved = store.try_reserve(dynamic);
|
||||
let (method, path, user_agent, request_headers, redactions) = if dynamic_reserved {
|
||||
(
|
||||
request.method().as_str().to_string(),
|
||||
request.uri().path().to_string(),
|
||||
request
|
||||
.headers()
|
||||
.get(hyper::header::USER_AGENT)
|
||||
.map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)),
|
||||
policy
|
||||
.capture_headers
|
||||
.then(|| sanitized_headers(request.headers()))
|
||||
.unwrap_or_default(),
|
||||
sensitive_values(request.headers(), request.uri().query()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
bounded_text(request.method().as_str().as_bytes(), 32),
|
||||
bounded_text(request.uri().path().as_bytes(), 512),
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
Arc::new(Self {
|
||||
store,
|
||||
epoch,
|
||||
policy,
|
||||
started,
|
||||
started_epoch_millis,
|
||||
state: Mutex::new(ExchangeState {
|
||||
method,
|
||||
path,
|
||||
route: TraceRoute::Unknown,
|
||||
peer_ip,
|
||||
effective_ip: None,
|
||||
user_agent,
|
||||
identity: TraceIdentity::default(),
|
||||
request_headers,
|
||||
response_headers: Vec::new(),
|
||||
request_body: BodyCapture::default(),
|
||||
response_body: BodyCapture::default(),
|
||||
status: None,
|
||||
frames: Vec::new(),
|
||||
timings: TraceTimings::default(),
|
||||
redactions,
|
||||
body_capture_blocked: !dynamic_reserved,
|
||||
}),
|
||||
reserved: AtomicUsize::new(
|
||||
base_reservation + if dynamic_reserved { dynamic } else { 0 },
|
||||
),
|
||||
committed: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the final request route before body polling or decoy forwarding.
|
||||
pub(crate) fn set_route(&self, route: TraceRoute) {
|
||||
self.state.lock().route = route;
|
||||
}
|
||||
|
||||
/// Sets the trusted effective client address after proxy-header validation.
|
||||
pub(crate) fn set_effective_ip(&self, client_ip: IpAddr) {
|
||||
self.state.lock().effective_ip = Some(client_ip);
|
||||
}
|
||||
|
||||
/// Binds non-secret profile and process session identity.
|
||||
pub(crate) fn bind_profile(&self, profile: &WebRuntimeProfile, session_id: u64) {
|
||||
let dynamic = profile.user.len().saturating_add(profile.key_fingerprint.len());
|
||||
let mut state = self.state.lock();
|
||||
state.identity.session_id = Some(session_id);
|
||||
if self.reserve(dynamic) {
|
||||
state.identity.user = Some(profile.user.clone());
|
||||
state.identity.key_fingerprint = Some(profile.key_fingerprint.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds an already resolved non-secret session identity.
|
||||
pub(crate) fn bind_identity(&self, identity: TraceIdentity) {
|
||||
let dynamic = identity.user.as_ref().map_or(0, String::len).saturating_add(
|
||||
identity
|
||||
.key_fingerprint
|
||||
.as_ref()
|
||||
.map_or(0, String::len),
|
||||
);
|
||||
let mut state = self.state.lock();
|
||||
state.identity.session_id = identity.session_id;
|
||||
if self.reserve(dynamic) {
|
||||
state.identity.user = identity.user;
|
||||
state.identity.key_fingerprint = identity.key_fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers an ephemeral credential for body scrubbing before commit.
|
||||
pub(crate) fn register_redaction(&self, value: &[u8]) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.reserve(value.len()) {
|
||||
self.block_body_capture();
|
||||
return;
|
||||
}
|
||||
self.state
|
||||
.lock()
|
||||
.redactions
|
||||
.push(Zeroizing::new(value.to_vec()));
|
||||
}
|
||||
|
||||
/// Captures response status and sanitized headers at handler completion.
|
||||
pub(crate) fn response_ready<B>(&self, response: &hyper::Response<B>) {
|
||||
let dynamic = response_dynamic_bytes(response, &self.policy);
|
||||
let reserved = self.reserve(dynamic);
|
||||
let mut state = self.state.lock();
|
||||
state.status = Some(response.status().as_u16());
|
||||
if self.policy.capture_headers && reserved {
|
||||
state.response_headers = sanitized_headers(response.headers());
|
||||
}
|
||||
if reserved {
|
||||
state
|
||||
.redactions
|
||||
.extend(sensitive_values(response.headers(), None));
|
||||
} else if dynamic != 0 {
|
||||
state.body_capture_blocked = true;
|
||||
state.request_body.captured.clear();
|
||||
state.response_body.captured.clear();
|
||||
state.request_body.truncated = true;
|
||||
state.response_body.truncated = true;
|
||||
}
|
||||
if self.policy.capture_timings {
|
||||
state.timings.response_ready_us = Some(self.elapsed_us());
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one body data frame without changing the proxied bytes.
|
||||
pub(crate) fn body_data(&self, direction: TraceDirection, data: &[u8]) {
|
||||
let mut state = self.state.lock();
|
||||
let route = state.route;
|
||||
let body_capture_blocked = state.body_capture_blocked;
|
||||
let body = match direction {
|
||||
TraceDirection::Request => &mut state.request_body,
|
||||
TraceDirection::Response => &mut state.response_body,
|
||||
};
|
||||
body.observed_bytes = body
|
||||
.observed_bytes
|
||||
.saturating_add(u64::try_from(data.len()).unwrap_or(u64::MAX));
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if body_capture_blocked {
|
||||
body.truncated |= !data.is_empty();
|
||||
return;
|
||||
}
|
||||
let Some(limit) = capture_limit(
|
||||
&self.policy,
|
||||
route,
|
||||
self.store.max_carrier_body_bytes(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if body.captured.len() >= limit {
|
||||
if !data.is_empty() && !body.truncated {
|
||||
self.store.record_truncation();
|
||||
}
|
||||
body.truncated |= !data.is_empty();
|
||||
return;
|
||||
}
|
||||
if body.captured.capacity() == 0 {
|
||||
if !self.reserve(limit) {
|
||||
body.truncated = true;
|
||||
return;
|
||||
}
|
||||
body.captured = Vec::with_capacity(limit);
|
||||
}
|
||||
let take = data.len().min(limit - body.captured.len());
|
||||
body.captured.extend_from_slice(&data[..take]);
|
||||
if take < data.len() {
|
||||
if !body.truncated {
|
||||
self.store.record_truncation();
|
||||
}
|
||||
body.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks one request or response body terminal state.
|
||||
pub(crate) fn body_finished(&self, direction: TraceDirection, terminal: TraceBodyState) {
|
||||
let mut state = self.state.lock();
|
||||
let body = match direction {
|
||||
TraceDirection::Request => &mut state.request_body,
|
||||
TraceDirection::Response => &mut state.response_body,
|
||||
};
|
||||
if body.state.is_none() {
|
||||
body.state = Some(terminal);
|
||||
}
|
||||
if self.policy.capture_timings {
|
||||
match direction {
|
||||
TraceDirection::Request => state.timings.request_body_us = Some(self.elapsed_us()),
|
||||
TraceDirection::Response => state.timings.response_body_us = Some(self.elapsed_us()),
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
if direction == TraceDirection::Response {
|
||||
self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches parsed carrier frame metadata from one complete bounded body.
|
||||
pub(crate) fn record_frames(
|
||||
&self,
|
||||
direction: TraceDirection,
|
||||
body: &[u8],
|
||||
limits: &WebLimitsConfig,
|
||||
) {
|
||||
if !self.policy.capture_frames {
|
||||
return;
|
||||
}
|
||||
let estimated_frames = body
|
||||
.len()
|
||||
.div_ceil(frame::HEADER_BYTES)
|
||||
.clamp(1, limits.max_frames_per_body);
|
||||
let reservation = estimated_frames.saturating_mul(std::mem::size_of::<TraceFrame>());
|
||||
if !self.reserve(reservation) {
|
||||
return;
|
||||
}
|
||||
let frames = match frame::parse_all(body, limits) {
|
||||
Ok(frames) => frames
|
||||
.into_iter()
|
||||
.map(|frame| TraceFrame {
|
||||
direction,
|
||||
frame_type: Some(frame_type_name(frame.frame_type)),
|
||||
stream_id: Some(frame.stream_id),
|
||||
payload_len: Some(frame.payload.len()),
|
||||
window_delta: (frame.frame_type == FrameType::Window)
|
||||
.then(|| frame::window_amount(frame.payload).ok())
|
||||
.flatten(),
|
||||
parse_error: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Err(error) => vec![TraceFrame {
|
||||
direction,
|
||||
frame_type: None,
|
||||
stream_id: None,
|
||||
payload_len: None,
|
||||
window_delta: None,
|
||||
parse_error: Some(frame_error_name(error)),
|
||||
}],
|
||||
};
|
||||
self.state.lock().frames.extend(frames);
|
||||
}
|
||||
|
||||
/// Commits once after response body consumption or drop.
|
||||
pub(crate) fn commit(&self) {
|
||||
if self.committed.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let reserved = self.reserved.load(Ordering::Acquire);
|
||||
let record = self.build_record();
|
||||
if !self.store.try_commit(record, reserved, self.epoch) {
|
||||
self.store.release(reserved);
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve(&self, bytes: usize) -> bool {
|
||||
if bytes == 0 {
|
||||
return true;
|
||||
}
|
||||
if self.store.try_reserve(bytes) {
|
||||
self.reserved.fetch_add(bytes, Ordering::AcqRel);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn block_body_capture(&self) {
|
||||
let mut state = self.state.lock();
|
||||
state.body_capture_blocked = true;
|
||||
state.request_body.captured.clear();
|
||||
state.response_body.captured.clear();
|
||||
state.request_body.truncated = true;
|
||||
state.response_body.truncated = true;
|
||||
}
|
||||
|
||||
fn build_record(&self) -> TraceRecord {
|
||||
let mut state = self.state.lock();
|
||||
let redactions = std::mem::take(&mut state.redactions);
|
||||
scrub_body(&mut state.request_body.captured, &redactions);
|
||||
scrub_body(&mut state.response_body.captured, &redactions);
|
||||
let request_body = body_snapshot(&self.policy, &mut state.request_body);
|
||||
let response_body = body_snapshot(&self.policy, &mut state.response_body);
|
||||
TraceRecord {
|
||||
seq: self.store.next_record_seq(),
|
||||
epoch_millis: self.started_epoch_millis,
|
||||
peer_ip: Some(state.peer_ip),
|
||||
effective_ip: state.effective_ip,
|
||||
user_agent: state.user_agent.take(),
|
||||
identity: std::mem::take(&mut state.identity),
|
||||
kind: TraceRecordKind::Http(TraceHttpRecord {
|
||||
method: std::mem::take(&mut state.method),
|
||||
path: std::mem::take(&mut state.path),
|
||||
route: state.route,
|
||||
request_headers: std::mem::take(&mut state.request_headers),
|
||||
response_headers: std::mem::take(&mut state.response_headers),
|
||||
request_body,
|
||||
status: state.status,
|
||||
response_body,
|
||||
frames: std::mem::take(&mut state.frames),
|
||||
timings: self
|
||||
.policy
|
||||
.capture_timings
|
||||
.then(|| std::mem::take(&mut state.timings)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_us(&self) -> u64 {
|
||||
self.started
|
||||
.elapsed()
|
||||
.as_micros()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HttpTraceExchange {
|
||||
fn drop(&mut self) {
|
||||
if !self.committed.load(Ordering::Acquire) {
|
||||
self.body_finished(TraceDirection::Request, TraceBodyState::Aborted);
|
||||
self.body_finished(TraceDirection::Response, TraceBodyState::Aborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_snapshot(
|
||||
policy: &WebDebugConfig,
|
||||
body: &mut BodyCapture,
|
||||
) -> Option<TraceBodySnapshot> {
|
||||
(policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot {
|
||||
observed_bytes: body.observed_bytes,
|
||||
captured: std::mem::take(&mut body.captured),
|
||||
truncated: body.truncated,
|
||||
state: body.state.unwrap_or(TraceBodyState::Aborted),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_response_capture_redacts_credentials_and_omits_query() {
|
||||
let request_token = "request-token-0123456789";
|
||||
let capability = "capability-0123456789";
|
||||
let response_token = "response-token-0123456789";
|
||||
let request = hyper::Request::builder()
|
||||
.uri(format!("/?bridge={capability}"))
|
||||
.header("authorization", format!("Bearer {request_token}"))
|
||||
.body(())
|
||||
.unwrap();
|
||||
let mut policy = WebDebugConfig::default();
|
||||
policy.enabled = true;
|
||||
policy.body_capture = WebDebugBodyCapture::Prefix;
|
||||
policy.body_prefix_bytes = 256;
|
||||
let mut limits = WebLimitsConfig::default();
|
||||
limits.debug_records_capacity = 4;
|
||||
limits.debug_bytes_global = 16 * 1024;
|
||||
let store = WebTraceStore::new(policy, &limits);
|
||||
let exchange = store
|
||||
.begin_http(&request, "192.0.2.30".parse().unwrap())
|
||||
.unwrap();
|
||||
exchange.set_route(TraceRoute::Bridge);
|
||||
exchange.body_data(
|
||||
TraceDirection::Request,
|
||||
format!("{request_token}:{capability}").as_bytes(),
|
||||
);
|
||||
exchange.body_finished(TraceDirection::Request, TraceBodyState::Complete);
|
||||
|
||||
let response = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("x-session-token", response_token)
|
||||
.body(())
|
||||
.unwrap();
|
||||
exchange.response_ready(&response);
|
||||
exchange.body_data(TraceDirection::Response, response_token.as_bytes());
|
||||
exchange.body_finished(TraceDirection::Response, TraceBodyState::Complete);
|
||||
|
||||
let records = store.snapshot_matching(|_| true);
|
||||
assert_eq!(records.len(), 1);
|
||||
let TraceRecordKind::Http(http) = &records[0].record.kind else {
|
||||
panic!("expected HTTP debug record");
|
||||
};
|
||||
assert_eq!(http.path, "/");
|
||||
assert_eq!(http.route, TraceRoute::Bridge);
|
||||
assert!(
|
||||
http.request_headers
|
||||
.iter()
|
||||
.any(|header| header.name == "authorization" && header.value.is_none())
|
||||
);
|
||||
assert!(
|
||||
http.response_headers
|
||||
.iter()
|
||||
.any(|header| header.name == "x-session-token" && header.value.is_none())
|
||||
);
|
||||
let request_body = http.request_body.as_ref().unwrap();
|
||||
let response_body = http.response_body.as_ref().unwrap();
|
||||
for secret in [request_token.as_bytes(), capability.as_bytes()] {
|
||||
assert!(!request_body.captured.windows(secret.len()).any(|value| value == secret));
|
||||
}
|
||||
assert!(
|
||||
!response_body
|
||||
.captured
|
||||
.windows(response_token.len())
|
||||
.any(|value| value == response_token.as_bytes())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Process-owned bounded WEB request, frame, and lifecycle debug recording.
|
||||
|
||||
// HTTP exchange ownership binds byte leases to one request and response.
|
||||
mod exchange;
|
||||
// Header, credential, body, and frame sanitization stays independent from storage.
|
||||
mod sanitize;
|
||||
// The process-wide store owns policy epochs, ring eviction, and render admission.
|
||||
mod store;
|
||||
// Closed record types define the status-page data contract.
|
||||
mod types;
|
||||
|
||||
pub(crate) use exchange::HttpTraceExchange;
|
||||
pub(crate) use store::{StoredTraceRecord, WebTraceStore, epoch_millis as store_epoch_millis};
|
||||
pub(crate) use types::{
|
||||
TraceBodySnapshot, TraceBodyState, TraceDirection, TraceHeader, TraceIdentity,
|
||||
TraceLifecycleEvent, TraceRecord, TraceRecordKind, TraceRoute,
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
use hyper::header;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::types::{TraceHeader, TraceRoute};
|
||||
use crate::config::{WebDebugBodyCapture, WebDebugConfig};
|
||||
use crate::web::frame::{FrameError, FrameType};
|
||||
|
||||
const USER_AGENT_MAX_BYTES: usize = 512;
|
||||
const MIN_REDACTION_BYTES: usize = 8;
|
||||
const MAX_REDACTION_BYTES: usize = 512;
|
||||
const MAX_HEADER_REDACTIONS: usize = 16;
|
||||
|
||||
/// Calculates a conservative request metadata and credential lease.
|
||||
pub(super) fn request_dynamic_bytes<B>(
|
||||
request: &hyper::Request<B>,
|
||||
policy: &WebDebugConfig,
|
||||
) -> usize {
|
||||
request
|
||||
.method()
|
||||
.as_str()
|
||||
.len()
|
||||
.saturating_add(request.uri().path().len())
|
||||
.saturating_add(
|
||||
request
|
||||
.headers()
|
||||
.get(header::USER_AGENT)
|
||||
.map_or(0, |value| lossy_text_reservation(value.as_bytes(), USER_AGENT_MAX_BYTES)),
|
||||
)
|
||||
.saturating_add(
|
||||
policy
|
||||
.capture_headers
|
||||
.then(|| sanitized_header_bytes(request.headers()))
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.saturating_add(sensitive_value_bytes(
|
||||
request.headers(),
|
||||
request.uri().query(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Calculates a conservative response metadata and credential lease.
|
||||
pub(super) fn response_dynamic_bytes<B>(
|
||||
response: &hyper::Response<B>,
|
||||
policy: &WebDebugConfig,
|
||||
) -> usize {
|
||||
policy
|
||||
.capture_headers
|
||||
.then(|| sanitized_header_bytes(response.headers()))
|
||||
.unwrap_or(0)
|
||||
.saturating_add(sensitive_value_bytes(response.headers(), None))
|
||||
}
|
||||
|
||||
/// Copies header names and only allowlisted bounded values.
|
||||
pub(super) fn sanitized_headers(headers: &hyper::HeaderMap) -> Vec<TraceHeader> {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| TraceHeader {
|
||||
name: name.as_str().to_string(),
|
||||
value: header_value_allowed(name)
|
||||
.then(|| bounded_text(value.as_bytes(), 4096)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copies a bounded set of ephemeral credentials into zeroizing scrub patterns.
|
||||
pub(super) fn sensitive_values(
|
||||
headers: &hyper::HeaderMap,
|
||||
query: Option<&str>,
|
||||
) -> Vec<Zeroizing<Vec<u8>>> {
|
||||
let mut values = Vec::new();
|
||||
for name in [
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-session-token",
|
||||
"sec-websocket-protocol",
|
||||
] {
|
||||
for value in headers.get_all(name) {
|
||||
push_redaction(&mut values, value.as_bytes());
|
||||
if let Some(token) = value.as_bytes().strip_prefix(b"Bearer ") {
|
||||
push_redaction(&mut values, token);
|
||||
}
|
||||
if values.len() >= MAX_HEADER_REDACTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if values.len() >= MAX_HEADER_REDACTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(capability) = query.and_then(|query| query.strip_prefix("bridge=")) {
|
||||
push_redaction(&mut values, capability.as_bytes());
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
/// Converts at most `limit` raw bytes into loss-tolerant display text.
|
||||
pub(super) fn bounded_text(value: &[u8], limit: usize) -> String {
|
||||
String::from_utf8_lossy(&value[..value.len().min(limit)]).into_owned()
|
||||
}
|
||||
|
||||
/// Overwrites complete credentials and a prefix cut by body truncation.
|
||||
pub(super) fn scrub_body(body: &mut [u8], redactions: &[Zeroizing<Vec<u8>>]) {
|
||||
for secret in redactions.iter().filter(|secret| !secret.is_empty()) {
|
||||
if secret.len() <= body.len() {
|
||||
for offset in 0..=body.len() - secret.len() {
|
||||
if body[offset..].starts_with(secret) {
|
||||
body[offset..offset + secret.len()].fill(b'*');
|
||||
}
|
||||
}
|
||||
}
|
||||
let overlap = secret.len().min(body.len());
|
||||
for length in (1..=overlap).rev() {
|
||||
if body.ends_with(&secret[..length]) {
|
||||
let start = body.len() - length;
|
||||
body[start..].fill(b'*');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the route-sensitive retained body allocation ceiling.
|
||||
pub(super) fn capture_limit(
|
||||
policy: &WebDebugConfig,
|
||||
route: TraceRoute,
|
||||
max_carrier_body_bytes: usize,
|
||||
) -> Option<usize> {
|
||||
match policy.body_capture {
|
||||
WebDebugBodyCapture::Off | WebDebugBodyCapture::Metadata => None,
|
||||
WebDebugBodyCapture::Prefix => Some(if decoy_route(route) {
|
||||
policy.decoy_body_prefix_bytes
|
||||
} else {
|
||||
policy.body_prefix_bytes
|
||||
}),
|
||||
WebDebugBodyCapture::Full => Some(if decoy_route(route) {
|
||||
policy.decoy_body_prefix_bytes
|
||||
} else {
|
||||
max_carrier_body_bytes
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a closed display label for one parsed frame type.
|
||||
pub(super) fn frame_type_name(frame_type: FrameType) -> &'static str {
|
||||
match frame_type {
|
||||
FrameType::Open => "OPEN",
|
||||
FrameType::Data => "DATA",
|
||||
FrameType::Close => "CLOSE",
|
||||
FrameType::Window => "WINDOW",
|
||||
FrameType::Ping => "PING",
|
||||
FrameType::Pong => "PONG",
|
||||
FrameType::Hello => "HELLO",
|
||||
FrameType::Welcome => "WELCOME",
|
||||
FrameType::Bye => "BYE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a closed display label for one frame parse failure.
|
||||
pub(super) fn frame_error_name(error: FrameError) -> &'static str {
|
||||
match error {
|
||||
FrameError::EmptyBatch => "empty_batch",
|
||||
FrameError::TooManyFrames => "too_many_frames",
|
||||
FrameError::Incomplete => "incomplete",
|
||||
FrameError::PayloadLimit => "payload_limit",
|
||||
FrameError::UnknownType => "unknown_type",
|
||||
FrameError::InvalidShape => "invalid_shape",
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitized_header_bytes(headers: &hyper::HeaderMap) -> usize {
|
||||
headers.iter().fold(0usize, |total, (name, value)| {
|
||||
total
|
||||
.saturating_add(std::mem::size_of::<TraceHeader>())
|
||||
.saturating_add(name.as_str().len())
|
||||
.saturating_add(
|
||||
header_value_allowed(name)
|
||||
.then(|| lossy_text_reservation(value.as_bytes(), 4096))
|
||||
.unwrap_or(0),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn header_value_allowed(name: &hyper::header::HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"host"
|
||||
| "user-agent"
|
||||
| "content-type"
|
||||
| "content-length"
|
||||
| "origin"
|
||||
| "x-forwarded-for"
|
||||
| "x-up-seq"
|
||||
| "x-up-ack"
|
||||
| "x-down-cursor"
|
||||
| "x-lane-id"
|
||||
| "x-lane-closed"
|
||||
| "x-carrier-mode"
|
||||
| "retry-after"
|
||||
| "cache-control"
|
||||
| "etag"
|
||||
| "if-none-match"
|
||||
| "accept"
|
||||
| "accept-encoding"
|
||||
)
|
||||
}
|
||||
|
||||
fn sensitive_value_bytes(headers: &hyper::HeaderMap, query: Option<&str>) -> usize {
|
||||
let mut total = 0usize;
|
||||
let mut retained = 0usize;
|
||||
for name in [
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"x-session-token",
|
||||
"sec-websocket-protocol",
|
||||
] {
|
||||
for value in headers.get_all(name) {
|
||||
for candidate in [
|
||||
Some(value.as_bytes()),
|
||||
value.as_bytes().strip_prefix(b"Bearer "),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if (MIN_REDACTION_BYTES..=MAX_REDACTION_BYTES).contains(&candidate.len()) {
|
||||
total = total
|
||||
.saturating_add(std::mem::size_of::<Vec<u8>>())
|
||||
.saturating_add(candidate.len());
|
||||
retained += 1;
|
||||
if retained >= MAX_HEADER_REDACTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if retained >= MAX_HEADER_REDACTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if retained >= MAX_HEADER_REDACTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(capability) = query.and_then(|query| query.strip_prefix("bridge="))
|
||||
&& (MIN_REDACTION_BYTES..=MAX_REDACTION_BYTES).contains(&capability.len())
|
||||
{
|
||||
total = total
|
||||
.saturating_add(std::mem::size_of::<Vec<u8>>())
|
||||
.saturating_add(capability.len());
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn lossy_text_reservation(value: &[u8], limit: usize) -> usize {
|
||||
value.len().min(limit).saturating_mul(3)
|
||||
}
|
||||
|
||||
fn decoy_route(route: TraceRoute) -> bool {
|
||||
matches!(route, TraceRoute::Unknown | TraceRoute::Decoy)
|
||||
}
|
||||
|
||||
fn push_redaction(values: &mut Vec<Zeroizing<Vec<u8>>>, value: &[u8]) {
|
||||
if value.len() < MIN_REDACTION_BYTES || value.len() > MAX_REDACTION_BYTES {
|
||||
return;
|
||||
}
|
||||
if !values.iter().any(|existing| existing.as_slice() == value) {
|
||||
values.push(Zeroizing::new(value.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_capture_keeps_decoy_bodies_prefix_bounded() {
|
||||
let mut policy = WebDebugConfig::default();
|
||||
policy.body_capture = WebDebugBodyCapture::Full;
|
||||
policy.decoy_body_prefix_bytes = 123;
|
||||
assert_eq!(capture_limit(&policy, TraceRoute::Decoy, 4096), Some(123));
|
||||
assert_eq!(capture_limit(&policy, TraceRoute::Uplink, 4096), Some(4096));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_removes_complete_and_prefix_truncated_credentials() {
|
||||
let secret = Zeroizing::new(b"credential-value".to_vec());
|
||||
let mut complete = b"before credential-value after".to_vec();
|
||||
scrub_body(&mut complete, std::slice::from_ref(&secret));
|
||||
assert!(
|
||||
!complete
|
||||
.windows(secret.len())
|
||||
.any(|value| value == secret.as_slice())
|
||||
);
|
||||
|
||||
let mut truncated = b"before credent".to_vec();
|
||||
scrub_body(&mut truncated, &[secret]);
|
||||
assert!(truncated.ends_with(b"*******"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
use super::exchange::HttpTraceExchange;
|
||||
use super::types::{
|
||||
TraceIdentity, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind,
|
||||
};
|
||||
use crate::config::{WebDebugConfig, WebLimitsConfig};
|
||||
|
||||
const BASE_RECORD_RESERVATION: usize = 1024;
|
||||
|
||||
struct RingState {
|
||||
records: VecDeque<Arc<StoredTraceRecord>>,
|
||||
}
|
||||
|
||||
/// One retained record whose lease survives status-page snapshots.
|
||||
pub(crate) struct StoredTraceRecord {
|
||||
/// Immutable trace record.
|
||||
pub(crate) record: TraceRecord,
|
||||
bytes: usize,
|
||||
used_bytes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Drop for StoredTraceRecord {
|
||||
fn drop(&mut self) {
|
||||
self.used_bytes.fetch_sub(self.bytes, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point-in-time process-owned trace counters and ring bounds.
|
||||
pub(crate) struct TraceStoreStatus {
|
||||
/// Current debug policy.
|
||||
pub(crate) policy: Arc<WebDebugConfig>,
|
||||
/// Retained record count.
|
||||
pub(crate) records: usize,
|
||||
/// Configured record capacity.
|
||||
pub(crate) records_capacity: usize,
|
||||
/// Retained plus in-flight byte leases.
|
||||
pub(crate) used_bytes: usize,
|
||||
/// Configured byte capacity.
|
||||
pub(crate) bytes_capacity: usize,
|
||||
/// Records dropped on commit-lock contention.
|
||||
pub(crate) contention_drops: u64,
|
||||
/// Oldest records evicted by ring capacity.
|
||||
pub(crate) evictions: u64,
|
||||
/// Body or metadata captures truncated by policy or byte capacity.
|
||||
pub(crate) byte_truncations: u64,
|
||||
/// Earliest retained record sequence.
|
||||
pub(crate) earliest_seq: Option<u64>,
|
||||
/// Latest retained record sequence.
|
||||
pub(crate) latest_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// Process-owned bounded WEB debug trace store.
|
||||
pub(crate) struct WebTraceStore {
|
||||
policy: ArcSwap<WebDebugConfig>,
|
||||
policy_update: Mutex<()>,
|
||||
enabled: AtomicBool,
|
||||
epoch: AtomicU64,
|
||||
records_capacity: usize,
|
||||
bytes_capacity: usize,
|
||||
max_carrier_body_bytes: usize,
|
||||
used_bytes: Arc<AtomicUsize>,
|
||||
ring: Mutex<RingState>,
|
||||
next_record_seq: AtomicU64,
|
||||
next_session_id: AtomicU64,
|
||||
contention_drops: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
byte_truncations: AtomicU64,
|
||||
renders: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl WebTraceStore {
|
||||
/// Creates a process-owned store from restart-only capacity and initial policy.
|
||||
pub(crate) fn new(policy: WebDebugConfig, limits: &WebLimitsConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
enabled: AtomicBool::new(policy.enabled),
|
||||
policy: ArcSwap::from_pointee(policy),
|
||||
policy_update: Mutex::new(()),
|
||||
epoch: AtomicU64::new(1),
|
||||
records_capacity: limits.debug_records_capacity,
|
||||
bytes_capacity: limits.debug_bytes_global,
|
||||
max_carrier_body_bytes: limits.max_body_bytes,
|
||||
used_bytes: Arc::new(AtomicUsize::new(0)),
|
||||
ring: Mutex::new(RingState {
|
||||
records: VecDeque::with_capacity(limits.debug_records_capacity),
|
||||
}),
|
||||
next_record_seq: AtomicU64::new(1),
|
||||
next_session_id: AtomicU64::new(1),
|
||||
contention_drops: AtomicU64::new(0),
|
||||
evictions: AtomicU64::new(0),
|
||||
byte_truncations: AtomicU64::new(0),
|
||||
renders: Arc::new(Semaphore::new(2)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies one hot policy and clears incompatible retained records.
|
||||
pub(crate) fn apply_policy(&self, policy: &WebDebugConfig) {
|
||||
let _policy_update = self.policy_update.lock();
|
||||
let current = self.policy.load_full();
|
||||
if current.as_ref() == policy {
|
||||
return;
|
||||
}
|
||||
let capture_changed = current.enabled != policy.enabled
|
||||
|| current.capture_lifecycle != policy.capture_lifecycle
|
||||
|| current.capture_headers != policy.capture_headers
|
||||
|| current.capture_timings != policy.capture_timings
|
||||
|| current.capture_frames != policy.capture_frames
|
||||
|| current.body_capture != policy.body_capture
|
||||
|| current.body_prefix_bytes != policy.body_prefix_bytes
|
||||
|| current.decoy_body_prefix_bytes != policy.decoy_body_prefix_bytes;
|
||||
self.policy.store(Arc::new(policy.clone()));
|
||||
self.enabled.store(policy.enabled, Ordering::Release);
|
||||
if capture_changed {
|
||||
self.epoch.fetch_add(1, Ordering::AcqRel);
|
||||
self.ring.lock().records.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocates a process-unique monotonic WEB session trace identifier.
|
||||
pub(crate) fn next_session_id(&self) -> u64 {
|
||||
self.next_session_id.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Starts one HTTP exchange only when debugging is enabled and budgeted.
|
||||
pub(crate) fn begin_http<B>(
|
||||
self: &Arc<Self>,
|
||||
request: &hyper::Request<B>,
|
||||
peer_ip: IpAddr,
|
||||
) -> Option<Arc<HttpTraceExchange>> {
|
||||
if !self.enabled.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
let epoch = self.epoch.load(Ordering::Acquire);
|
||||
let policy = self.policy.load_full();
|
||||
if !policy.enabled || !self.try_reserve_record(BASE_RECORD_RESERVATION) {
|
||||
return None;
|
||||
}
|
||||
Some(HttpTraceExchange::new(
|
||||
Arc::clone(self),
|
||||
epoch,
|
||||
policy,
|
||||
request,
|
||||
peer_ip,
|
||||
BASE_RECORD_RESERVATION,
|
||||
))
|
||||
}
|
||||
|
||||
/// Records one typed lifecycle event without retaining dynamic error strings.
|
||||
pub(crate) fn record_lifecycle(
|
||||
&self,
|
||||
peer_ip: Option<IpAddr>,
|
||||
effective_ip: Option<IpAddr>,
|
||||
identity: TraceIdentity,
|
||||
event: TraceLifecycleEvent,
|
||||
stream_id: Option<u32>,
|
||||
reason: Option<&'static str>,
|
||||
) {
|
||||
if !self.enabled.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let epoch = self.epoch.load(Ordering::Acquire);
|
||||
let policy = self.policy.load_full();
|
||||
let identity_bytes = identity
|
||||
.user
|
||||
.as_ref()
|
||||
.map_or(0, String::len)
|
||||
.checked_add(
|
||||
identity
|
||||
.key_fingerprint
|
||||
.as_ref()
|
||||
.map_or(0, String::len),
|
||||
);
|
||||
let Some(reservation) = identity_bytes
|
||||
.and_then(|bytes| BASE_RECORD_RESERVATION.checked_add(bytes))
|
||||
else {
|
||||
self.record_truncation();
|
||||
return;
|
||||
};
|
||||
if !policy.enabled
|
||||
|| !policy.capture_lifecycle
|
||||
|| !self.try_reserve_record(reservation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let record = TraceRecord {
|
||||
seq: self.next_record_seq(),
|
||||
epoch_millis: epoch_millis(),
|
||||
peer_ip,
|
||||
effective_ip,
|
||||
user_agent: None,
|
||||
identity,
|
||||
kind: TraceRecordKind::Lifecycle(TraceLifecycleRecord {
|
||||
event,
|
||||
stream_id,
|
||||
reason,
|
||||
}),
|
||||
};
|
||||
if !self.try_commit(record, reservation, epoch) {
|
||||
self.release(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records lifecycle identity from a profile only after enabled-policy checks.
|
||||
pub(crate) fn record_profile_lifecycle(
|
||||
&self,
|
||||
effective_ip: IpAddr,
|
||||
session_id: Option<u64>,
|
||||
profile: &crate::config::WebRuntimeProfile,
|
||||
event: TraceLifecycleEvent,
|
||||
stream_id: Option<u32>,
|
||||
reason: Option<&'static str>,
|
||||
) {
|
||||
if !self.enabled.load(Ordering::Acquire) || !self.policy.load().capture_lifecycle {
|
||||
return;
|
||||
}
|
||||
self.record_lifecycle(
|
||||
None,
|
||||
Some(effective_ip),
|
||||
TraceIdentity::from_optional_profile(session_id, profile),
|
||||
event,
|
||||
stream_id,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns records matching the supplied predicate in newest-first order.
|
||||
pub(crate) fn snapshot_matching<F>(&self, mut matches: F) -> Vec<Arc<StoredTraceRecord>>
|
||||
where
|
||||
F: FnMut(&TraceRecord) -> bool,
|
||||
{
|
||||
self.ring
|
||||
.lock()
|
||||
.records
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|record| matches(&record.record))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns current bounds, counters, and retained sequence range.
|
||||
pub(crate) fn status(&self) -> TraceStoreStatus {
|
||||
let ring = self.ring.lock();
|
||||
TraceStoreStatus {
|
||||
policy: self.policy.load_full(),
|
||||
records: ring.records.len(),
|
||||
records_capacity: self.records_capacity,
|
||||
used_bytes: self.used_bytes.load(Ordering::Acquire),
|
||||
bytes_capacity: self.bytes_capacity,
|
||||
contention_drops: self.contention_drops.load(Ordering::Relaxed),
|
||||
evictions: self.evictions.load(Ordering::Relaxed),
|
||||
byte_truncations: self.byte_truncations.load(Ordering::Relaxed),
|
||||
earliest_seq: ring.records.front().map(|record| record.record.seq),
|
||||
latest_seq: ring.records.back().map(|record| record.record.seq),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserves one of two bounded concurrent status-page render slots.
|
||||
pub(crate) fn try_render_permit(&self) -> Option<OwnedSemaphorePermit> {
|
||||
Arc::clone(&self.renders).try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
/// Returns the current capture-policy epoch.
|
||||
pub(super) fn policy_epoch(&self) -> u64 {
|
||||
self.epoch.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Returns the restart-frozen recognized carrier body ceiling.
|
||||
pub(super) fn max_carrier_body_bytes(&self) -> usize {
|
||||
self.max_carrier_body_bytes
|
||||
}
|
||||
|
||||
/// Atomically reserves debug bytes or records one truncation.
|
||||
pub(super) fn try_reserve(&self, bytes: usize) -> bool {
|
||||
if self.try_reserve_inner(bytes) {
|
||||
true
|
||||
} else {
|
||||
self.byte_truncations.fetch_add(1, Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_inner(&self, bytes: usize) -> bool {
|
||||
let mut current = self.used_bytes.load(Ordering::Acquire);
|
||||
loop {
|
||||
let Some(next) = current.checked_add(bytes) else {
|
||||
return false;
|
||||
};
|
||||
if next > self.bytes_capacity {
|
||||
return false;
|
||||
}
|
||||
match self.used_bytes.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return true,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_record(&self, bytes: usize) -> bool {
|
||||
if self.try_reserve_inner(bytes) {
|
||||
return true;
|
||||
}
|
||||
let Some(mut ring) = self.ring.try_lock() else {
|
||||
self.byte_truncations.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
while let Some(record) = ring.records.pop_front() {
|
||||
self.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
drop(record);
|
||||
if self.try_reserve_inner(bytes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.byte_truncations.fetch_add(1, Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
|
||||
/// Releases an in-flight lease that was not transferred into a record.
|
||||
pub(super) fn release(&self, bytes: usize) {
|
||||
self.used_bytes.fetch_sub(bytes, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Increments the closed truncation counter.
|
||||
pub(super) fn record_truncation(&self) {
|
||||
self.byte_truncations.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Allocates a process-monotonic record sequence number.
|
||||
pub(super) fn next_record_seq(&self) -> u64 {
|
||||
self.next_record_seq.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Attempts one non-blocking ring commit under the originating policy epoch.
|
||||
pub(super) fn try_commit(&self, record: TraceRecord, bytes: usize, epoch: u64) -> bool {
|
||||
if epoch != self.policy_epoch() {
|
||||
return false;
|
||||
}
|
||||
let Some(mut ring) = self.ring.try_lock() else {
|
||||
self.contention_drops.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
if epoch != self.policy_epoch() {
|
||||
return false;
|
||||
}
|
||||
while ring.records.len() >= self.records_capacity {
|
||||
ring.records.pop_front();
|
||||
self.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
ring.records.push_back(Arc::new(StoredTraceRecord {
|
||||
record,
|
||||
bytes,
|
||||
used_bytes: Arc::clone(&self.used_bytes),
|
||||
}));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Unix epoch milliseconds with saturation for stored display timestamps.
|
||||
pub(crate) fn epoch_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store(records_capacity: usize, bytes_capacity: usize) -> Arc<WebTraceStore> {
|
||||
let mut policy = WebDebugConfig::default();
|
||||
policy.enabled = true;
|
||||
let mut limits = WebLimitsConfig::default();
|
||||
limits.debug_records_capacity = records_capacity;
|
||||
limits.debug_bytes_global = bytes_capacity;
|
||||
WebTraceStore::new(policy, &limits)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_evicts_oldest_records_and_snapshot_leases_survive_clear() {
|
||||
let store = store(2, 4 * BASE_RECORD_RESERVATION);
|
||||
for _ in 0..3 {
|
||||
store.record_lifecycle(
|
||||
None,
|
||||
Some("192.0.2.10".parse().unwrap()),
|
||||
TraceIdentity::default(),
|
||||
TraceLifecycleEvent::BridgeIssued,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let snapshot = store.snapshot_matching(|_| true);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|record| record.record.seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![3, 2]
|
||||
);
|
||||
assert_eq!(store.status().evictions, 1);
|
||||
assert_eq!(store.status().used_bytes, 2 * BASE_RECORD_RESERVATION);
|
||||
|
||||
let policy = WebDebugConfig::default();
|
||||
store.apply_policy(&policy);
|
||||
assert_eq!(store.status().records, 0);
|
||||
assert_eq!(store.status().used_bytes, 2 * BASE_RECORD_RESERVATION);
|
||||
drop(snapshot);
|
||||
assert_eq!(store.status().used_bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_policy_epoch_rejects_an_inflight_old_policy_record() {
|
||||
let store = store(4, 8 * BASE_RECORD_RESERVATION);
|
||||
let request = hyper::Request::builder().uri("/").body(()).unwrap();
|
||||
let exchange = store
|
||||
.begin_http(&request, "192.0.2.20".parse().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let mut changed = WebDebugConfig::default();
|
||||
changed.enabled = true;
|
||||
changed.capture_headers = false;
|
||||
store.apply_policy(&changed);
|
||||
exchange.commit();
|
||||
|
||||
assert_eq!(store.status().records, 0);
|
||||
assert_eq!(store.status().used_bytes, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// WEB HTTP route classification retained by the debug trace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TraceRoute {
|
||||
/// Routing has not completed yet.
|
||||
Unknown,
|
||||
/// Ordinary static or upstream fallback traffic.
|
||||
Decoy,
|
||||
/// Authenticated bridge page issuance.
|
||||
Bridge,
|
||||
/// Bootstrap-to-session exchange.
|
||||
Session,
|
||||
/// Carrier uplink exchange.
|
||||
Uplink,
|
||||
/// Carrier downlink exchange.
|
||||
Downlink,
|
||||
}
|
||||
|
||||
impl TraceRoute {
|
||||
/// Returns the stable status-page label.
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Decoy => "decoy",
|
||||
Self::Bridge => "bridge",
|
||||
Self::Session => "session",
|
||||
Self::Uplink => "uplink",
|
||||
Self::Downlink => "downlink",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of captured HTTP body or carrier frame data.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TraceDirection {
|
||||
/// Client-to-server data.
|
||||
Request,
|
||||
/// Server-to-client data.
|
||||
Response,
|
||||
}
|
||||
|
||||
impl TraceDirection {
|
||||
/// Returns the stable status-page label.
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Request => "request",
|
||||
Self::Response => "response",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal observation state of one HTTP body.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TraceBodyState {
|
||||
/// The body completed normally.
|
||||
Complete,
|
||||
/// Body polling returned an error.
|
||||
Error,
|
||||
/// The body was dropped before a terminal poll.
|
||||
Aborted,
|
||||
}
|
||||
|
||||
impl TraceBodyState {
|
||||
/// Returns the stable status-page label.
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Complete => "complete",
|
||||
Self::Error => "error",
|
||||
Self::Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One sanitized HTTP header.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceHeader {
|
||||
/// Lowercase header name.
|
||||
pub(crate) name: String,
|
||||
/// Allowlisted value, or `None` when only the name may be retained.
|
||||
pub(crate) value: Option<String>,
|
||||
}
|
||||
|
||||
/// One bounded HTTP body observation.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceBodySnapshot {
|
||||
/// Total bytes observed on the wire-facing Hyper body.
|
||||
pub(crate) observed_bytes: u64,
|
||||
/// Retained prefix or full bounded body bytes.
|
||||
pub(crate) captured: Vec<u8>,
|
||||
/// Indicates that bytes were omitted by policy or capacity.
|
||||
pub(crate) truncated: bool,
|
||||
/// Terminal body state.
|
||||
pub(crate) state: TraceBodyState,
|
||||
}
|
||||
|
||||
/// One parsed carrier frame without payload retention.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceFrame {
|
||||
/// HTTP body direction containing the frame.
|
||||
pub(crate) direction: TraceDirection,
|
||||
/// Stable protocol frame type, when the header parsed.
|
||||
pub(crate) frame_type: Option<&'static str>,
|
||||
/// Logical stream or lane identifier.
|
||||
pub(crate) stream_id: Option<u32>,
|
||||
/// Frame payload length.
|
||||
pub(crate) payload_len: Option<usize>,
|
||||
/// Decoded non-zero WINDOW delta.
|
||||
pub(crate) window_delta: Option<u32>,
|
||||
/// Closed parse or shape error category.
|
||||
pub(crate) parse_error: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Request lifecycle timing points measured from service entry.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TraceTimings {
|
||||
/// Request body terminal poll time.
|
||||
pub(crate) request_body_us: Option<u64>,
|
||||
/// Handler response-ready time.
|
||||
pub(crate) response_ready_us: Option<u64>,
|
||||
/// Response body terminal poll or drop time.
|
||||
pub(crate) response_body_us: Option<u64>,
|
||||
}
|
||||
|
||||
/// Stable non-secret WEB trace identity.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct TraceIdentity {
|
||||
/// Process-unique monotonic WEB session identifier.
|
||||
pub(crate) session_id: Option<u64>,
|
||||
/// Exact access user name.
|
||||
pub(crate) user: Option<String>,
|
||||
/// Domain-separated client-secret fingerprint.
|
||||
pub(crate) key_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
impl TraceIdentity {
|
||||
/// Builds a non-secret identity from one validated runtime profile.
|
||||
pub(crate) fn from_profile(
|
||||
session_id: u64,
|
||||
profile: &crate::config::WebRuntimeProfile,
|
||||
) -> Self {
|
||||
Self::from_optional_profile(Some(session_id), profile)
|
||||
}
|
||||
|
||||
/// Builds a profile identity when admission failed before session allocation.
|
||||
pub(crate) fn from_optional_profile(
|
||||
session_id: Option<u64>,
|
||||
profile: &crate::config::WebRuntimeProfile,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
user: Some(profile.user.clone()),
|
||||
key_fingerprint: Some(profile.key_fingerprint.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete request-to-response WEB HTTP trace.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceHttpRecord {
|
||||
/// HTTP method.
|
||||
pub(crate) method: String,
|
||||
/// URI path without query material.
|
||||
pub(crate) path: String,
|
||||
/// Final route classification.
|
||||
pub(crate) route: TraceRoute,
|
||||
/// Sanitized request headers.
|
||||
pub(crate) request_headers: Vec<TraceHeader>,
|
||||
/// Sanitized response headers.
|
||||
pub(crate) response_headers: Vec<TraceHeader>,
|
||||
/// Request body observation when enabled by policy.
|
||||
pub(crate) request_body: Option<TraceBodySnapshot>,
|
||||
/// Response status when a response head was produced.
|
||||
pub(crate) status: Option<u16>,
|
||||
/// Response body observation when enabled by policy.
|
||||
pub(crate) response_body: Option<TraceBodySnapshot>,
|
||||
/// Parsed carrier frame metadata.
|
||||
pub(crate) frames: Vec<TraceFrame>,
|
||||
/// Monotonic request timing points.
|
||||
pub(crate) timings: Option<TraceTimings>,
|
||||
}
|
||||
|
||||
/// Closed WEB lifecycle event category.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TraceLifecycleEvent {
|
||||
/// A bridge bootstrap was issued.
|
||||
BridgeIssued,
|
||||
/// Bootstrap or bridge admission was rejected.
|
||||
BootstrapRejected,
|
||||
/// A new session was created.
|
||||
SessionCreated,
|
||||
/// An idempotent session creation was replayed.
|
||||
SessionReplayed,
|
||||
/// Session creation was rejected.
|
||||
SessionRejected,
|
||||
/// A session closed.
|
||||
SessionClosed,
|
||||
/// A logical stream was admitted.
|
||||
StreamAdmitted,
|
||||
/// A logical stream was rejected.
|
||||
StreamRejected,
|
||||
/// A logical stream delivered its first inner byte.
|
||||
StreamFirstByte,
|
||||
/// An admitted logical stream released its relay and tuple ownership.
|
||||
StreamClosed,
|
||||
/// The inner MTProxy handshake succeeded.
|
||||
HandshakeSucceeded,
|
||||
/// The inner MTProxy handshake timed out.
|
||||
HandshakeTimeout,
|
||||
/// The inner MTProxy handshake failed on I/O.
|
||||
HandshakeIo,
|
||||
/// The inner MTProxy handshake was rejected.
|
||||
HandshakeRejected,
|
||||
/// Authenticated relay started.
|
||||
RelayStarted,
|
||||
/// Authenticated relay ended.
|
||||
RelayEnded,
|
||||
}
|
||||
|
||||
impl TraceLifecycleEvent {
|
||||
/// Returns the stable status-page label.
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::BridgeIssued => "bridge_issued",
|
||||
Self::BootstrapRejected => "bootstrap_rejected",
|
||||
Self::SessionCreated => "session_created",
|
||||
Self::SessionReplayed => "session_replayed",
|
||||
Self::SessionRejected => "session_rejected",
|
||||
Self::SessionClosed => "session_closed",
|
||||
Self::StreamAdmitted => "stream_admitted",
|
||||
Self::StreamRejected => "stream_rejected",
|
||||
Self::StreamFirstByte => "stream_first_byte",
|
||||
Self::StreamClosed => "stream_closed",
|
||||
Self::HandshakeSucceeded => "handshake_succeeded",
|
||||
Self::HandshakeTimeout => "handshake_timeout",
|
||||
Self::HandshakeIo => "handshake_io",
|
||||
Self::HandshakeRejected => "handshake_rejected",
|
||||
Self::RelayStarted => "relay_started",
|
||||
Self::RelayEnded => "relay_ended",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One typed WEB lifecycle observation.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceLifecycleRecord {
|
||||
/// Closed event category.
|
||||
pub(crate) event: TraceLifecycleEvent,
|
||||
/// Logical stream identifier when applicable.
|
||||
pub(crate) stream_id: Option<u32>,
|
||||
/// Closed outcome or rejection reason.
|
||||
pub(crate) reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Trace record payload variant.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TraceRecordKind {
|
||||
/// HTTP request-to-response exchange.
|
||||
Http(TraceHttpRecord),
|
||||
/// Session or stream lifecycle event.
|
||||
Lifecycle(TraceLifecycleRecord),
|
||||
}
|
||||
|
||||
/// Common stored WEB trace record.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TraceRecord {
|
||||
/// Process-unique monotonic record sequence.
|
||||
pub(crate) seq: u64,
|
||||
/// Event completion time in Unix epoch milliseconds.
|
||||
pub(crate) epoch_millis: u64,
|
||||
/// Direct TCP peer accepted by the WEB listener.
|
||||
pub(crate) peer_ip: Option<IpAddr>,
|
||||
/// Trusted effective client identity.
|
||||
pub(crate) effective_ip: Option<IpAddr>,
|
||||
/// Bounded user-agent value retained for filtering.
|
||||
pub(crate) user_agent: Option<String>,
|
||||
/// Non-secret WEB identity.
|
||||
pub(crate) identity: TraceIdentity,
|
||||
/// Typed record payload.
|
||||
pub(crate) kind: TraceRecordKind,
|
||||
}
|
||||
Reference in New Issue
Block a user