Compare commits

...
57 changed files with 2788 additions and 378 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
Generated
+36 -1
View File
@@ -619,6 +619,8 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -627,10 +629,15 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -651,6 +658,7 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9531,6 +9539,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9569,6 +9578,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log 0.4.29",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -11492,7 +11502,6 @@ dependencies = [
"log 0.4.29",
"mime_guess",
"native-tls",
"regex 1.11.1",
"reqwest 0.12.20",
"serde",
"serde_json",
@@ -11569,8 +11578,10 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"ts-rs",
"urlencoding",
"yaak-core",
"yaak-database",
"yaak-templates",
]
[[package]]
@@ -11664,6 +11675,29 @@ dependencies = [
"yaak-ws",
]
[[package]]
name = "yaak-send-proxy"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"clap",
"env_logger",
"futures-util",
"log 0.4.29",
"serde",
"serde_json",
"tokio",
"tower-http",
"ts-rs",
"url",
"uuid",
"yaak-http",
"yaak-models",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11758,6 +11792,7 @@ dependencies = [
"web-sys",
"yaak-lifecycle",
"yaak-models",
"yaak-templates",
]
[[package]]
+2
View File
@@ -27,6 +27,8 @@ members = [
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-send-proxy",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1
# The stateless send proxy for web.yaak.app. Builds only crates-server/yaak-send-proxy
# (over yaak-http), so no plugins/Node. Binds a fixed 8080; Railway routes to it.
FROM rust:1-bookworm AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release -p yaak-send-proxy
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/target/release/yaak-send-proxy /usr/local/bin/yaak-send-proxy
ENV YAAK_PROXY_BIND=0.0.0.0:8080
EXPOSE 8080
CMD ["yaak-send-proxy"]
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
# web.yaak.app — the browser Yaak client, built static and served by Caddy.
FROM node:22-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN git init -q && git add -A \
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
# Build-time: which send proxy the tab talks to (baked into the bundle by Vite).
ARG VITE_YAAK_SEND_PROXY_URL
ENV VITE_YAAK_SEND_PROXY_URL=$VITE_YAAK_SEND_PROXY_URL
ENV YAAK_TARGET=web
ENV SKIP_WASM_BUILD=1
RUN npm ci
RUN node_modules/.bin/vp -C apps/yaak-client build
FROM caddy:2-alpine
COPY deploy/web/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist/apps/yaak-client /srv
+1 -1
View File
@@ -13,7 +13,7 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "yaak-send-proxy"
version = "0.1.0"
edition = "2024"
publish = false
description = "Stateless HTTP send executor for Yaak in the browser"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
# opens no database, runs no plugins, and renders nothing. yaak-models comes
# along only because yaak-http's types are its types; nothing here calls into
# its query layer.
[[bin]]
name = "yaak-send-proxy"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = "0.7"
base64 = "0.22.1"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
futures-util = "0.3"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["cors"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
+139
View File
@@ -0,0 +1,139 @@
# yaak-send-proxy
The network half of Yaak in a browser.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Running it
```shell
cargo run -p yaak-send-proxy
```
Listens on `127.0.0.1:9227`. Then run the web build against it:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
The tab looks for the proxy at `http://127.0.0.1:9227` unless
`VITE_YAAK_SEND_PROXY_URL` says otherwise at build time.
Every flag has a `YAAK_PROXY_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## What it refuses, and why
A hosted proxy is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So it refuses, always, to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
- anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a
redirect chain, so a public hostname that points at an internal address is
caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on the proxy's disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. There is no switch to turn this off: the
proxy's private network is the cloud's, not the user's, so a `localhost` or LAN
API can never be reached through it — that is what the desktop app is for.
## Deploying
One binary, no dependencies:
```shell
cargo build --release -p yaak-send-proxy
YAAK_PROXY_BIND=0.0.0.0:9227 \
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
./target/release/yaak-send-proxy
```
There is no authentication: an instance is anonymous and protected by the
per-client rate limit and the destination policy, which is what the hosted
funnel wants. Anything more (a shared token, per-user quotas) is a later slice
and would sit in front of `send_http` in `main.rs`. Put TLS in front of it (a
reverse proxy). If the reverse proxy buffers responses, tell it not to: the reply
is a stream and the `X-Accel-Buffering: no` header it sets is honoured by
nginx-shaped ones.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": [], "body": {}, "bodyType": null, "urlParameters": [] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the proxy builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| --- | --- | --- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-send-proxy` after changing a frame)
and published to the tab as `@yaakapp-internal/send-proxy`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.
+48
View File
@@ -0,0 +1,48 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
+64
View File
@@ -0,0 +1,64 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the proxy only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };
+4
View File
@@ -0,0 +1,4 @@
// The send proxy's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-send-proxy`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_send_proxy";
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/send-proxy",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
@@ -0,0 +1,52 @@
use clap::Parser;
use std::net::SocketAddr;
/// A stateless HTTP send executor for Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary only puts bytes on the
/// network and streams back what came back. Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-send-proxy", version, about, long_about = None)]
pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
#[arg(long, env = "YAAK_PROXY_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Browser origins allowed to call this proxy (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_PROXY_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the proxy will relay before cutting the send off.
#[arg(long, env = "YAAK_PROXY_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_PROXY_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_PROXY_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
+252
View File
@@ -0,0 +1,252 @@
//! Where a send may go.
//!
//! A hosted sender is, by construction, a machine that makes HTTP requests on
//! behalf of strangers. Left alone that is an open relay into whatever network
//! it sits on: cloud metadata endpoints, internal admin panels, the database
//! next door. So every destination is checked twice — once on the URL before a
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
//! addresses a hostname actually resolves to, right before the connection is
//! made. The second check is the one that matters for a hostname pointing at
//! an internal address, and it runs on every redirect hop because the engine
//! resolves every hop.
use async_trait::async_trait;
use log::warn;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use tokio::sync::mpsc;
use url::Url;
use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, always. A hosted
/// proxy's "private network" is the cloud's, not the user's, so there is no configuration
/// that makes reaching it right.
#[derive(Clone, Default)]
pub struct DestinationPolicy;
impl DestinationPolicy {
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
}
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = host.trim_matches(|c| c == '[' || c == ']');
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known.
if let Ok(ip) = host.parse::<IpAddr>() {
self.check_ip(ip)?;
}
Ok(())
}
/// The veto the engine's resolver applies to every address a hostname resolves to.
pub fn address_filter(&self) -> AddressFilter {
let policy = self.clone();
Arc::new(move |ip| policy.check_ip(ip))
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
)),
None => Ok(()),
}
}
}
/// Why an address is not a public internet address, or `None` if it is one.
///
/// Every range here is one a hosted relay must never be talked into reaching: the machine
/// itself, the network it sits on, and the link-local range where cloud metadata services
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
/// NAT64 prefix is not knowable here.
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => non_public_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return non_public_v4(v4);
}
if let Some(v4) = embedded_v4(&v6) {
return non_public_v4(v4);
}
if v6.is_loopback() {
Some("loopback")
} else if v6.is_unspecified() {
Some("unspecified")
} else if v6.is_unique_local() {
Some("unique local (fc00::/7)")
} else if v6.is_unicast_link_local() {
Some("link-local (fe80::/10)")
} else if v6.is_multicast() {
Some("multicast")
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
Some("NAT64 local-use (64:ff9b:1::/48)")
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
Some("discard-only (100::/64)")
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
Some("site-local (fec0::/10)")
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
Some("documentation (2001:db8::/32)")
} else {
None
}
}
}
}
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
let o = v4.octets();
if v4.is_loopback() {
Some("loopback (127.0.0.0/8)")
} else if v4.is_private() {
Some("private (10/8, 172.16/12, 192.168/16)")
} else if v4.is_link_local() {
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
} else if v4.is_unspecified() || o[0] == 0 {
Some("this network (0.0.0.0/8)")
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
Some("carrier-grade NAT (100.64.0.0/10)")
} else if v4.is_broadcast() {
Some("broadcast")
} else if v4.is_multicast() {
Some("multicast (224.0.0.0/4)")
} else if o[0] >= 240 {
Some("reserved (240.0.0.0/4)")
} else if v4.is_documentation() {
Some("documentation")
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
Some("IETF protocol assignments (192.0.0.0/24)")
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
Some("benchmarking (198.18.0.0/15)")
} else {
None
}
}
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let s = v6.segments();
let o = v6.octets();
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
}
if s[0] == 0x2002 {
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
}
None
}
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
///
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
/// including a redirect to a literal internal IP, which the resolver would never see.
pub struct GuardedSender<S> {
inner: S,
policy: DestinationPolicy,
}
impl<S: HttpSender> GuardedSender<S> {
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait]
impl<S: HttpSender> HttpSender for GuardedSender<S> {
async fn send(
&self,
request: SendableHttpRequest,
event_tx: mpsc::Sender<HttpResponseEvent>,
) -> yaak_http::error::Result<HttpResponse> {
if let Err(reason) = self.policy.check_url(&request.url) {
warn!("Refused {} {}: {reason}", request.method, request.url);
return Err(yaak_http::error::Error::RequestError(reason));
}
self.inner.send(request, event_tx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn refuses_the_ranges_a_relay_must_never_reach() {
for addr in [
"127.0.0.1",
"127.9.9.9",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254",
"169.254.0.1",
"0.0.0.0",
"100.64.0.1",
"255.255.255.255",
"224.0.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"64:ff9b::7f00:1",
"ff02::1",
] {
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
}
}
#[test]
fn allows_public_addresses() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"2606:4700:4700::1111",
] {
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
}
}
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy;
assert!(policy.check_url("http://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::1]/").is_err());
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy;
assert!(policy.check_url("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok());
}
}
@@ -0,0 +1,90 @@
//! Per-client rate limiting, kept deliberately small.
//!
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
//! map that is swept of idle entries as it goes. Good enough to keep one
//! caller from monopolising a hosted instance; not a substitute for whatever
//! sits in front of it in production.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
// Sweep buckets that have been idle long enough to be full again; there is nothing
// to remember about them.
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_bucket_then_a_wait() {
let limiter = RateLimiter::new(3);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
}
#[test]
fn clients_are_independent_and_zero_disables() {
let limiter = RateLimiter::new(1);
let a: IpAddr = "203.0.113.5".parse().unwrap();
let b: IpAddr = "203.0.113.6".parse().unwrap();
assert!(limiter.check(a).is_ok());
assert!(limiter.check(a).is_err());
assert!(limiter.check(b).is_ok());
let unlimited = RateLimiter::new(0);
for _ in 0..1000 {
assert!(unlimited.check(a).is_ok());
}
}
}
+191
View File
@@ -0,0 +1,191 @@
//! yaak-send-proxy: the network half of Yaak in a browser.
//!
//! A tab can't see a response the way a desktop app can — CORS hides most
//! headers, redirects are followed silently, there is no timeline. So the tab
//! renders the request and hands it here; this process puts it on the network
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! One binary, configured by flags or `YAAK_PROXY_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
mod config;
mod guard;
mod limits;
mod send;
mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use clap::Parser;
use config::Config;
use guard::DestinationPolicy;
use limits::RateLimiter;
use log::{info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::cors::{AllowOrigin, CorsLayer};
use wire::SendRequest;
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
limits: Arc<SendLimits>,
rate_limiter: Arc<RateLimiter>,
in_flight: Arc<Semaphore>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let policy = DestinationPolicy;
let state = AppState {
limits: Arc::new(SendLimits {
policy,
max_response_bytes: config.max_response_bytes,
max_timeout: Duration::from_secs(config.max_timeout_secs),
}),
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let app = Router::new()
.route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
// on the same router, behind the same policy, limits and auth. Not built; see README.
.route("/v1/http/send", post(send_http))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state.clone());
let bind = state.config.bind;
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!(
"yaak-send-proxy listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
}
let parsed: Vec<HeaderValue> =
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
AllowOrigin::list(parsed)
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
(status, Json(json!({ "error": message }))).into_response()
}
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
/// hop when the operator has said the header can be trusted.
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn send_http(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> Response {
let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) {
warn!("Rate limited {ip}");
let mut res = error_response(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
);
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
return res;
}
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
warn!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This proxy is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
Ok(p) => p,
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Destination(m)) => {
warn!("Refused send from {ip}: {m}");
return error_response(StatusCode::FORBIDDEN, m);
}
};
let description = prepared.describe();
info!("{ip} -> {description}");
let started = Instant::now();
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
tokio::spawn(async move {
prepared.run(tx).await;
send::log_outcome(&description, started, "finished");
drop(permit);
});
let stream = tokio_stream_from(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.header(header::CACHE_CONTROL, "no-store")
// Some reverse proxies buffer streamed responses unless told not to
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.expect("valid response")
}
fn tokio_stream_from<T: Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<T>,
) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
}
+357
View File
@@ -0,0 +1,357 @@
//! 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());
}
+97
View File
@@ -0,0 +1,97 @@
//! What crosses the wire between a tab and this proxy.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
//! cookies the send starts with. The reply is a stream of newline-delimited
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The proxy
//! does not know what the tab will call this response; it only knows what came
//! back.
//!
//! The TypeScript side of this contract is generated from these types into
//! `bindings/` (`cargo test -p yaak-send-proxy`) and published to the tab as
//! `@yaakapp-internal/send-proxy`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_models::models::{
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
};
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_send_proxy.ts")]
pub struct SendRequest {
/// The request to send, in the desktop's own model shape but with every template already
/// rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
/// the desktop does after rendering.
pub request: HttpRequest,
/// The resolved settings, values only. Where they came from is the tab's to record in
/// its timeline; the proxy only needs to obey them.
pub settings: HttpSendSettings,
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
#[serde(default)]
pub cookies: Option<Vec<Cookie>>,
}
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
/// camelCase like every model the tab stores.
#[derive(Serialize, Debug, TS)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_send_proxy.ts")]
pub enum Frame {
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
/// else in the order the engine produced it.
Event { event: HttpResponseEventData },
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
/// the body — so the tab can show status and headers while the body streams.
Response {
status: u16,
status_reason: Option<String>,
/// The URL that answered, after redirects.
url: String,
remote_addr: Option<String>,
version: Option<String>,
headers: Vec<HttpResponseHeader>,
/// The headers that were actually sent on the final hop, cookies and all.
request_headers: Vec<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
Body { data: String },
/// The send finished. The last frame on a successful stream.
Done {
/// Milliseconds from the start of the send to the end of the body.
#[ts(type = "number")]
elapsed: u64,
/// Bytes of body relayed, after decompression.
#[ts(type = "number")]
content_length: u64,
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
#[ts(type = "number")]
content_length_compressed: u64,
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
cookies: Option<Vec<Cookie>>,
},
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
/// still come back — the transaction may have set some before the hop that failed.
Error {
message: String,
cookies: Option<Vec<Cookie>>,
},
}
+1 -1
View File
@@ -4,5 +4,5 @@
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub use yaak::render::{render_grpc_request, render_http_request};
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
+11
View File
@@ -70,6 +70,17 @@ export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
-1
View File
@@ -19,7 +19,6 @@ hyper-util = { version = "0.1.17", default-features = false, features = ["client
log = { workspace = true }
mime_guess = "2.0.5"
native-tls = { version = "0.2", features = ["alpn"] }
regex = "1.11.1"
reqwest = { workspace = true, features = [
"rustls-tls-manual-roots-no-provider",
"native-tls",
+11 -3
View File
@@ -1,4 +1,4 @@
use crate::dns::LocalhostResolver;
use crate::dns::{AddressFilter, LocalhostResolver};
use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
@@ -103,13 +103,18 @@ pub struct HttpConnectionOptions {
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
/// Refuse connections to addresses a hostname resolves to. `None` means
/// every resolved address is connectable, which is what the desktop wants:
/// a user sending to their own machine or their own network is the point.
/// A hosted sender is the caller that supplies one.
pub address_filter: Option<AddressFilter>,
}
impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver.
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
let mut client = client_builder()
.connection_verbose(true)
.redirect(redirect::Policy::none())
@@ -135,7 +140,10 @@ impl HttpConnectionOptions {
}
// Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
let resolver = LocalhostResolver::with_address_filter(
self.dns_overrides.clone(),
self.address_filter.clone(),
);
client = client.dns_resolver(resolver.clone());
// Configure proxy
+39
View File
@@ -20,15 +20,32 @@ pub struct ResolvedOverride {
pub ipv6: Vec<Ipv6Addr>,
}
/// A veto on the addresses a hostname resolves to, consulted after resolution
/// and before any connection is made. Returning `Err` refuses the whole lookup
/// with that message; a hostname is never partially allowed.
///
/// A hosted sender uses this to refuse private and metadata ranges no matter
/// what name they hide behind. Checking here rather than on the URL is what
/// catches a public hostname that resolves to an internal address.
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct LocalhostResolver {
fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
address_filter: Option<AddressFilter>,
}
impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
Self::with_address_filter(dns_overrides, None)
}
pub fn with_address_filter(
dns_overrides: Vec<DnsOverride>,
address_filter: Option<AddressFilter>,
) -> Arc<Self> {
let resolver = HyperGaiResolver::new();
// Pre-parse DNS overrides into a lookup map
@@ -55,9 +72,25 @@ impl LocalhostResolver {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
address_filter,
})
}
/// Apply the address filter, if any, to a resolved address list.
fn filter_addrs(
filter: &Option<AddressFilter>,
addrs: &[SocketAddr],
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(filter) = filter {
for addr in addrs {
if let Err(reason) = filter(addr.ip()) {
return Err(Box::new(std::io::Error::other(reason)));
}
}
}
Ok(())
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
@@ -72,6 +105,7 @@ impl Resolve for LocalhostResolver {
let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
let address_filter = self.address_filter.clone();
info!("DNS resolve called for: {}", host);
@@ -94,6 +128,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -125,6 +161,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -161,6 +199,7 @@ impl Resolve for LocalhostResolver {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
Self::filter_addrs(&address_filter, &addr_vec)?;
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
+4 -1
View File
@@ -5,9 +5,12 @@ pub mod decompress;
pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
mod proto;
pub mod sender;
pub mod tee_reader;
pub mod transaction;
pub mod types;
// Moved to yaak-models so the browser's wasm host can render requests with the
// same code; re-exported here so existing callers keep their path.
pub use yaak_models::path_placeholders;
+2
View File
@@ -19,8 +19,10 @@ serde_json = { workspace = true }
schemars = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
urlencoding = "2.1.3"
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
+16
View File
@@ -304,6 +304,22 @@ export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = {
validateCertificates: boolean;
followRedirects: boolean;
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
};
export type HttpUrlParameter = {
enabled?: boolean;
/**
+107
View File
@@ -0,0 +1,107 @@
//! Carrying a send's cookie changes back into a jar.
//!
//! A send starts from a snapshot of the jar and hands back the jar as the
//! transaction left it. Writing that whole result over the jar would also
//! write over anything the user changed *while* the send was in flight — a
//! cookie edited or deleted in the jar view, or set by another send. So the
//! send's contribution is taken as a difference (what it added, changed, or
//! removed relative to its snapshot) and applied to whatever the jar holds now.
use crate::models::{Cookie, CookieDomain};
/// The identity of a cookie in a jar: two cookies with the same name, domain
/// and path are the same cookie, whatever their value or attributes.
type CookieKey = (String, CookieDomain, String);
fn key(c: &Cookie) -> CookieKey {
(c.name.clone(), c.domain.clone(), c.path.clone())
}
/// Apply the changes between `before` (the snapshot a send started from) and
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
///
/// Cookies the send removed are removed; cookies it added or changed replace
/// their counterpart in `current`, or are appended. Cookies the send did not
/// touch are left exactly as `current` has them.
pub fn apply_cookie_changes(
current: Vec<Cookie>,
before: &[Cookie],
after: &[Cookie],
) -> Vec<Cookie> {
let removed: Vec<CookieKey> =
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
let mut result: Vec<Cookie> =
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
for cookie in changed {
match result.iter_mut().find(|c| key(c) == key(cookie)) {
Some(existing) => *existing = cookie.clone(),
None => result.push(cookie.clone()),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::CookieExpires;
fn cookie(name: &str, value: &str) -> Cookie {
Cookie {
name: name.to_string(),
value: value.to_string(),
domain: CookieDomain::HostOnly("example.com".to_string()),
expires: CookieExpires::SessionEnd,
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
}
}
#[test]
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
let before = vec![cookie("a", "1")];
let current = vec![cookie("a", "edited"), cookie("b", "2")];
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
}
#[test]
fn additions_and_changes_land_without_touching_concurrent_edits() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
// Meanwhile the user edited `a` and added `d`.
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![
cookie("a", "edited"),
cookie("b", "3"),
cookie("d", "5"),
cookie("c", "4")
]
);
}
#[test]
fn a_cookie_the_send_removed_is_removed() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("b", "2")];
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![cookie("b", "2"), cookie("c", "3")]
);
}
#[test]
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
let before = vec![cookie("a", "1")];
let after = vec![cookie("a", "1")]; // untouched by the send
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
let after = vec![cookie("a", "fresh")]; // the send set it again
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
}
}
+2
View File
@@ -10,11 +10,13 @@ use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
pub mod cookies;
mod connection_or_tx;
pub mod error;
pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod path_placeholders;
pub mod queries;
pub mod query_manager;
pub mod render;
+64
View File
@@ -158,6 +158,70 @@ impl Default for ResolvedHttpRequestSettings {
}
}
impl ResolvedHttpRequestSettings {
/// The `* Setting name=value` lines a send writes at the top of its timeline, sources and
/// all. Built here, once, so every host that runs a send — the desktop, the CLI, the browser
/// tab handing off to a proxy — records the same lines the same way.
pub fn timeline_events(&self) -> Vec<HttpResponseEventData> {
fn event<T>(
name: &str,
value: String,
setting: &ResolvedSetting<T>,
) -> HttpResponseEventData {
HttpResponseEventData::Setting {
name: name.to_string(),
value,
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
}
}
let timeout = if self.request_timeout.value > 0 {
format!("{:?}", std::time::Duration::from_millis(self.request_timeout.value as u64))
} else {
"Infinity".to_string()
};
vec![
event(
"validate_certificates",
self.validate_certificates.value.to_string(),
&self.validate_certificates,
),
event("redirects", self.follow_redirects.value.to_string(), &self.follow_redirects),
event("timeout", timeout, &self.request_timeout),
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
]
}
}
/// The resolved send settings, values only: what an executor has to obey, with the sources
/// (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
/// crosses from a tab to the send proxy, and what the proxy reads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpSendSettings {
pub validate_certificates: bool,
pub follow_redirects: bool,
/// Milliseconds. Zero or negative means no timeout.
pub timeout_ms: i32,
pub send_cookies: bool,
pub store_cookies: bool,
}
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
fn from(s: &ResolvedHttpRequestSettings) -> Self {
Self {
validate_certificates: s.validate_certificates.value,
follow_redirects: s.follow_redirects.value,
timeout_ms: s.request_timeout.value,
send_cookies: s.send_cookies.value,
store_cookies: s.store_cookies.value,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -1,4 +1,4 @@
use yaak_models::models::HttpUrlParameter;
use crate::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
@@ -34,27 +34,41 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
return url.to_string();
}
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
// placeholder while leaving `:increment-importance` as literal text.
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
//
// A plain scan rather than a regex: the name is matched literally, so a name containing
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
let name = p.name.as_str();
let value = urlencoding::encode(p.value.as_str());
let mut result = String::with_capacity(url.len());
let mut rest = url;
while let Some(slash) = rest.find('/') {
let after_slash = &rest[slash + 1..];
let is_placeholder = after_slash.starts_with(name)
&& after_slash[name.len()..]
.chars()
.next()
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
if is_placeholder {
result.push_str(&rest[..=slash]);
result.push_str(&value);
rest = &after_slash[name.len()..];
} else {
result.push_str(&rest[..=slash]);
rest = after_slash;
}
}
result.push_str(rest);
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::models::{HttpRequest, HttpUrlParameter};
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
@@ -98,6 +112,30 @@ mod placeholder_tests {
);
}
#[test]
fn placeholder_name_is_matched_literally() {
// `.` in a name is a dot, not "any character".
let p = HttpUrlParameter {
name: ":id.v2".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
"https://example.com/xxx/:idXv2",
);
}
#[test]
fn placeholder_repeated() {
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id/:id"),
"https://example.com/7/7",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
+223 -2
View File
@@ -1,5 +1,159 @@
use crate::models::{Environment, EnvironmentVariable};
use std::collections::HashMap;
//! Rendering requests against an environment chain.
//!
//! Lives here rather than beside the send engine so that the browser's wasm
//! host, which has the model layer but no sockets, renders exactly what the
//! desktop renders.
use crate::models::{
Environment, EnvironmentVariable, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use crate::path_placeholders::apply_path_placeholders;
use log::info;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
/// Render every template in an HTTP request against an environment chain.
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
let mut variables = HashMap::new();
@@ -27,3 +181,70 @@ fn add_variable_to_map(
map
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+7
View File
@@ -10,6 +10,13 @@ wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["wasm"]
# The `#[wasm_bindgen]` exports (parse_template etc.) that make up the
# @yaakapp-internal/templates package. Off for crates that link this one into
# their own wasm module and do not want these re-exported from theirs.
wasm = []
[dependencies]
base64 = "0.22.1"
serde = { workspace = true, features = ["derive"] }
+1
View File
@@ -4,6 +4,7 @@ pub mod format_json;
pub mod parser;
pub mod renderer;
pub mod strip_json_comments;
#[cfg(feature = "wasm")]
pub mod wasm;
pub use parser::*;
+2
View File
@@ -27,6 +27,8 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
+1 -1
View File
@@ -3,4 +3,4 @@
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
// nowhere else: it owns a SQLite database, and there must be exactly one of it
// per origin.
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
+11
View File
@@ -25,6 +25,17 @@ export function blob_put(id: string, bytes: Uint8Array): void;
*/
export function boot(): Promise<void>;
/**
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
*/
export function prepare_http_send(payload: any): Promise<any>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
+35 -19
View File
@@ -62,6 +62,22 @@ export function boot() {
return ret;
}
/**
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
* @param {any} payload
* @returns {Promise<any>}
*/
export function prepare_http_send(payload) {
const ret = wasm.prepare_http_send(payload);
return ret;
}
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
@@ -496,7 +512,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -681,23 +697,23 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
console.warn(arg0, arg1);
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1103, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h400c17219073e521);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 198, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
@@ -734,30 +750,30 @@ export function __wbindgen_init_externref_table() {
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3);
}
Binary file not shown.
+6 -5
View File
@@ -5,6 +5,7 @@ export const blob_delete: (a: number, b: number) => [number, number];
export const blob_get: (a: number, b: number) => [number, number, number, number];
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
export const boot: () => any;
export const prepare_http_send: (a: any) => any;
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
export const rust_sqlite_wasm_abort: () => void;
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
@@ -16,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h400c17219073e521: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
+201 -3
View File
@@ -12,8 +12,10 @@
//! JavaScript side owns that; this crate assumes it is the only writer.
//!
//! The command surface is deliberately narrow: what the frontend needs to keep
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
//! everything else with a socket or a filesystem behind it lives elsewhere.
//! its model store coherent, blob storage, and the "prepare" half of a send
//! (resolve, inherit, render — see [`prepare_http_send`]). Putting bytes on the
//! network, plugins, git, sync and everything else with a socket or a
//! filesystem behind it lives elsewhere.
// Nothing in here means anything off wasm32, and building it there would drag
// SQLite's wasm C shim into a native compile. So on any other target the crate
@@ -24,12 +26,19 @@ use std::cell::RefCell;
use std::sync::mpsc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::AnyModel;
use yaak_models::cookies::apply_cookie_changes;
use yaak_models::models::{
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
HttpSendSettings,
};
use yaak_models::models_ops;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_templates::{RenderOptions, TemplateCallback};
/// Names inside the VFS, not paths on any disk. Two files because the desktop
/// keeps two: models in one, blobs in the other.
@@ -209,6 +218,28 @@ struct UpsertIntrospectionReq {
content: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResponseIdReq {
response_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistSendCookiesReq {
cookie_jar_id: String,
before: Vec<Cookie>,
after: Vec<Cookie>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertResponseEventsReq {
response_id: String,
workspace_id: String,
events: Vec<HttpResponseEventData>,
}
fn dispatch(
host: &Host,
cmd: &str,
@@ -313,6 +344,48 @@ fn dispatch(
// Nothing here can open a socket, so no connection ever produced any.
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
"web_get_http_request" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?)
}
"cmd_get_http_response_events" => {
let req: ResponseIdReq = from_js(payload)?;
to_json(
host.queries
.connect()
.list_http_response_events(&req.response_id)
.map_err(js_error)?,
)
}
// The cookies a send set or cleared, applied to the jar as it is *now* rather than
// written over it, so an edit made while the send was in flight survives.
"web_persist_send_cookies" => {
let req: PersistSendCookiesReq = from_js(payload)?;
if req.before == req.after {
return to_json(());
}
let db = host.queries.connect();
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
to_json(())
}
// The tab's half of the send timeline: the events the proxy streamed back, recorded
// under the response they belong to. Same rows the desktop's send task writes, and the
// writes fan out to every tab as `model_writes` like any other.
"web_insert_http_response_events" => {
let req: InsertResponseEventsReq = from_js(payload)?;
let db = host.queries.connect();
for event in req.events {
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
db.upsert_http_response_event(&model, source).map_err(js_error)?;
}
to_json(())
}
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let db = host.queries.connect();
@@ -346,6 +419,131 @@ fn dispatch(
}
}
/* -------------------------------------------------------------------------- */
/* Preparing a send */
/* -------------------------------------------------------------------------- */
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PrepareHttpSendReq {
request_id: String,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
}
/// Everything a send needs that lives in the database, resolved and rendered: the desktop's
/// `HttpSendInputs`, in the shape a tab hands to the proxy and keeps for itself.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PreparedHttpSend {
/// The request with inherited headers and authentication applied and every template
/// rendered. What the proxy sends, and what the response records as its request.
request: HttpRequest,
settings: HttpSendSettings,
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
/// sources and all. The tab records them before the proxy's own events.
setting_events: Vec<HttpResponseEventData>,
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
cookie_jar: Option<CookieJar>,
}
/// A template callback for a host with no plugins. Variables render; a function is a clear
/// refusal naming the function, so the user knows what the request needs rather than seeing
/// an empty string sent in its place.
struct NoPluginsCallback;
impl TemplateCallback for NoPluginsCallback {
fn run(
&self,
fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
let message = format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
Plugins aren't available in the browser yet"
);
async move { Err(yaak_templates::error::Error::RenderError(message)) }
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> yaak_templates::error::Result<String> {
Ok(arg_value.to_string())
}
}
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
/// request on the network: the environment chain, inherited headers and auth, request
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
/// posts to the send proxy.
///
/// Refuses, with a message the user can act on, when the request needs something this host
/// doesn't have: an authentication plugin, or a template function.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
let db = host.queries.connect();
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
let environment_chain = db
.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
req.environment_id.as_deref(),
)
.map_err(js_error)?;
let (authentication_type, authentication, _auth_context_id) =
db.resolve_auth_for_http_request(&request).map_err(js_error)?;
let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?;
let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?;
let cookie_jar = match req.cookie_jar_id.as_deref() {
Some(id) => Some(db.get_cookie_jar(id).map_err(js_error)?),
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&NoPluginsCallback,
&RenderOptions::throw(),
)
.await
.map_err(js_error)?;
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
// request sent without the auth it asked for is worse than one refused with the reason.
let auth_disabled =
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
if let Some(auth_type) = rendered.authentication_type.as_deref()
&& auth_type != "none"
&& !auth_disabled
{
return Err(js_error(format!(
"This request uses {auth_type} authentication, which needs plugins. \
Plugins aren't available in the browser yet"
)));
}
let prepared = PreparedHttpSend {
request: rendered,
settings: HttpSendSettings::from(&settings),
setting_events: settings.timeline_events(),
cookie_jar,
};
// JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`,
// and the default serializer's `Map` for the request body would stringify to `{}`.
use serde::Serialize as _;
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
}
/* -------------------------------------------------------------------------- */
/* Blobs */
/* -------------------------------------------------------------------------- */
-1
View File
@@ -2,7 +2,6 @@ pub mod error;
pub mod export;
pub mod import;
pub mod plugin_events;
pub mod render;
pub mod response_body;
pub mod send;
-217
View File
@@ -1,217 +0,0 @@
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+22 -55
View File
@@ -1,4 +1,3 @@
use crate::render::render_http_request;
use async_trait::async_trait;
use log::warn;
use std::path::{Path, PathBuf};
@@ -25,10 +24,11 @@ use yaak_http::types::{
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::{
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
ResolvedHttpRequestSettings, ResolvedSetting,
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
ProxySettingAuth, ResolvedHttpRequestSettings,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{UpdateSource, generate_prefixed_id};
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
@@ -193,6 +193,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
proxy: runtime_config.proxy.clone(),
client_certificate,
dns_overrides: runtime_config.dns_overrides.clone(),
address_filter: None,
})
.await?;
@@ -715,36 +716,24 @@ pub async fn send_http_request<T: TemplateCallback>(
let started_at = Instant::now();
let request_started_url = sendable_request.url.clone();
send_setting_event(
&event_tx,
"validate_certificates",
resolved_settings.validate_certificates.value.to_string(),
&resolved_settings.validate_certificates,
);
send_setting_event(
&event_tx,
"redirects",
sendable_request.options.follow_redirects.to_string(),
&resolved_settings.follow_redirects,
);
send_setting_event(
&event_tx,
"timeout",
timeout_setting_value(sendable_request.options.timeout),
&resolved_settings.request_timeout,
);
send_setting_event(
&event_tx,
"send_cookies",
cookie_behavior.send_cookies.to_string(),
&resolved_settings.send_cookies,
);
send_setting_event(
&event_tx,
"store_cookies",
cookie_behavior.store_cookies.to_string(),
&resolved_settings.store_cookies,
);
for event in resolved_settings.timeline_events() {
if let HttpResponseEventData::Setting {
name,
value,
source_model,
source_id,
source_name,
} = event
{
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name,
value,
source_model,
source_id,
source_name,
});
}
}
let mut http_response =
match executor.send(sendable_request, event_tx, cookie_behavior.clone()).await {
@@ -1130,28 +1119,6 @@ pub fn persist_cookies_after_send(
Ok(())
}
fn send_setting_event<T>(
event_tx: &mpsc::Sender<SenderHttpResponseEvent>,
name: impl Into<String>,
value: impl Into<String>,
setting: &ResolvedSetting<T>,
) {
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name: name.into(),
value: value.into(),
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
});
}
fn timeout_setting_value(timeout: Option<Duration>) -> String {
match timeout {
Some(timeout) if !timeout.is_zero() => format!("{timeout:?}"),
_ => "Infinity".to_string(),
}
}
fn proxy_setting_from_settings(proxy: Option<ProxySetting>) -> HttpConnectionProxySetting {
match proxy {
None => HttpConnectionProxySetting::System,
+8
View File
@@ -0,0 +1,8 @@
:{$PORT}
root * /srv
encode gzip zstd
try_files {path} /index.html
file_server
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
header /index.html Cache-Control "no-cache"
+11
View File
@@ -70,6 +70,7 @@
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"crates-server/yaak-send-proxy",
"apps/yaak-client",
"apps/yaak-proxy"
],
@@ -272,6 +273,10 @@
"name": "@yaakapp-internal/proxy-lib",
"version": "1.0.0"
},
"crates-server/yaak-send-proxy": {
"name": "@yaakapp-internal/send-proxy",
"version": "1.0.0"
},
"crates-tauri/yaak-app-client": {
"name": "@yaakapp-internal/tauri-client",
"version": "1.0.0"
@@ -5645,6 +5650,10 @@
"resolved": "crates/common/yaak-rpc-schema",
"link": true
},
"node_modules/@yaakapp-internal/send-proxy": {
"resolved": "crates-server/yaak-send-proxy",
"link": true
},
"node_modules/@yaakapp-internal/sse": {
"resolved": "crates/yaak-sse",
"link": true
@@ -15799,7 +15808,9 @@
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0"
}
},
+1
View File
@@ -69,6 +69,7 @@
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"crates-server/yaak-send-proxy",
"apps/yaak-client",
"apps/yaak-proxy"
],
+2
View File
@@ -9,7 +9,9 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
+1
View File
@@ -49,6 +49,7 @@ function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
}
const ALL_CAPABILITIES: PlatformCapabilities = {
httpSending: true,
grpc: true,
websocket: true,
git: true,
+2
View File
@@ -237,6 +237,8 @@ export interface Platform {
* from the cargo features they were built with.
*/
export interface PlatformCapabilities {
/** Send HTTP requests and see the whole response: every header, the redirect chain, timing. */
httpSending: boolean;
/** Send gRPC requests. Needs HTTP/2 trailers, so it needs a real backend. */
grpc: boolean;
/** Send WebSocket requests with custom headers and auth. */
+49 -29
View File
@@ -24,8 +24,10 @@ installs the Tauri host exactly as before.
```
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
└─ pages in IndexedDB
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
└─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-send-proxy (crates-server) ──▶ the internet
◀── NDJSON: events, response, body, cookies ──
```
| File | What it is |
@@ -33,13 +35,19 @@ tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaak
| `index.ts` | The `Platform` implementation. |
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
| `send.ts` | Sending: the worker renders (`prepare_http_send`), the proxy executes, this file stores what comes back where the desktop stores it. |
| `proxy.ts` | The proxy's location and wire shapes, mirrored by hand from `crates-server/yaak-send-proxy/src/wire.rs`. |
| `worker.ts` | The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans `model_writes` out to every port. |
| `protocol.ts` | The message shapes both sides import. |
| `errors.ts` | `UnsupportedCommandError`, the structured refusal. |
| `storage.ts` | `navigator.storage.persist()`. |
The Rust side is `crates/yaak-web` (`@yaakapp-internal/web`): `boot()`,
`rpc(cmd, payload, label)` returning `{ result, events }`, and blob get/put.
`rpc(cmd, payload, label)` returning `{ result, events }`, blob get/put, and
`prepare_http_send(payload)` — the database half of a send (environment chain,
inherited headers and auth, request settings, cookie jar, rendering), which is
`yaak_models::render::render_http_request`, the same function the desktop
renders with.
Its `pkg/` is committed; rebuilding needs a clang with a WebAssembly backend
(`brew install llvm`), and `build-wasm.cjs` skips with a notice when there
isn't one, so a desktop `npm run bootstrap` never depends on it.
@@ -70,13 +78,14 @@ Behaviours worth knowing before changing anything:
## Commands
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
31, declines 44 by name with a reason, and refuses the remaining 34 generically.
32, declines 43 by name with a reason, and refuses the remaining 34 generically.
### Implemented (31)
### Implemented (32)
| Group | Commands |
| --- | --- |
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
| Sending | `cmd_send_http_request` (through the send proxy; see below) |
| App | `cmd_metadata`, `cmd_get_workspace_meta`, `cmd_default_headers`, `cmd_get_themes`, `cmd_check_for_updates`, `cmd_dismiss_notification`, `cmd_plugin_init_errors` |
| Bodies | `cmd_http_response_body`, `cmd_http_response_body_path`, `cmd_http_request_body`, `cmd_get_http_response_events`, `cmd_get_sse_events` |
| Plugin surfaces (empty results) | `cmd_http_request_actions`, `cmd_websocket_request_actions`, `cmd_grpc_request_actions`, `cmd_workspace_actions`, `cmd_folder_actions`, `cmd_template_function_summaries`, `cmd_get_http_authentication_summaries`, `cmd_get_http_authentication_config` |
@@ -97,7 +106,7 @@ Some of these answer honestly rather than fully, and the difference matters:
- `cmd_metadata` reports empty strings for the data, log, plugin and project
directories. There is no filesystem behind this host.
### Declined by name (44)
### Declined by name (43)
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
`message`, and the `capability` a caller should have checked. The UI turns it
@@ -105,7 +114,7 @@ into a toast.
| Reason | Commands |
| --- | --- |
| Sending isn't available yet (slice 2) | `cmd_send_http_request`, `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
| Sending, the parts not wired yet | `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
| No plugin runtime | `cmd_reload_plugins`, `cmd_plugin_info`, `cmd_plugins_search`, `cmd_plugins_install`, `cmd_plugins_install_from_directory`, `cmd_plugins_uninstall`, `cmd_plugins_updates`, `cmd_plugins_update_all`, `cmd_template_function_config`, `cmd_template_tokens_to_string`, `cmd_call_http_request_action`, `cmd_call_websocket_request_action`, `cmd_call_grpc_request_action`, `cmd_call_workspace_action`, `cmd_call_folder_action`, `cmd_call_http_authentication_action`, `cmd_curl_to_request`, `cmd_format_graphql` |
| No filesystem | `cmd_import_data`, `cmd_export_data`, `cmd_save_response`, `cmd_save_base64_to_binary` |
| Needs a real socket | `cmd_grpc_reflect`, `cmd_grpc_go`, `cmd_delete_all_grpc_connections`, `cmd_ws_connect`, `cmd_ws_send`, `cmd_ws_close`, `cmd_ws_delete_connections` |
@@ -129,7 +138,7 @@ Reported honestly, so callers gate on the question rather than on the host:
| True | False |
| --- | --- |
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead
of swallowing them, and drops those three rows from the hotkeys screen.
@@ -174,26 +183,37 @@ other's writes for an echo of their own and drop them.
crates for their types), so the crate declares the handful of request shapes
it needs locally, and `commands.ts` stays typed against `RpcSchema`.
## What slice 2 (the send proxy) will need from this layer
## Sending
Sending becomes a stateless hosted service; this layer stays the only place data
lives. Concretely:
A page cannot see a response the way a desktop app can — CORS exposes a handful
of headers, redirects are followed silently, there is no timeline — so the
network half of a send runs on a small stateless proxy,
`crates-server/yaak-send-proxy`. This layer stays the only place data lives:
1. **A rendered request to send.** The client assembles `HttpSendInputs` and
posts it. Nothing about the workspace is uploaded except what this request
needs.
2. **Cookies out, cookies in.** The active `cookie_jar` model's `cookies` array
goes up with the request; the proxy returns the jar as the exchange left it,
and the client upserts it back through `models_upsert` like any other write.
The proxy keeps nothing.
3. **A response body sink.** `blob_put(responseId, bytes)` in the worker
writes through the desktop's `blob_manager`, chunked the way it chunks.
Streaming will want an append path rather than one whole-body write.
4. **A request body sink** under `${responseId}.request`, which
`cmd_http_request_body` already reads.
5. **Response and timeline models.** `cmd_send_http_request` currently declines;
it will instead upsert an `http_response` as the exchange progresses, plus
`http_response_event` rows once `timeline` becomes true. Both flow through
the same `write()` helper, so other tabs see a send land live.
6. **Blob cleanup is the desktop's.** `delete_http_response` and
`delete_workspace` in `yaak-models` already remove blob chunks.
1. `send.ts` creates the `http_response` row (state `initialized`), as the
desktop does, so anything that goes wrong lands in the response pane.
2. The worker resolves and renders the request (`prepare_http_send`): the
environment chain, inherited headers and auth, request settings, the cookie
jar. This is the desktop's `HttpSendInputs`, in Rust, on the same model layer,
with `yaak_models::render::render_http_request`. Variables (`${[ name ]}`)
render here with no plugins involved.
3. The rendered request, the settings and the jar's cookies are POSTed to the
proxy. It streams back timeline events, the response head, body chunks and a
terminal frame carrying the jar as the send left it.
4. Each frame is written where the desktop writes it: the response row as it
progresses, `http_response_event` rows for the timeline (which is why
`timeline` is true), the body under the response id via `blob_put`, and the
cookie jar through `models_upsert`. Every write fans out to every tab.
**What sends today:** any saved request whose templates are variables and whose
authentication is none, or an inline header. Sending a request that needs a
template *function* (`${[ timestamp() ]}`) or an authentication plugin (bearer,
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
what it needs; those light up when plugins run in the browser. Requests with a
file body or multipart file fields are refused by the proxy (it has no access to
your files, and must not read its own). And a request to `localhost` or a LAN
address can't work from a browser: the proxy runs elsewhere and refuses private
ranges outright — reaching your own machine's APIs is what the desktop app is for.
The proxy URL is `VITE_YAAK_SEND_PROXY_URL` at build time, defaulting to
`http://127.0.0.1:9227` (see `proxy.ts`). Run one with `cargo run -p yaak-send-proxy`.
+17 -13
View File
@@ -21,6 +21,7 @@ import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
import type { CapabilityName, RpcPayload } from "../types";
import type { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import { sendHttpRequest } from "./send";
export type AppCmd = keyof RpcSchema;
@@ -68,6 +69,16 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
/* ------------------------------- sending ------------------------------- */
// The tab renders and stores; a stateless proxy puts the bytes on the wire.
// See send.ts for the whole shape of it.
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
},
/* -------------------------------- app ---------------------------------- */
async cmd_metadata() {
@@ -205,9 +216,8 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
return bytes == null ? null : Array.from(bytes);
},
async cmd_get_http_response_events() {
return [];
},
// The rows the sender wrote for that response, same table as the desktop.
cmd_get_http_response_events: (payload, db) => db.rpc("cmd_get_http_response_events", payload),
async cmd_get_sse_events() {
return [];
@@ -239,16 +249,10 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// Sending — the next slice. Everything else about a request works today;
// only the part that puts bytes on the network is missing.
cmd_send_http_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
null,
],
cmd_send_ephemeral_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
null,
],
// Saved requests send through the proxy (see send.ts). Ephemeral sends — the
// ones nothing stores, used for GraphQL introspection — take the same road but
// return the body inline; not wired yet.
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
// Protocols that need a real socket.
+5
View File
@@ -163,6 +163,11 @@ export class WorkerConnection {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
/** See `prepare_http_send` in crates/yaak-web: the database half of a send. */
prepareHttpSend<T>(payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
}
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
return buf == null ? null : new Uint8Array(buf);
+13 -8
View File
@@ -7,11 +7,12 @@
* the origin, so two tabs stay coherent for the same reason two desktop windows
* do: one process holds the data and pushes every write to all of them.
*
* What a page genuinely cannot do is not faked. There is no file dialog, no
* second window, no clipboard read without a prompt, and — in this slice — no
* sending. Those report false through `capabilities` and refuse with a reason
* if called anyway, so a missing feature shows up as a disabled control or a
* toast that explains itself, never as a silent no-op.
* Sending goes through a small stateless proxy, because a page cannot see a
* response the way a desktop app can (see send.ts). What a page genuinely
* cannot do is not faked: there is no file dialog, no second window, no
* clipboard read without a prompt. Those report false through `capabilities`
* and refuse with a reason if called anyway, so a missing feature shows up as a
* disabled control or a toast that explains itself, never as a silent no-op.
*/
import type {
@@ -32,17 +33,21 @@ import { requestPersistence } from "./storage";
/** What this host can do, reported honestly. */
function capabilitiesFor(): PlatformCapabilities {
return {
// Through the send proxy: the tab renders, the proxy executes, the tab
// stores. Requests needing plugin auth or template functions are refused
// with the reason until plugins run here.
httpSending: true,
grpc: false,
websocket: false,
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire. Nothing in the browser does yet.
// wire, and the send proxy uses its own.
tlsOptions: false,
// The jar can be edited and stored here; only filling it needs the sender.
cookieJar: true,
localFiles: false,
timeline: false,
// The proxy streams the engine's events back and the sender stores them.
timeline: true,
// Whether the host can put a *second window* on this data on demand — what
// `cmd_new_child_window` does for Settings and workspace switching. A tab
// can't, so those open in place instead. This is not a claim that nothing
+6
View File
@@ -10,6 +10,12 @@
/** Tab → worker */
export type ToWorker =
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
/**
* The prepare half of a send: resolve, inherit and render a request against
* the database. Its own message rather than an `rpc` command because it is
* async in the engine (rendering is), where every `rpc` command is not.
*/
| { type: "prepare_http_send"; id: number; payload: unknown }
| { type: "blob_get"; id: number; blobId: string }
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
| { type: "blob_delete"; id: number; blobId: string }
+71
View File
@@ -0,0 +1,71 @@
/**
* The wire to the send proxy: where it is, and how to read what comes back.
*
* The shapes themselves are generated from `crates-server/yaak-send-proxy/src/wire.rs`
* into `@yaakapp-internal/send-proxy`, so the two sides cannot drift silently.
*/
import type { Frame } from "@yaakapp-internal/send-proxy";
/* ------------------------------- location -------------------------------- */
/**
* Where the tab sends. Build-time configuration for now: `VITE_YAAK_SEND_PROXY_URL`
* (Vite exposes `VITE_*` to the bundle), defaulting to a proxy on this machine at
* its default port. A per-user setting can replace this later without touching
* the callers, which only ever ask for the URL.
*/
export function proxyBaseUrl(): string {
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_SEND_PROXY_URL?.trim();
return (configured || "http://127.0.0.1:9227").replace(/\/+$/, "");
}
export function proxySendUrl(): string {
return `${proxyBaseUrl()}/v1/http/send`;
}
let identity: Promise<string> | null = null;
/**
* Who does the sending, for the timeline: `yaak-send-proxy 0.1.0 at http://…`.
* Asked of `/v1/health` once per page load; if the proxy can't be reached the
* URL alone is the answer, and the send itself will say why shortly after.
*/
export function proxyIdentity(): Promise<string> {
identity ??= fetch(`${proxyBaseUrl()}/v1/health`)
.then((res) => res.json() as Promise<{ version?: string }>)
.then((health) => `yaak-send-proxy ${health.version ?? ""} at ${proxyBaseUrl()}`.replace(" ", " "))
.catch(() => {
identity = null; // try again next send
return `send proxy at ${proxyBaseUrl()}`;
});
return identity;
}
/**
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
* held until its newline comes; anything left when the stream ends is dropped,
* because a frame without its newline is a frame the proxy didn't finish writing.
*/
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim() !== "") yield JSON.parse(line) as Frame;
newline = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
+352
View File
@@ -0,0 +1,352 @@
/**
* Sending an HTTP request from a tab.
*
* A tab can't see a response the way the desktop can — CORS hides most headers,
* redirects are followed silently, there is no timeline — so the network half of
* a send happens on a small stateless proxy (`crates-server/yaak-send-proxy`).
* Everything else happens here, against this tab's own database, in the same
* order the desktop does it:
*
* 1. create the `http_response` row (state: initialized);
* 2. resolve and render the request in the worker (`prepare_http_send`: the
* environment chain, inherited headers and auth, request settings, cookie
* jar — the desktop's `HttpSendInputs`, in Rust, on the same model layer);
* 3. POST the rendered request to the proxy and consume its stream: timeline
* events, the response head, body chunks, and a terminal frame;
* 4. write what comes back where the desktop writes it — the response row as
* it progresses, `http_response_event` rows for the timeline, the body
* blob under the response id, the cookie jar with the proxy's changes.
*
* The proxy keeps nothing. Every byte it sees comes from this tab and every
* byte it returns is stored by this tab.
*/
// Types only: the models package imports this one at runtime, and a type import
// is erased, so there is no cycle.
import type {
Cookie,
CookieJar,
HttpRequest,
HttpResponse,
HttpResponseEventData,
HttpSendSettings,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy";
import type { WorkerConnection } from "./connection";
import { proxyIdentity, proxySendUrl, readFrames } from "./proxy";
/* -------------------------------- shapes --------------------------------- */
/**
* The response row as this file knows it: what identifies it, plus whatever
* has been written so far. Every other field is optional and takes the model
* layer's default when absent, the same way the desktop's row does — defaults
* live in Rust, once.
*/
type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
Partial<Omit<HttpResponse, "model" | "requestId" | "workspaceId">>;
type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-web) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
settings: HttpSendSettings;
settingEvents: HttpResponseEventData[];
cookieJar: CookieJar | null;
}
/** The desktop writes progress at most this often while a body streams in. */
const PROGRESS_INTERVAL_MS = 100;
/* --------------------------------- send ---------------------------------- */
export async function sendHttpRequest(
db: WorkerConnection,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
): Promise<ResponseRow> {
// The response row exists before anything can go wrong, as on the desktop, so
// a failure to render or to reach the proxy lands in the response pane as
// that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId);
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
await response.create();
const cancel = new AbortController();
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
} finally {
unlistenCancel();
}
return response.current();
}
async function runSend(
db: WorkerConnection,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
signal: AbortSignal,
): Promise<void> {
const prepared = await db.prepareHttpSend<PreparedHttpSend>({
requestId,
environmentId,
cookieJarId,
});
await response.patch({ url: prepared.request.url });
// The first line of the timeline says what did the sending and where. A
// request through a proxy shows a different origin to the server than the
// user's machine, and this is where that should be visible.
const timeline = new TimelineWriter(db, response.id, response.workspaceId);
timeline.push([{ type: "info", message: `Executed by ${await proxyIdentity()}` }]);
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request: prepared.request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
const startedAt = performance.now();
const res = await fetch(proxySendUrl(), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal,
}).catch((err: unknown) => {
if (signal.aborted) throw err;
throw new Error(`Couldn't reach the send proxy at ${proxySendUrl()}: ${errorMessage(err)}`);
});
if (!res.ok) {
// A refusal, not a failed send: bad destination, rate limit, a body the
// proxy can't build. It comes as JSON with the reason.
const text = await res.text();
let reason = text;
try {
reason = (JSON.parse(text) as { error?: string }).error ?? text;
} catch {
/* not JSON; the text is the reason */
}
throw new Error(reason || `The send proxy answered ${res.status}`);
}
if (res.body == null) throw new Error("The send proxy sent no body");
const chunks: Uint8Array[] = [];
let received = 0;
let lastProgress = startedAt;
let terminal: Frame | null = null;
for await (const frame of readFrames(res.body)) {
switch (frame.type) {
case "event":
timeline.push([frame.event]);
break;
case "response":
await response.patch(headOf(frame));
break;
case "body": {
const bytes = base64ToBytes(frame.data);
chunks.push(bytes);
received += bytes.byteLength;
const now = performance.now();
if (now - lastProgress >= PROGRESS_INTERVAL_MS) {
lastProgress = now;
await response.patch({
contentLength: received,
elapsed: Math.round(now - startedAt),
});
}
break;
}
case "done":
case "error":
terminal = frame;
break;
}
if (terminal != null) break;
}
// Everything the proxy said about the timeline is in the database before the
// response is marked closed, so a reader that wakes on "closed" sees all of it.
await timeline.flush();
if (terminal == null) {
throw new Error("The send proxy closed the stream without finishing");
}
// Cookies come back on both outcomes: a hop before the failing one may have
// set some, and the desktop keeps those too.
if (prepared.cookieJar != null && terminal.cookies != null) {
await persistCookies(db, prepared.cookieJar, terminal.cookies);
}
if (terminal.type === "error") {
throw new Error(terminal.message);
}
// The body is written under the response id, which is how every reader —
// `cmd_http_response_body`, the image viewer, the download button — asks for
// it. One write, once the whole body is here: the worker's blob store has no
// append, and a body larger than memory is over the proxy's cap anyway.
await db.blobPut(response.id, concat(chunks, received));
await response.finish({
contentLength: terminal.contentLength,
contentLengthCompressed: terminal.contentLengthCompressed,
elapsed: terminal.elapsed,
});
}
function headOf(frame: Extract<Frame, { type: "response" }>): ResponsePatch {
return {
state: "connected",
status: frame.status,
statusReason: frame.statusReason,
url: frame.url,
remoteAddr: frame.remoteAddr,
version: frame.version,
headers: frame.headers,
requestHeaders: frame.requestHeaders,
contentLength: frame.contentLength,
elapsedHeaders: frame.elapsedHeaders,
elapsedDns: frame.elapsedDns,
};
}
/* ------------------------------- helpers --------------------------------- */
/**
* The response row, written the way the desktop writes it: created empty,
* patched as the send progresses, closed at the end. Each write goes through
* `models_upsert`, so every tab on this database sees the response land.
*/
class ResponseWriter {
private state: ResponseRow;
constructor(
private readonly db: WorkerConnection,
initial: ResponseRow,
) {
this.state = initial;
}
get id(): string {
return this.state.id ?? "";
}
get workspaceId(): string {
return this.state.workspaceId;
}
current(): ResponseRow {
return this.state;
}
/** Create the row. Everything but its identity is the model layer's default. */
async create(): Promise<void> {
const id = await this.db.rpc<string>("models_upsert", { model: this.state });
this.state = { ...this.state, id };
}
async patch(patch: ResponsePatch): Promise<void> {
// Structured clone carries `undefined` across to the worker as a present
// key, and the model layer reads that as "wrong type" and refuses the whole
// model. Nothing here should produce one, but a missing wire field must
// not take the response row down with it.
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
this.state = { ...this.state, ...defined };
await this.db.rpc("models_upsert", { model: this.state });
}
async finish(patch: ResponsePatch): Promise<void> {
await this.patch({ ...patch, state: "closed" });
}
}
/**
* Timeline events, written in the order they arrived. Writes are chained rather
* than awaited inline so a burst of `header_down` events doesn't serialise the
* body read behind a database round trip each, and `flush()` is the point at
* which the whole timeline is known to be in the database.
*/
class TimelineWriter {
private queue: HttpResponseEventData[] = [];
private inFlight: Promise<void> = Promise.resolve();
constructor(
private readonly db: WorkerConnection,
private readonly responseId: string,
private readonly workspaceId: string,
) {}
push(events: HttpResponseEventData[]): void {
if (events.length === 0) return;
this.queue.push(...events);
this.inFlight = this.inFlight.then(() => this.drain());
}
private async drain(): Promise<void> {
if (this.queue.length === 0) return;
const events = this.queue;
this.queue = [];
await this.db.rpc("web_insert_http_response_events", {
responseId: this.responseId,
workspaceId: this.workspaceId,
events,
});
}
flush(): Promise<void> {
return this.inFlight;
}
}
/**
* Carry the send's cookie changes into the jar. The worker applies them as a
* difference against the jar as it is now (see `apply_cookie_changes` in
* yaak-models), so an edit made while the send was in flight survives rather
* than being written over by the send's stale snapshot.
*/
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
}
/**
* The request's workspace, needed to create the response row before the worker
* has resolved the request (which is where a render refusal would land).
*/
async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise<string> {
const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId });
return req.workspaceId;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function base64ToBytes(data: string): Uint8Array {
const bin = atob(data);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function concat(chunks: Uint8Array[], total: number): Uint8Array {
if (chunks.length === 1) return chunks[0]!;
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}
+6 -1
View File
@@ -122,7 +122,7 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
return;
}
const { rpc, blob_get, blob_put, blob_delete } = engine!;
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
try {
switch (message.type) {
@@ -142,6 +142,11 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
}
return;
}
case "prepare_http_send": {
const prepared = await prepare_http_send(message.payload);
send(port, { type: "result", id: message.id, result: prepared });
return;
}
case "blob_get": {
const bytes = blob_get(message.blobId);
if (bytes == null) {