Serve the web client from yaak-web (#582)

This commit is contained in:
Gregory Schier
2026-08-18 10:39:17 -07:00
committed by GitHub
parent 3f202ff664
commit 538f782068
50 changed files with 809 additions and 424 deletions
-139
View File
@@ -1,139 +0,0 @@
# 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.
-4
View File
@@ -1,4 +0,0 @@
// 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";
@@ -1,52 +0,0 @@
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,
}
@@ -1,9 +1,9 @@
[package]
name = "yaak-send-proxy"
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
description = "Stateless HTTP send executor for Yaak in the browser"
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
@@ -13,7 +13,7 @@ description = "Stateless HTTP send executor for Yaak in the browser"
# its query layer.
[[bin]]
name = "yaak-send-proxy"
name = "yaak-web"
path = "src/main.rs"
[dependencies]
@@ -28,7 +28,7 @@ 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"] }
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
+219
View File
@@ -0,0 +1,219 @@
# yaak-web
The network half of Yaak in a browser — and, with `--serve`, the half that
hands the browser the app in the first place.
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.
## Self-hosting it
One container, no configuration, nothing behind it:
```shell
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
```
Open <http://localhost:8080>. The image carries the built web client and this
binary, which serves it — so the app and its sends are on one origin, and the
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
configure. The image is `linux/amd64` and `linux/arm64`, built from
`Dockerfile.web` at the repo root.
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
the container. The container is stateless: nothing is written to disk, so
upgrading is `docker pull` and nothing else.
Two settings are worth knowing about:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
ghcr.io/mountain-loop/yaak-web
```
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
private and link-local addresses. Off by default, and it should stay off on
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
Turn it on for an instance on your own network, where calling the API on the
next machine is the whole point. Note that "private" is relative to the
*container*: `127.0.0.1` is the container itself, and reaching the Docker
host means `host.docker.internal` (or `--network host`).
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
which suits a public instance and not a team of your own; `0` disables it.
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
limit sees real client addresses instead of its own — and only then, since
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
binary sets is honoured by nginx-shaped ones.
## Running it from source
```shell
cargo run -p yaak-web -- --serve dist/apps/yaak-client
```
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
Without `--serve` it is the send executor alone, which is what the frontend
dev server wants:
```shell
cargo run -p yaak-web
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
different origin and serves no `/v1`); a production build sends to its own
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
## Configuration
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
| `--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. |
## Serving the app
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
first, everything else comes from `DIR`, and a path with no file behind it gets
`index.html` so the app's own routes survive a refresh. Responses are compressed
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
those names — and everything else is `no-cache`, so a new deploy arrives on the
next reload.
Serving files changes nothing about sending: the same rendered request, the same
destination policy, the same stateless executor. It exists so that a
self-hosted Yaak is one thing to run rather than two.
## Split deployments
The app and the sender can still be separate services — one CDN-hosted bundle and
one server elsewhere, or one server shared by several fronts. Then the bundle has
to be told where to send, at build time:
```shell
docker build -f Dockerfile.web \
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
```
and the server needs the CORS origins its callers use, since the requests are no
longer same-origin:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
ghcr.io/mountain-loop/yaak-web \
yaak-web
```
The trailing `yaak-web` is a command override: the same image run without
`--serve`, so it executes sends and serves no app.
## What it refuses, and why
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. So by default it refuses 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 its own disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
anything else strangers can reach) this must stay on: the machine's private
network is the host's, not the user's, so a `localhost` or LAN API is not the
user's to reach through it — the desktop app is what reaches those. On an
instance you run for yourself, that reasoning is inverted, and
`--allow-private-networks` inverts the policy with it. It allows every range
above, including `169.254.169.254`, so use it only where the network on the
other side is one the users are entitled to.
There is no authentication either way: an instance is anonymous, protected by
the per-client rate limit and the destination policy. 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 a public instance.
## 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 server 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-web` after changing a frame)
and published to the tab as `@yaakapp-internal/web`, 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.
@@ -28,7 +28,7 @@ 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.
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
@@ -49,13 +49,13 @@ cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies:
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
* rendered by the tab. The server 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.
* its timeline; the server only needs to obey them.
*/
settings: HttpSendSettings,
/**
+4
View File
@@ -0,0 +1,4 @@
// The server's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-web`). 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_web";
@@ -1,5 +1,5 @@
{
"name": "@yaakapp-internal/send-proxy",
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts"
+67
View File
@@ -0,0 +1,67 @@
use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf;
/// The server behind Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary puts the bytes on the network
/// and streams back what came back, and with `--serve` hands the browser the app as well.
/// Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-web", 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_WEB_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Also serve a built web client from this directory, on the same origin as the API.
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
/// Without this the binary is only the send executor.
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
pub serve: Option<PathBuf>,
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
/// only for an instance whose users are meant to reach that network — a self-hosted one
/// on a LAN, where the point is to call the API on the next machine.
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
pub allow_private_networks: bool,
/// Browser origins allowed to call this server (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_WEB_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the server will relay before cutting the send off.
#[arg(long, env = "YAAK_WEB_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_WEB_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_WEB_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_WEB_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_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
@@ -20,13 +20,20 @@ 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.
/// The destination policy, shared by every send: public addresses only, unless the operator
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
/// network *is* the user's, which is what `--allow-private-networks` means.
#[derive(Clone, Default)]
pub struct DestinationPolicy;
pub struct DestinationPolicy {
allow_private: bool,
}
impl DestinationPolicy {
pub fn new(allow_private: bool) -> Self {
Self { allow_private }
}
/// 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> {
@@ -53,9 +60,12 @@ impl DestinationPolicy {
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
if self.allow_private {
return Ok(());
}
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
)),
None => Ok(()),
}
@@ -236,15 +246,24 @@ mod tests {
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy;
let policy = DestinationPolicy::new(false);
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 allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
let policy = DestinationPolicy::new(true);
assert!(policy.check_url("http://127.0.0.1/").is_ok());
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
assert!(policy.check_url("file:///etc/passwd").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy;
let policy = DestinationPolicy::new(false);
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());
@@ -1,4 +1,4 @@
//! yaak-send-proxy: the network half of Yaak in a browser.
//! yaak-web: 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
@@ -6,7 +6,7 @@
//! 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.
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
@@ -18,8 +18,9 @@ mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, State};
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use clap::Parser;
@@ -30,10 +31,13 @@ use log::{info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use wire::SendRequest;
#[derive(Clone)]
@@ -49,7 +53,13 @@ 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 policy = DestinationPolicy::new(config.allow_private_networks);
if config.allow_private_networks {
warn!(
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
on an instance strangers cannot reach"
);
}
let state = AppState {
limits: Arc::new(SendLimits {
policy,
@@ -66,7 +76,7 @@ async fn main() {
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let app = Router::new()
let api = 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.
@@ -75,13 +85,21 @@ async fn main() {
.layer(cors)
.with_state(state.clone());
let app = match &state.config.serve {
Some(dir) => {
info!("Serving the web client from {}", dir.display());
api.merge(web_router(dir))
}
None => api,
};
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)",
"yaak-web listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
@@ -94,6 +112,48 @@ async fn main() {
.expect("server error");
}
/// The built web client, served on the same origin as the API.
///
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
/// only a file server — a send behaves exactly as it does without this flag.
///
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
/// refresh).
fn web_router(dir: &Path) -> Router {
let index = ServeFile::new(dir.join("index.html"));
Router::new()
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
.fallback_service(ServeDir::new(dir).fallback(index))
.layer(middleware::from_fn(cache_control))
.layer(CompressionLayer::new())
}
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
/// Everything else — `index.html` above all, including the copy served for an unknown path —
/// must be revalidated, or a browser keeps serving the deploy before last.
async fn cache_control(req: Request, next: Next) -> Response {
let hashed_name = req.uri().path().starts_with("/assets/");
let mut res = next.run(req).await;
if !res.status().is_success() {
return res;
}
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
@@ -149,7 +209,7 @@ async fn send_http(
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");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
@@ -42,7 +42,7 @@ pub struct SendLimits {
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.
/// The destination is not one this server will talk to.
Destination(String),
/// The request could not be turned into something sendable.
Invalid(String),
@@ -57,10 +57,10 @@ pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<Prepa
// 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.
// local file read for anyone who can reach the server.
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"
"Binary file bodies can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
@@ -74,7 +74,7 @@ pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<Prepa
});
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"
"Multipart file fields can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
@@ -204,7 +204,7 @@ impl PreparedSend {
if self.timeout_capped {
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
"Timeout set to {:?} (this proxy's ceiling)",
"Timeout set to {:?} (this server's ceiling)",
self.timeout
)));
}
@@ -276,7 +276,7 @@ impl PreparedSend {
total += n;
if total > limits.max_response_bytes {
break Err(format!(
"Response body exceeds this proxy's limit of {} bytes",
"Response body exceeds this server's limit of {} bytes",
limits.max_response_bytes
));
}
@@ -309,7 +309,7 @@ impl PreparedSend {
///
/// 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.
/// desktop shows the outer `Debug`; a stranger reading its reply deserves the reason.
fn describe_error(err: &yaak_http::error::Error) -> String {
match err {
yaak_http::error::Error::Client(e) => {
@@ -1,4 +1,4 @@
//! What crosses the wire between a tab and this proxy.
//! What crosses the wire between a tab and this server.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
@@ -6,13 +6,13 @@
//! 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
//! Nothing here names a workspace, a request id, or a response id. The server
//! 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.
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
//! `@yaakapp-internal/web`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -23,14 +23,14 @@ use yaak_models::models::{
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_send_proxy.ts")]
#[ts(export, export_to = "gen_web.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
/// rendered by the tab. The server 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.
/// its timeline; the server 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)]
@@ -45,7 +45,7 @@ pub struct SendRequest {
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_send_proxy.ts")]
#[ts(export, export_to = "gen_web.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.