mirror of
https://github.com/telemt/telemt.git
synced 2026-10-10 11:25:57 +03:00
fca6506449
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
551 lines
20 KiB
Rust
551 lines
20 KiB
Rust
use std::convert::Infallible;
|
|
use std::error::Error;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use bytes::Bytes;
|
|
use http_body_util::BodyExt;
|
|
use http_body_util::combinators::UnsyncBoxBody;
|
|
use hyper::header::{self, HeaderName, HeaderValue};
|
|
use hyper::server::conn::http1;
|
|
use hyper::service::service_fn;
|
|
use hyper::{Method, Request, Response, StatusCode};
|
|
use hyper_util::rt::{TokioIo, TokioTimer};
|
|
use ipnetwork::IpNetwork;
|
|
use tokio::net::TcpStream;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::config::{WebClientIpSource, WebDecoyFastTrackMode, WebRuntimeVhost};
|
|
use crate::maestro::generation::RuntimeGeneration;
|
|
use crate::web::bridge;
|
|
use crate::web::manager::{ManagerError, WebProcessRuntime};
|
|
use crate::web::telemetry::WebDecoyFastTrackDisposition;
|
|
|
|
// Response-body activity keeps connection idle accounting lifecycle-correct.
|
|
mod activity;
|
|
// Body collection retains allocation permits through request processing.
|
|
mod body;
|
|
// Canonical capability parsing and complete scans remain isolated from HTTP routing.
|
|
mod capability;
|
|
// Authentic credential containment stays independent from carrier routing.
|
|
mod secrets;
|
|
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
|
mod decoy;
|
|
// Authenticated generated-bridge diagnostics remain outside carrier framing.
|
|
mod diagnostic;
|
|
// Downlink long-poll handling remains isolated from request routing.
|
|
mod down;
|
|
// Canonical request parsing rejects ambiguous credentials before routing.
|
|
mod request;
|
|
// Positive-only recovery representation stays separate from ordinary bridge rendering.
|
|
mod recovery;
|
|
// Carrier response construction and lane-header helpers are shared by handlers.
|
|
mod response;
|
|
// Session creation and replacement negotiation remain separate from request routing.
|
|
mod session;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
// RFC 6455 upgrade validation and carrier drivers remain isolated from HTTP routing.
|
|
mod websocket;
|
|
// Enabled-debug integration coverage remains separate from carrier behavior tests.
|
|
#[cfg(test)]
|
|
#[path = "http/trace_tests.rs"]
|
|
mod trace_tests;
|
|
|
|
use crate::web::trace::{HttpTraceExchange, TraceDirection, TraceLifecycleEvent, TraceRoute};
|
|
use activity::{ActivityBody, ConnectionActivity, RequestActivity, RequestDeadlineHandle};
|
|
use body::{CollectBodyError, CollectedBody, RequestBody, collect_body};
|
|
use capability::bridge_candidate;
|
|
use decoy::serve_decoy;
|
|
use down::handle_down;
|
|
use request::{
|
|
bearer_token_hash, binary_content_type, 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 session::handle_session;
|
|
|
|
type BoxError = Box<dyn Error + Send + Sync>;
|
|
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
|
|
type HttpResponse = Response<HttpBody>;
|
|
|
|
const TRANSPORT_SUFFIXES: [&str; 4] = [
|
|
"api/v1/session",
|
|
"api/v1/up",
|
|
"api/v1/down",
|
|
"api/v1/diagnostic",
|
|
];
|
|
const WEBSOCKET_SUFFIX: &str = "api/v1/ws";
|
|
|
|
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
|
|
pub(crate) async fn serve_connection(
|
|
stream: TcpStream,
|
|
peer: SocketAddr,
|
|
client_ip_source: WebClientIpSource,
|
|
trusted_proxy_cidrs: Arc<[IpNetwork]>,
|
|
runtime: Arc<WebProcessRuntime>,
|
|
cancellation: CancellationToken,
|
|
connection_permit: tokio::sync::OwnedSemaphorePermit,
|
|
) {
|
|
let config = runtime.active_generation().config();
|
|
let max_header_bytes = config.web.limits.max_header_bytes;
|
|
let header_timeout = Duration::from_secs(config.web.timeouts.header_secs);
|
|
let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs);
|
|
let connection_activity = ConnectionActivity::new();
|
|
let service_activity = connection_activity.clone();
|
|
let service = service_fn(move |mut request| {
|
|
let runtime = Arc::clone(&runtime);
|
|
let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs);
|
|
let connection_activity = service_activity.clone();
|
|
let client_ip_source = client_ip_source;
|
|
async move {
|
|
let Some(activity) = RequestActivity::begin(connection_activity) else {
|
|
let mut response = service_unavailable();
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONNECTION, HeaderValue::from_static("close"));
|
|
return Ok::<_, Infallible>(response);
|
|
};
|
|
request.extensions_mut().insert(activity.deadline_handle());
|
|
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,
|
|
peer,
|
|
client_ip_source,
|
|
&trusted_proxy_cidrs,
|
|
runtime,
|
|
)
|
|
.await
|
|
} else {
|
|
service_unavailable()
|
|
};
|
|
if let Some(trace) = &trace {
|
|
trace.response_ready(&response);
|
|
}
|
|
let response =
|
|
response.map(|body| ActivityBody::new(body, activity, trace).boxed_unsync());
|
|
Ok::<_, Infallible>(response)
|
|
}
|
|
});
|
|
let connection = http1::Builder::new()
|
|
.timer(TokioTimer::new())
|
|
.header_read_timeout(header_timeout)
|
|
.max_buf_size(max_header_bytes)
|
|
.keep_alive(true)
|
|
.serve_connection(
|
|
TokioIo::new(websocket::ConnectionIo::new(stream, connection_permit)),
|
|
service,
|
|
)
|
|
.with_upgrades();
|
|
tokio::pin!(connection);
|
|
let mut idle_check = tokio::time::interval((idle_timeout / 2).max(Duration::from_secs(1)));
|
|
idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancellation.cancelled() => break,
|
|
_ = &mut connection => break,
|
|
_ = idle_check.tick() => {
|
|
if connection_activity.should_close(tokio::time::Instant::now(), idle_timeout) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_request(
|
|
mut 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 {
|
|
return generic_not_found();
|
|
};
|
|
let Some(host) = canonical_request_host(&request) else {
|
|
return generic_not_found();
|
|
};
|
|
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
|
|
return generic_not_found();
|
|
};
|
|
secrets::mark_internal_credential(&mut request, web_runtime, &runtime);
|
|
let suffix = request.uri().path().strip_prefix(&vhost.base);
|
|
if suffix == Some(WEBSOCKET_SUFFIX) {
|
|
return websocket::handle(
|
|
request,
|
|
peer,
|
|
client_ip_source,
|
|
trusted_proxy_cidrs,
|
|
runtime,
|
|
vhost,
|
|
)
|
|
.await;
|
|
}
|
|
if suffix.is_some_and(|suffix| TRANSPORT_SUFFIXES.contains(&suffix)) {
|
|
return handle_api(
|
|
request,
|
|
peer,
|
|
client_ip_source,
|
|
trusted_proxy_cidrs,
|
|
runtime,
|
|
vhost,
|
|
)
|
|
.await;
|
|
}
|
|
if suffix == Some("") && matches!(*request.method(), Method::GET | Method::HEAD) {
|
|
return handle_root(
|
|
request,
|
|
peer,
|
|
client_ip_source,
|
|
trusted_proxy_cidrs,
|
|
runtime,
|
|
generation,
|
|
vhost,
|
|
)
|
|
.await;
|
|
}
|
|
let sanitize_recovery = recovery::has_media_type(&request);
|
|
if sanitize_recovery {
|
|
strip_query(&mut request);
|
|
}
|
|
serve_decoy(request, vhost, sanitize_recovery, &runtime).await
|
|
}
|
|
|
|
async fn handle_root(
|
|
mut request: Request<RequestBody>,
|
|
peer: SocketAddr,
|
|
client_ip_source: WebClientIpSource,
|
|
trusted_proxy_cidrs: &[IpNetwork],
|
|
runtime: Arc<WebProcessRuntime>,
|
|
generation: Arc<RuntimeGeneration>,
|
|
vhost: Arc<WebRuntimeVhost>,
|
|
) -> HttpResponse {
|
|
let representation = recovery::classify(&request);
|
|
if matches!(representation, recovery::RootRepresentation::Invalid) {
|
|
strip_query(&mut request);
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
let candidate = bridge_candidate(request.uri().query());
|
|
let canonical = candidate.is_canonical();
|
|
let plausible_candidate = canonical && request.method() == Method::GET;
|
|
let fasttrack_mode = vhost.decoy_fasttrack_mode;
|
|
match fasttrack_mode {
|
|
WebDecoyFastTrackMode::Off => {}
|
|
WebDecoyFastTrackMode::Shadow => {
|
|
runtime
|
|
.telemetry()
|
|
.record_decoy_fasttrack(if plausible_candidate {
|
|
WebDecoyFastTrackDisposition::ShadowCandidateFullScan
|
|
} else {
|
|
WebDecoyFastTrackDisposition::ShadowWouldFastTrack
|
|
});
|
|
}
|
|
WebDecoyFastTrackMode::Enforce if !plausible_candidate => {
|
|
runtime
|
|
.telemetry()
|
|
.record_decoy_fasttrack(WebDecoyFastTrackDisposition::EnforceFastTrack);
|
|
let recovery_requested =
|
|
matches!(representation, recovery::RootRepresentation::Recovery(_));
|
|
if recovery_requested {
|
|
strip_query(&mut request);
|
|
}
|
|
return serve_decoy(request, vhost, recovery_requested, &runtime).await;
|
|
}
|
|
WebDecoyFastTrackMode::Enforce => {
|
|
runtime
|
|
.telemetry()
|
|
.record_decoy_fasttrack(WebDecoyFastTrackDisposition::EnforceCandidateFullScan);
|
|
}
|
|
}
|
|
let matched_profile = match_profile(&vhost, candidate.scan_bytes());
|
|
let recovery_requested = matches!(representation, recovery::RootRepresentation::Recovery(_));
|
|
let profile = matched_profile.filter(|_| canonical && request.method() == Method::GET);
|
|
let Some(profile) = profile else {
|
|
if recovery_requested {
|
|
strip_query(&mut request);
|
|
}
|
|
return serve_decoy(request, vhost, recovery_requested, &runtime).await;
|
|
};
|
|
let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else {
|
|
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 user_agent = request
|
|
.headers()
|
|
.get(header::USER_AGENT)
|
|
.and_then(|value| value.to_str().ok());
|
|
let recovery_session = match representation {
|
|
recovery::RootRepresentation::Recovery(Some(hash)) => {
|
|
runtime.bridge_recovery_session(hash, &vhost.host, &profile)
|
|
}
|
|
_ => None,
|
|
};
|
|
let bootstrap = match match representation {
|
|
recovery::RootRepresentation::Bridge => runtime.issue_bootstrap_for_request(
|
|
&generation,
|
|
Arc::clone(&profile),
|
|
client_ip,
|
|
user_agent,
|
|
),
|
|
recovery::RootRepresentation::Recovery(_) => runtime.issue_recovery_bootstrap_for_request(
|
|
&generation,
|
|
Arc::clone(&profile),
|
|
client_ip,
|
|
user_agent,
|
|
recovery_session
|
|
.as_ref()
|
|
.map(|session| session.trace_session_id()),
|
|
),
|
|
recovery::RootRepresentation::Invalid => {
|
|
strip_query(&mut request);
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
} {
|
|
Ok(bootstrap) => bootstrap,
|
|
Err(error) => {
|
|
runtime.trace().record_profile_lifecycle(
|
|
client_ip,
|
|
None,
|
|
&profile,
|
|
TraceLifecycleEvent::BootstrapRejected,
|
|
None,
|
|
Some(error.as_str()),
|
|
);
|
|
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 config = generation.config();
|
|
if let recovery::RootRepresentation::Recovery(_) = representation {
|
|
if let Some(session) = recovery_session {
|
|
let outcome = session.close(crate::web::session::SessionCloseReason::BridgeRecovery);
|
|
if outcome != crate::web::session::SessionCloseOutcome::Closed
|
|
&& tokio::time::timeout(
|
|
Duration::from_secs(config.web.timeouts.bridge_request_secs),
|
|
session.wait_close_complete(),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
strip_query(&mut request);
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
}
|
|
let Some(response) = recovery::response(
|
|
&bootstrap,
|
|
&vhost,
|
|
&profile,
|
|
&config.web.limits,
|
|
&config.web.timeouts,
|
|
) else {
|
|
strip_query(&mut request);
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
};
|
|
return response;
|
|
}
|
|
let page = bridge::render(
|
|
&vhost.host,
|
|
&vhost.base,
|
|
&bootstrap.token,
|
|
config.web.limits.carrier_batch_bytes,
|
|
config.web.limits.pending_bytes_per_session,
|
|
config.web.limits.pending_items_per_session,
|
|
profile.max_streams_per_session,
|
|
profile.carrier_negotiation_enabled,
|
|
profile.carriers.len(),
|
|
profile.carrier_negotiation_deadlines_secs,
|
|
config.web.timeouts.long_poll_secs,
|
|
config.web.timeouts.bridge_request_secs,
|
|
config.web.timeouts.bridge_retry_secs,
|
|
config.web.timeouts.bridge_recovery_secs,
|
|
config.web.timeouts.websocket_open_secs,
|
|
config.web.timeouts.reconnect_grace_secs,
|
|
config.web.timeouts.carrier_probe_coalesce_ms,
|
|
config.web.debug.bridge_diagnostics_enabled(),
|
|
config.web.carrier_method,
|
|
&generation.rng,
|
|
);
|
|
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
);
|
|
insert_header(
|
|
&mut response,
|
|
header::CONTENT_SECURITY_POLICY,
|
|
&page.content_security_policy,
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response.headers_mut().insert(
|
|
header::REFERRER_POLICY,
|
|
HeaderValue::from_static("no-referrer"),
|
|
);
|
|
response.headers_mut().insert(
|
|
header::X_CONTENT_TYPE_OPTIONS,
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
response.headers_mut().insert(
|
|
HeaderName::from_static("x-dns-prefetch-control"),
|
|
HeaderValue::from_static("off"),
|
|
);
|
|
insert_header(
|
|
&mut response,
|
|
HeaderName::from_static("permissions-policy"),
|
|
bridge::PERMISSIONS_POLICY,
|
|
);
|
|
response
|
|
}
|
|
|
|
async fn handle_api(
|
|
request: Request<RequestBody>,
|
|
peer: SocketAddr,
|
|
client_ip_source: WebClientIpSource,
|
|
trusted_proxy_cidrs: &[IpNetwork],
|
|
runtime: Arc<WebProcessRuntime>,
|
|
vhost: Arc<WebRuntimeVhost>,
|
|
) -> HttpResponse {
|
|
if request.uri().query().is_some() || !compatible_cookie_header(&request) {
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
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;
|
|
};
|
|
match request.uri().path().strip_prefix(&vhost.base) {
|
|
Some("api/v1/session") => {
|
|
handle_session(request, runtime, vhost, token_hash, client_ip).await
|
|
}
|
|
Some("api/v1/up") => handle_up(request, runtime, vhost, token_hash).await,
|
|
Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await,
|
|
Some("api/v1/diagnostic") => {
|
|
diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await
|
|
}
|
|
_ => serve_decoy(request, vhost, true, &runtime).await,
|
|
}
|
|
}
|
|
async fn handle_up(
|
|
request: Request<RequestBody>,
|
|
runtime: Arc<WebProcessRuntime>,
|
|
vhost: Arc<WebRuntimeVhost>,
|
|
token_hash: crate::web::manager::TokenHash,
|
|
) -> HttpResponse {
|
|
if !matches!(*request.method(), Method::POST | Method::PUT) || !binary_content_type(&request) {
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
let Some(sequence) = canonical_u64_header(&request, "x-up-seq").filter(|value| *value != 0)
|
|
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 session.carrier().uses_websocket() {
|
|
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;
|
|
};
|
|
let limit = session.limits().max_body_bytes;
|
|
let CollectedBody {
|
|
request,
|
|
body,
|
|
_body_budget,
|
|
} = match collect_body(
|
|
request,
|
|
&runtime,
|
|
Duration::from_secs(session.timeouts().body_secs),
|
|
limit,
|
|
false,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(CollectBodyError::Limit) => return service_unavailable(),
|
|
Err(CollectBodyError::Invalid(request)) => {
|
|
return serve_decoy(request, vhost, true, &runtime).await;
|
|
}
|
|
};
|
|
if let Some(trace) = request_trace(&request) {
|
|
trace.record_frames(TraceDirection::Request, &body, session.limits());
|
|
}
|
|
let result = match lane_id {
|
|
Some(lane_id) => session.process_up_lane(lane_id, sequence, &body),
|
|
None => session.process_up(sequence, &body),
|
|
};
|
|
match result {
|
|
Ok(ack) => {
|
|
let mut response = carrier_empty(StatusCode::NO_CONTENT);
|
|
insert_header(
|
|
&mut response,
|
|
HeaderName::from_static("x-up-ack"),
|
|
&ack.to_string(),
|
|
);
|
|
response
|
|
}
|
|
Err(ManagerError::Backpressure | ManagerError::Concurrent | ManagerError::Limit) => {
|
|
service_unavailable()
|
|
}
|
|
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
|
}
|
|
}
|
|
|
|
fn strip_query<B>(request: &mut Request<B>) {
|
|
if request.uri().query().is_some()
|
|
&& let Ok(uri) = request.uri().path().parse()
|
|
{
|
|
*request.uri_mut() = uri;
|
|
}
|
|
}
|
|
|
|
fn request_trace<B>(request: &Request<B>) -> Option<&Arc<HttpTraceExchange>> {
|
|
request.extensions().get::<Arc<HttpTraceExchange>>()
|
|
}
|
|
|
|
fn request_deadline<B>(request: &Request<B>) -> Option<RequestDeadlineHandle> {
|
|
request.extensions().get::<RequestDeadlineHandle>().cloned()
|
|
}
|
|
|
|
fn set_trace_route<B>(request: &Request<B>, route: TraceRoute) {
|
|
if let Some(trace) = request_trace(request) {
|
|
trace.set_route(route);
|
|
}
|
|
}
|