mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 06:42:02 +02:00
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:
co-authored by
Claude Opus 5
parent
5a793e1d42
commit
8a6e4810fd
+4
-1
@@ -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:
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-1
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user