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
+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
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
+35 -4
View File
@@ -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<u16>,
/// 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<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::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::<SocketAddr>()` 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<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 {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
@@ -235,3 +275,21 @@ fn tokio_stream_from<T: Send + 'static>(
) -> impl futures_util::Stream<Item = T> + 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]");
}
}
+4 -1
View File
@@ -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);
+3 -1
View File
@@ -9,7 +9,9 @@ use yaak_web::{Config, router};
fn config(serve: Option<PathBuf>) -> 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()],