Listen on HOST and PORT instead of a bind address (#666)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-15 13:27:07 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 5a793e1d42
commit 8a6e4810fd
9 changed files with 141 additions and 40 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv 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 EXPOSE 8080
USER nobody USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor: # Overriding the command (dropping --serve) leaves the stateless send executor:
+8 -11
View File
@@ -30,20 +30,17 @@ const iconsDir = normalizePath(
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop"; 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 * `HOST` is an instruction about what the server accepts rather than an address to
* to dial, so it becomes loopback here — the dev server and the send server share * dial, so a wildcard becomes loopback: the dev server and the send server share a
* a machine. * machine.
*/ */
function sendServerUrl(): string { function sendServerUrl(): string {
const bind = process.env.YAAK_WEB_BIND?.trim(); const host = process.env.HOST?.trim();
if (!bind) return "http://127.0.0.1:9227"; const port = process.env.PORT?.trim() || "9227";
const port = bind.slice(bind.lastIndexOf(":") + 1); const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]";
const host = bind.slice(0, bind.lastIndexOf(":")); return `http://${wildcard ? "127.0.0.1" : host}:${port}`;
const dialable =
!host || host === "0.0.0.0" || host === "[::]" || host === "::" ? "127.0.0.1" : host;
return `http://${dialable}:${port}`;
} }
/** /**
+20 -17
View File
@@ -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 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 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 CORS in the loop. `PORT` moves this server and the dev server follows it. A
it. A production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was
was set when it was built. set when it was built.
Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows 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 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 ## Configuration
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no Every flag has an environment variable, so a container needs no arguments;
arguments; `--help` lists them all. `--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 | | Flag | Default | What |
| -------------------------- | ---------------- | --------------------------------------------------------------------------------------------- | | -------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
| `--serve` | off | Also serve a built web client from this directory, on the same origin. | | `--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`. | | `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. |
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. | | `--port` | `9227` | Port to listen on. The image sets `8080`. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. | | `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. | | `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. | | `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. | | `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. | | `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--max-concurrent` | 256 | Sends in flight at once. | | `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. | | `--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 ## Logging
+35 -4
View File
@@ -1,5 +1,5 @@
use clap::Parser; use clap::Parser;
use std::net::SocketAddr; use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf; use std::path::PathBuf;
/// The server behind Yaak running in a browser. /// The server behind Yaak running in a browser.
@@ -10,9 +10,25 @@ use std::path::PathBuf;
#[derive(Parser, Debug, Clone)] #[derive(Parser, Debug, Clone)]
#[command(name = "yaak-web", version, about, long_about = None)] #[command(name = "yaak-web", version, about, long_about = None)]
pub struct Config { pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container. /// Interface 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")] /// which is what makes the port reachable from outside it.
pub bind: SocketAddr, #[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<u16>,
/// Also serve a built web client from this directory, on the same origin as the API. /// 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. /// 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)] #[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool, 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<SocketAddr, String> {
(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))
}
}
+61 -3
View File
@@ -22,7 +22,7 @@ use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State}; use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next}; use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Redirect, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
pub use config::Config; pub use config::Config;
use guard::DestinationPolicy; use guard::DestinationPolicy;
@@ -55,7 +55,7 @@ struct AppState {
/// ///
/// The returned router owns its state and accepts normal Axum middleware via `.layer()`. /// 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 /// 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::<SocketAddr>()` so the send endpoint /// Serve with `into_make_service_with_connect_info::<SocketAddr>()` so the send endpoint
/// can extract the peer address for rate limiting. /// 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()); info!("Serving the web client from {}", dir.display());
api.merge(web_router(dir)) 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 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<u16>, 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 <DIR> pointing at a built\n\
web client.\n",
)
.into_response()
}
fn allowed_origins(origins: &[String]) -> AllowOrigin { fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") { if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any(); return AllowOrigin::any();
@@ -235,3 +275,21 @@ fn tokio_stream_from<T: Send + 'static>(
) -> impl futures_util::Stream<Item = T> + Send + 'static { ) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)) 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]");
}
}
+4 -1
View File
@@ -12,7 +12,10 @@ async fn main() {
) )
.init(); .init();
let config = Config::parse(); 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 rate_limit_per_minute = config.rate_limit_per_minute;
let app = router(config); let app = router(config);
+3 -1
View File
@@ -9,7 +9,9 @@ use yaak_web::{Config, router};
fn config(serve: Option<PathBuf>) -> Config { fn config(serve: Option<PathBuf>) -> Config {
Config { Config {
bind: "127.0.0.1:0".parse().unwrap(), host: "127.0.0.1".to_string(),
port: 0,
app_port: None,
serve, serve,
allow_private_networks: false, allow_private_networks: false,
allowed_origins: vec!["*".into()], allowed_origins: vec!["*".into()],
+1 -1
View File
@@ -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 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` 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` 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 `VITE_YAAK_WEB_URL` overrides this, for a deployment that keeps the app and the
server apart. server apart.
+5 -1
View File
@@ -94,7 +94,11 @@ switch (mode) {
// The send executor behind the dev server, on the port a dev build looks for. // The send executor behind the dev server, on the port a dev build looks for.
case "proxy": 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; break;
case "build": case "build":