mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-18 09:25:14 +02:00
358 lines
14 KiB
Rust
358 lines
14 KiB
Rust
//! The one thing this binary does: execute a rendered request and stream back what happened.
|
|
//!
|
|
//! This is the "execute" half of the desktop's `send_http_request` — the part after rendering
|
|
//! and before storage — driven through the same `HttpTransaction` the desktop drives, with the
|
|
//! same redirect loop, cookie jar, decompression and timeline events. Everything the desktop
|
|
//! would write to its database is written to the reply stream instead, and the tab stores it.
|
|
|
|
use crate::guard::{DestinationPolicy, GuardedSender};
|
|
use crate::wire::{Frame, SendRequest};
|
|
use base64::Engine;
|
|
use bytes::Bytes;
|
|
use log::{info, warn};
|
|
use std::convert::Infallible;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::sync::{mpsc, watch};
|
|
use yaak_http::client::{HttpConnectionOptions, HttpConnectionProxySetting};
|
|
use yaak_http::cookies::CookieStore;
|
|
use yaak_http::sender::{HttpResponseEvent, ReqwestSender};
|
|
use yaak_http::transaction::HttpTransaction;
|
|
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
|
use yaak_models::models::HttpResponseHeader;
|
|
|
|
/// How many frames may sit unread by the client before body reading pauses. Backpressure, so a
|
|
/// slow tab slows the upstream read rather than filling memory.
|
|
pub const FRAME_CHANNEL_CAPACITY: usize = 64;
|
|
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
|
const BODY_READ_CHUNK: usize = 64 * 1024;
|
|
|
|
/// What a send needs from the process, beyond the request itself.
|
|
pub struct SendLimits {
|
|
pub policy: DestinationPolicy,
|
|
pub max_response_bytes: usize,
|
|
pub max_timeout: Duration,
|
|
}
|
|
|
|
/// Why a send was refused before anything was put on the network. Distinct from a failure
|
|
/// mid-stream: these become a plain HTTP error, not a stream with an error frame.
|
|
#[derive(Debug)]
|
|
pub enum Refusal {
|
|
/// The request asks for something a browser-originated send cannot mean.
|
|
Unsupported(String),
|
|
/// The destination is not one this proxy will talk to.
|
|
Destination(String),
|
|
/// The request could not be turned into something sendable.
|
|
Invalid(String),
|
|
}
|
|
|
|
pub type FrameSender = mpsc::Sender<Result<Bytes, Infallible>>;
|
|
|
|
/// Check and prepare a send, then hand back the task that runs it. Refusals happen here, before
|
|
/// the caller has committed to a streaming response.
|
|
pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<PreparedSend, Refusal> {
|
|
let request = send.request;
|
|
|
|
// The engine reads files for these body types. There are no files here that a browser tab
|
|
// could legitimately mean, and letting a request name a path on this machine would be a
|
|
// local file read for anyone who can reach the proxy.
|
|
if request.body_type.as_deref() == Some("binary") {
|
|
return Err(Refusal::Unsupported(
|
|
"Binary file bodies can't be sent from the browser: the proxy has no access to your files"
|
|
.to_string(),
|
|
));
|
|
}
|
|
if request.body_type.as_deref() == Some("multipart/form-data") {
|
|
let names_a_file =
|
|
request.body.get("form").and_then(|f| f.as_array()).is_some_and(|entries| {
|
|
entries.iter().any(|e| {
|
|
e.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)
|
|
&& e.get("file").and_then(|v| v.as_str()).is_some_and(|f| !f.is_empty())
|
|
})
|
|
});
|
|
if names_a_file {
|
|
return Err(Refusal::Unsupported(
|
|
"Multipart file fields can't be sent from the browser: the proxy has no access to your files"
|
|
.to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// The tab's requested timeout, capped. Zero means "none", which here means the cap.
|
|
let requested = if send.settings.timeout_ms > 0 {
|
|
Some(Duration::from_millis(send.settings.timeout_ms as u64))
|
|
} else {
|
|
None
|
|
};
|
|
let timeout = requested.map_or(limits.max_timeout, |t| t.min(limits.max_timeout));
|
|
let timeout_capped = requested.is_none_or(|t| t > limits.max_timeout);
|
|
|
|
let sendable = SendableHttpRequest::from_http_request(
|
|
&request,
|
|
SendableHttpRequestOptions {
|
|
timeout: Some(timeout),
|
|
follow_redirects: send.settings.follow_redirects,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| Refusal::Invalid(e.to_string()))?;
|
|
|
|
// The first hop, checked up front so a bad destination is a clean refusal rather than a
|
|
// stream that opens and immediately errors. Every later hop is checked by GuardedSender.
|
|
limits.policy.check_url(&sendable.url).map_err(Refusal::Destination)?;
|
|
|
|
Ok(PreparedSend {
|
|
limits,
|
|
sendable,
|
|
settings: send.settings,
|
|
cookies: send.cookies,
|
|
timeout,
|
|
timeout_capped,
|
|
})
|
|
}
|
|
|
|
pub struct PreparedSend {
|
|
limits: Arc<SendLimits>,
|
|
sendable: SendableHttpRequest,
|
|
settings: yaak_models::models::HttpSendSettings,
|
|
cookies: Option<Vec<yaak_models::models::Cookie>>,
|
|
timeout: Duration,
|
|
timeout_capped: bool,
|
|
}
|
|
|
|
impl PreparedSend {
|
|
pub fn describe(&self) -> String {
|
|
format!("{} {}", self.sendable.method, self.sendable.url)
|
|
}
|
|
|
|
/// Run the send, writing frames to `frames` until the terminal frame. Returns when the
|
|
/// stream is complete or the client has gone away.
|
|
pub async fn run(mut self, frames: FrameSender) {
|
|
let cookie_store = self.cookies.take().map(CookieStore::from_cookies);
|
|
let store_for_result = cookie_store.clone();
|
|
let outcome = self.execute(frames.clone(), cookie_store).await;
|
|
|
|
let cookies = store_for_result.as_ref().map(|s| s.get_all_cookies());
|
|
let terminal = match outcome {
|
|
Ok(done) => Frame::Done {
|
|
elapsed: done.elapsed,
|
|
content_length: done.content_length,
|
|
content_length_compressed: done.content_length_compressed,
|
|
cookies,
|
|
},
|
|
Err(message) => Frame::Error { message, cookies },
|
|
};
|
|
let _ = write_frame(&frames, &terminal).await;
|
|
}
|
|
|
|
async fn execute(
|
|
self,
|
|
frames: FrameSender,
|
|
cookie_store: Option<CookieStore>,
|
|
) -> Result<DoneStats, String> {
|
|
let limits = self.limits;
|
|
|
|
let (client, resolver) = HttpConnectionOptions {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
validate_certificates: self.settings.validate_certificates,
|
|
// The proxy connects directly. Going through a system proxy would move DNS, and
|
|
// therefore the address check, somewhere this process can't see.
|
|
proxy: HttpConnectionProxySetting::Disabled,
|
|
client_certificate: None,
|
|
dns_overrides: Vec::new(),
|
|
address_filter: Some(limits.policy.address_filter()),
|
|
}
|
|
.build_client()
|
|
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
|
|
|
// Timeline events go into the same frame stream as everything else, as they happen.
|
|
// The desktop persists them from a task like this one; here the task serialises them.
|
|
let (event_tx, mut event_rx) = mpsc::channel::<HttpResponseEvent>(EVENT_CHANNEL_CAPACITY);
|
|
resolver.set_event_sender(Some(event_tx.clone())).await;
|
|
let dns_elapsed = Arc::new(AtomicU64::new(0));
|
|
let event_frames = frames.clone();
|
|
let event_dns = dns_elapsed.clone();
|
|
let event_task = tokio::spawn(async move {
|
|
while let Some(event) = event_rx.recv().await {
|
|
if let HttpResponseEvent::DnsResolved { duration, .. } = &event {
|
|
event_dns.store(*duration, Ordering::Relaxed);
|
|
}
|
|
let frame = Frame::Event { event: event.into() };
|
|
if write_frame(&event_frames, &frame).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Cancellation: the client hanging up, or the overall deadline. The deadline exists
|
|
// because a per-hop timeout times each hop separately; ten slow redirects must not add
|
|
// up to ten timeouts.
|
|
let (cancel_tx, cancel_rx) = watch::channel(false);
|
|
let deadline = self.timeout * 2 + Duration::from_secs(5);
|
|
let deadline_cancel = cancel_tx.clone();
|
|
let deadline_task = tokio::spawn(async move {
|
|
tokio::time::sleep(deadline).await;
|
|
let _ = deadline_cancel.send(true);
|
|
});
|
|
let hangup_frames = frames.clone();
|
|
let hangup_task = tokio::spawn(async move {
|
|
hangup_frames.closed().await;
|
|
let _ = cancel_tx.send(true);
|
|
});
|
|
|
|
if self.timeout_capped {
|
|
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
|
|
"Timeout set to {:?} (this proxy's ceiling)",
|
|
self.timeout
|
|
)));
|
|
}
|
|
|
|
let sender = GuardedSender::new(ReqwestSender::with_client(client), limits.policy.clone());
|
|
let transaction = match cookie_store {
|
|
Some(store) => HttpTransaction::with_cookie_behavior(
|
|
sender,
|
|
store,
|
|
self.settings.send_cookies,
|
|
self.settings.store_cookies,
|
|
),
|
|
None => HttpTransaction::new(sender),
|
|
};
|
|
|
|
let started_at = Instant::now();
|
|
let result = transaction
|
|
.execute_with_cancellation(self.sendable, cancel_rx.clone(), event_tx.clone())
|
|
.await;
|
|
resolver.set_event_sender(None).await;
|
|
|
|
let mut response = match result {
|
|
Ok(response) => response,
|
|
Err(err) => {
|
|
drop(event_tx);
|
|
let _ = event_task.await;
|
|
deadline_task.abort();
|
|
hangup_task.abort();
|
|
return Err(describe_error(&err));
|
|
}
|
|
};
|
|
|
|
let elapsed_headers = started_at.elapsed().as_millis() as u64;
|
|
let head = Frame::Response {
|
|
status: response.status,
|
|
status_reason: response.status_reason.clone(),
|
|
url: response.url.clone(),
|
|
remote_addr: response.remote_addr.clone(),
|
|
version: response.version.clone(),
|
|
headers: to_wire_headers(&response.headers),
|
|
request_headers: to_wire_headers(&response.request_headers),
|
|
content_length: response.content_length,
|
|
elapsed_headers,
|
|
elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
|
|
};
|
|
write_frame(&frames, &head).await.map_err(|_| "Client went away".to_string())?;
|
|
|
|
let declared_length = response.content_length;
|
|
let mut body = response
|
|
.into_body_stream()
|
|
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
|
let mut buf = vec![0u8; BODY_READ_CHUNK];
|
|
let mut total: usize = 0;
|
|
let mut cancel_rx = cancel_rx;
|
|
let base64 = base64::engine::general_purpose::STANDARD;
|
|
|
|
let read_result: Result<(), String> = loop {
|
|
if *cancel_rx.borrow() {
|
|
break Err("Request canceled".to_string());
|
|
}
|
|
let read = tokio::select! {
|
|
biased;
|
|
_ = cancel_rx.changed() => break Err("Request canceled".to_string()),
|
|
r = body.read(&mut buf) => r,
|
|
};
|
|
match read {
|
|
Ok(0) => break Ok(()),
|
|
Ok(n) => {
|
|
total += n;
|
|
if total > limits.max_response_bytes {
|
|
break Err(format!(
|
|
"Response body exceeds this proxy's limit of {} bytes",
|
|
limits.max_response_bytes
|
|
));
|
|
}
|
|
let frame = Frame::Body { data: base64.encode(&buf[..n]) };
|
|
if write_frame(&frames, &frame).await.is_err() {
|
|
break Err("Client went away".to_string());
|
|
}
|
|
}
|
|
Err(e) => break Err(format!("Failed to read response body: {e}")),
|
|
}
|
|
};
|
|
drop(body);
|
|
|
|
// Let the timeline drain before the terminal frame, so nothing arrives after "done".
|
|
drop(event_tx);
|
|
let _ = event_task.await;
|
|
deadline_task.abort();
|
|
hangup_task.abort();
|
|
|
|
read_result?;
|
|
Ok(DoneStats {
|
|
elapsed: started_at.elapsed().as_millis() as u64,
|
|
content_length: total as u64,
|
|
content_length_compressed: declared_length.unwrap_or(total as u64),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A send error as a sentence, not a debug dump.
|
|
///
|
|
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
|
|
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
|
|
/// desktop shows the outer `Debug`; a stranger reading a proxy's reply deserves the reason.
|
|
fn describe_error(err: &yaak_http::error::Error) -> String {
|
|
match err {
|
|
yaak_http::error::Error::Client(e) => {
|
|
let mut leaf: &dyn std::error::Error = e;
|
|
while let Some(next) = leaf.source() {
|
|
leaf = next;
|
|
}
|
|
let outer = e.to_string();
|
|
let inner = leaf.to_string();
|
|
if inner == outer { outer } else { format!("{outer}: {inner}") }
|
|
}
|
|
yaak_http::error::Error::RequestError(message) => format!("Request failed: {message}"),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
struct DoneStats {
|
|
elapsed: u64,
|
|
content_length: u64,
|
|
content_length_compressed: u64,
|
|
}
|
|
|
|
fn to_wire_headers(headers: &[(String, String)]) -> Vec<HttpResponseHeader> {
|
|
headers
|
|
.iter()
|
|
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
|
.collect()
|
|
}
|
|
|
|
async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
|
|
let mut line = match serde_json::to_vec(frame) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
warn!("Failed to serialize frame: {e}");
|
|
return Err(());
|
|
}
|
|
};
|
|
line.push(b'\n');
|
|
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
|
|
}
|
|
|
|
/// Log a finished send at info: destination, outcome, and how long, never the content.
|
|
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
|
|
info!("{description} -> {outcome} in {:?}", started.elapsed());
|
|
}
|