From 8a6e4810fd465eb99918de532ed3d6d4f19939da Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 15 Sep 2026 13:27:07 -0700 Subject: [PATCH] Listen on HOST and PORT instead of a bind address (#666) Co-authored-by: Claude Opus 5 --- Dockerfile.web | 5 +- apps/yaak-client/vite.config.ts | 19 ++++---- crates-server/yaak-web/README.md | 37 ++++++++------- crates-server/yaak-web/src/config.rs | 39 ++++++++++++++-- crates-server/yaak-web/src/lib.rs | 64 ++++++++++++++++++++++++-- crates-server/yaak-web/src/main.rs | 5 +- crates-server/yaak-web/tests/router.rs | 4 +- packages/platform/src/web/README.md | 2 +- scripts/run-web.mjs | 6 ++- 9 files changed, 141 insertions(+), 40 deletions(-) diff --git a/Dockerfile.web b/Dockerfile.web index dba755cc..460b5a1e 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -38,7 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web COPY --from=web /app/dist/apps/yaak-client /srv -ENV YAAK_WEB_BIND=0.0.0.0:8080 +# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a +# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed. +ENV HOST=0.0.0.0 +ENV PORT=8080 EXPOSE 8080 USER nobody # Overriding the command (dropping --serve) leaves the stateless send executor: diff --git a/apps/yaak-client/vite.config.ts b/apps/yaak-client/vite.config.ts index a5cafe24..da1c6202 100644 --- a/apps/yaak-client/vite.config.ts +++ b/apps/yaak-client/vite.config.ts @@ -30,20 +30,17 @@ const iconsDir = normalizePath( const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop"; /** - * Where `yaak-web` is listening, taken from the same variable that put it there. + * Where `yaak-web` is listening, taken from the same variables that put it there. * - * A wildcard bind is an instruction about what the server accepts, not an address - * to dial, so it becomes loopback here — the dev server and the send server share - * a machine. + * `HOST` is an instruction about what the server accepts rather than an address to + * dial, so a wildcard becomes loopback: the dev server and the send server share a + * machine. */ function sendServerUrl(): string { - const bind = process.env.YAAK_WEB_BIND?.trim(); - if (!bind) return "http://127.0.0.1:9227"; - const port = bind.slice(bind.lastIndexOf(":") + 1); - const host = bind.slice(0, bind.lastIndexOf(":")); - const dialable = - !host || host === "0.0.0.0" || host === "[::]" || host === "::" ? "127.0.0.1" : host; - return `http://${dialable}:${port}`; + const host = process.env.HOST?.trim(); + const port = process.env.PORT?.trim() || "9227"; + const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]"; + return `http://${wildcard ? "127.0.0.1" : host}:${port}`; } /** diff --git a/crates-server/yaak-web/README.md b/crates-server/yaak-web/README.md index 0b920113..74558f1c 100644 --- a/crates-server/yaak-web/README.md +++ b/crates-server/yaak-web/README.md @@ -74,9 +74,9 @@ YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client The dev server passes `/v1` through to this binary, so a dev build sends to its own origin exactly like a production build does — one address to open, and no -CORS in the loop. `YAAK_WEB_BIND` moves this server and the dev server follows -it. A production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` -was set when it was built. +CORS in the loop. `PORT` moves this server and the dev server follows it. A +production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was +set when it was built. Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows addresses but not names, so opening it as a hostname also needs @@ -84,21 +84,24 @@ addresses but not names, so opening it as a hostname also needs ## Configuration -Every flag has a `YAAK_WEB_*` environment variable, so a container needs no -arguments; `--help` lists them all. +Every flag has an environment variable, so a container needs no arguments; +`--help` lists them all. The listen address uses the platform conventions — +`HOST` and `PORT` — so the published image runs unchanged on a +platform-as-a-service that assigns a port. Everything else is `YAAK_WEB_*`. -| 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. | +| Flag | Default | What | +| -------------------------- | ----------- | --------------------------------------------------------------------------------------------- | +| `--serve` | off | Also serve a built web client from this directory, on the same origin. | +| `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. | +| `--port` | `9227` | Port to listen on. The image sets `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. | ## Logging diff --git a/crates-server/yaak-web/src/config.rs b/crates-server/yaak-web/src/config.rs index bd86c82f..b53fb158 100644 --- a/crates-server/yaak-web/src/config.rs +++ b/crates-server/yaak-web/src/config.rs @@ -1,5 +1,5 @@ use clap::Parser; -use std::net::SocketAddr; +use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; /// The server behind Yaak running in a browser. @@ -10,9 +10,25 @@ use std::path::PathBuf; #[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, + /// Interface to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container, + /// which is what makes the port reachable from outside it. + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + pub host: String, + + /// Port to listen on. Named `PORT` because that is the variable a platform-as-a-service + /// assigns and expects to be obeyed, so the same image runs there and under `docker run` + /// with nothing changed. + #[arg(long, env = "PORT", default_value_t = 9227)] + pub port: u16, + + /// Port the app is served on elsewhere. A browser that opens this server directly is + /// redirected there, keeping whatever hostname it used — so `home:9227` becomes + /// `home:1424` without this server knowing what "home" is. + /// + /// Set by `vp run web:dev`, where the app is on the dev server and this is only the + /// executor behind it. Irrelevant with `--serve`, which puts both on one port. + #[arg(long, env = "YAAK_WEB_APP_PORT")] + pub app_port: Option, /// 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. @@ -65,3 +81,18 @@ pub struct Config { #[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)] pub trust_forwarded_for: bool, } + +impl Config { + /// The address to listen on. + /// + /// Resolved rather than parsed, so `HOST` may be a name — `localhost` is the one people + /// actually type, and it is not an `IpAddr`. The first address wins; a host resolving to + /// several is a machine with several interfaces, and any of them is a listen address. + pub fn listen_addr(&self) -> Result { + (self.host.as_str(), self.port) + .to_socket_addrs() + .map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))? + .next() + .ok_or_else(|| format!("HOST {:?} resolved to no address", self.host)) + } +} diff --git a/crates-server/yaak-web/src/lib.rs b/crates-server/yaak-web/src/lib.rs index 2f3f2705..a1f50588 100644 --- a/crates-server/yaak-web/src/lib.rs +++ b/crates-server/yaak-web/src/lib.rs @@ -22,7 +22,7 @@ use axum::body::Body; 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::response::{IntoResponse, Json, Redirect, Response}; use axum::routing::{get, post}; pub use config::Config; use guard::DestinationPolicy; @@ -55,7 +55,7 @@ struct AppState { /// /// The returned router owns its state and accepts normal Axum middleware via `.layer()`. /// Construction does not parse arguments, initialize logging, bind a socket, or install -/// shutdown handlers. `config.bind` is used only by the standalone binary. +/// shutdown handlers. `config.host` and `config.port` are used only by the standalone binary. /// /// Serve with `into_make_service_with_connect_info::()` so the send endpoint /// can extract the peer address for rate limiting. @@ -97,7 +97,13 @@ pub fn router(config: Config) -> Router { info!("Serving the web client from {}", dir.display()); api.merge(web_router(dir)) } - None => api, + // Without `--serve` there is no app here, only `/v1`. Someone who opens this + // port in a browser guessed wrong about which of the two dev servers hosts the + // app, so send them to the right one when we have been told where it is. + None => { + let app_port = state.config.app_port; + api.fallback(move |headers: HeaderMap| async move { no_app_here(app_port, headers) }) + } } } @@ -140,6 +146,40 @@ async fn cache_control(req: Request, next: Next) -> Response { res } +/// The name in a `Host` header, without the port this server is reached on. +/// +/// An IPv6 literal keeps its brackets and its own colons: the last colon only separates a +/// port when what follows it isn't part of the address, which is what the `]` test decides. +fn host_without_port(host: &str) -> &str { + match host.rfind(':') { + Some(i) if !host[i..].contains(']') => &host[..i], + _ => host, + } +} + +/// What the send executor does with a browser that came looking for the app. +/// +/// With an app port configured this is a redirect, built from the `Host` header so the +/// hostname the browser already used is the one it keeps — this server has no idea which of +/// its addresses someone typed, and does not need to. +fn no_app_here(app_port: Option, headers: HeaderMap) -> Response { + if let Some(port) = app_port + && let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) + { + return Redirect::temporary(&format!("http://{}:{port}/", host_without_port(host))) + .into_response(); + } + + ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "This is the Yaak send executor. It serves the API under /v1 and no app.\n\n\ + To serve the app from here too, restart with --serve pointing at a built\n\ + web client.\n", + ) + .into_response() +} + fn allowed_origins(origins: &[String]) -> AllowOrigin { if origins.iter().any(|o| o.trim() == "*") { return AllowOrigin::any(); @@ -235,3 +275,21 @@ fn tokio_stream_from( ) -> impl futures_util::Stream + Send + 'static { futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)) } + +#[cfg(test)] +mod tests { + use super::host_without_port; + + #[test] + fn strips_the_port_and_keeps_the_name() { + assert_eq!(host_without_port("home:9227"), "home"); + assert_eq!(host_without_port("home"), "home"); + assert_eq!(host_without_port("192.168.1.5:9227"), "192.168.1.5"); + assert_eq!(host_without_port("192.168.1.5"), "192.168.1.5"); + // An IPv6 literal is full of colons, and only the one outside the brackets is a port. + assert_eq!(host_without_port("[::1]:9227"), "[::1]"); + assert_eq!(host_without_port("[::1]"), "[::1]"); + assert_eq!(host_without_port("[2606:4700::1111]:8080"), "[2606:4700::1111]"); + assert_eq!(host_without_port("[2606:4700::1111]"), "[2606:4700::1111]"); + } +} diff --git a/crates-server/yaak-web/src/main.rs b/crates-server/yaak-web/src/main.rs index d06f17f4..a28d3e22 100644 --- a/crates-server/yaak-web/src/main.rs +++ b/crates-server/yaak-web/src/main.rs @@ -12,7 +12,10 @@ async fn main() { ) .init(); let config = Config::parse(); - let bind = config.bind; + let bind = config.listen_addr().unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); let rate_limit_per_minute = config.rate_limit_per_minute; let app = router(config); diff --git a/crates-server/yaak-web/tests/router.rs b/crates-server/yaak-web/tests/router.rs index e954f77b..c18f4963 100644 --- a/crates-server/yaak-web/tests/router.rs +++ b/crates-server/yaak-web/tests/router.rs @@ -9,7 +9,9 @@ use yaak_web::{Config, router}; fn config(serve: Option) -> Config { Config { - bind: "127.0.0.1:0".parse().unwrap(), + host: "127.0.0.1".to_string(), + port: 0, + app_port: None, serve, allow_private_networks: false, allowed_origins: vec!["*".into()], diff --git a/packages/platform/src/web/README.md b/packages/platform/src/web/README.md index 0a99f07d..c01a3df2 100644 --- a/packages/platform/src/web/README.md +++ b/packages/platform/src/web/README.md @@ -223,6 +223,6 @@ production, so there is no address to learn and no CORS in the loop. A production build is served by the sender itself (`yaak-web --serve dist/apps/yaak-client`, which is what the `ghcr.io/mountain-loop/yaak-web` image runs). In development the Vite server passes `/v1` through to `yaak-web` -instead, following `YAAK_WEB_BIND` to find it — `vp run web:dev` starts both. +instead, following `PORT` to find it — `vp run web:dev` starts both. `VITE_YAAK_WEB_URL` overrides this, for a deployment that keeps the app and the server apart. diff --git a/scripts/run-web.mjs b/scripts/run-web.mjs index ceea0b69..ccb9bb33 100644 --- a/scripts/run-web.mjs +++ b/scripts/run-web.mjs @@ -94,7 +94,11 @@ switch (mode) { // The send executor behind the dev server, on the port a dev build looks for. case "proxy": - serve(buildServer(), []); + serve(buildServer(), [], { + // So opening the send server's port in a browser lands on the app instead + // of an explanation of why the app is not there. + YAAK_WEB_APP_PORT: process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", + }); break; case "build":