From 8cc485cf8186ada427aa79701c400dc01580ef10 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 8 Sep 2026 22:17:39 +0200 Subject: [PATCH] fix(self-host): tie the public address to a single origin (#2605) --- deploy/self-hosting/.env.example | 97 +++++--- deploy/self-hosting/docker-compose.yml | 9 +- deploy/self-hosting/tunnel.compose.yml | 2 + fluxer_app_proxy/src/config.rs | 66 ++++- fluxer_common/src/config.rs | 232 +++++++++++++++++- fluxer_gifs/src/media_proxy.rs | 1 + fluxer_media_proxy/src/config/mod.rs | 15 +- fluxer_media_proxy/src/config/tests/mod.rs | 1 + .../src/config/tests/public_endpoint.rs | 74 ++++++ fluxer_media_proxy/src/server/self_origin.rs | 55 +++++ fluxer_unfurl/src/shard_impl.rs | 3 +- packages/config/src/ConfigLoader.ts | 73 +++++- packages/config/src/EndpointDerivation.ts | 53 +++- packages/config/src/MasterConfig.ts | 1 + .../config/src/__tests__/ConfigLoader.test.ts | 179 ++++++++++++++ .../src/__tests__/EndpointDerivation.test.ts | 102 ++++++++ .../src/config_loader/EnvironmentOverrides.ts | 1 + 17 files changed, 903 insertions(+), 61 deletions(-) create mode 100644 fluxer_media_proxy/src/config/tests/public_endpoint.rs diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index a0ea53a37..298884654 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -3,12 +3,20 @@ # A name absent from this file is one Compose does not forward, and it reaches a # service only through a Compose override file that adds it to that service's # environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when -# a Compose edit forgets the matching line here. +# a Compose edit forgets the matching line here. Compose expands this file from +# top to bottom, so a line written with ${...} has to sit below every name it +# reads. FLUXER_DOMAIN=chat.example.com FLUXER_PUBLIC_SCHEME=https FLUXER_PUBLIC_PORT=443 +# The three lines above are the address browsers use, and every endpoint the +# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move +# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down +# do that, and a non-default port needs the matching one set as well. Both +# complete recipes are written out beside them. + # How browsers reach this instance. # # Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate. @@ -33,50 +41,71 @@ FLUXER_PUBLIC_PORT=443 # address if it reaches Fluxer from a public IP. #FLUXER_EDGE_TRUSTED_PROXIES=private_ranges -# The public origin browsers use, without a trailing slash. Derived from the three -# values above and correct for the usual https-on-443 setup, so leave it alone -# unless you serve Fluxer on a non-default port, where the port must appear here. +# The origin browsers see, without a trailing slash. Leave it unset and each +# service builds one from the three values at the top of this file. Set it and it +# wins: every service reads the host, the scheme and the port out of it and +# ignores those three names. Use it when browsers reach the instance on a host +# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host +# and an optional port and nothing after them, or the services refuse to start. +# It does not move the edge listener or the published ports either, so set the +# publish below to the port written here. #FLUXER_PUBLIC_ORIGIN=https://chat.example.com -# Overrides the address Fluxer's edge listens on. Honoured in the default mode -# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last -# file win, so a value here is discarded under the proxy overlay with no warning. -# Set it only for an unusual default-mode layout, such as serving several -# hostnames or binding a non-default TLS port. -#FLUXER_EDGE_SITE_ADDRESS=chat.example.com +# Overrides the address the edge listens on inside its container. Compose builds +# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps +# its container ports at 80 and 443 whatever the public port is. Caddy matches a +# site by host and ignores the port in the Host header, so a request arriving on +# a non-default published port still lands on this site. Put a port in this value +# only if you also publish that same container port below, or nothing will be +# listening where the publish points. Honoured in the default mode only: +# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the +# literal :80, and Compose lets the last file win, so a value here is discarded +# under either overlay with no warning. Set it for an unusual default-mode +# layout, such as serving several hostnames. Write the scheme into it: a bare +# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says. +#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com # The old name for the value above. It is read only when # FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener # it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience. #FLUXER_CADDY_SITE_ADDRESS= -# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port -# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an -# origin written with a default port never matches a browser Origin header. -# Serving on any other port means setting all three, plus the published port -# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host. -# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to -# stay below the two values it reads. Above them it silently expands to a bare -# host with a trailing colon. -#FLUXER_PUBLIC_SCHEME=http -#FLUXER_PUBLIC_PORT=19080 -#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT} -#FLUXER_HTTP_PORT=19080 - -# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside -# the container, so change only these when something else already owns the -# standard ports or another proxy sits in front. Both take an optional bind -# address in front of the port, and 127.0.0.1 keeps the publish off every -# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish -# together, because HTTP/3 needs both on the same port. +# Host side of the edge's publishes, and the only two names that decide which +# host ports Fluxer binds. The container side is fixed. Container 80 carries the +# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https +# scheme, and the site itself under an http one. Container 443 carries the TLS +# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because +# HTTP/3 needs both on the same port. Both take an optional bind address in front +# of the port, and 127.0.0.1 keeps the publish off every public interface. Give +# them different host ports: the same host port on both is two publishes of one +# port and the edge refuses to start. #FLUXER_HTTP_PORT=80 #FLUXER_HTTPS_PORT=443 #FLUXER_HTTP_PORT=127.0.0.1:80 #FLUXER_HTTPS_PORT=127.0.0.1:443 +# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME +# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the +# certificate is issued if a router in front forwards public 80 to this host and +# is not issued otherwise. Serve your own certificate from the Caddyfile when it +# cannot. +#FLUXER_PUBLIC_PORT=8443 +#FLUXER_HTTPS_PORT=8443 + +# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing +# binds host 80. Under an http scheme nothing listens on container 443, so the +# last line parks that publish on loopback for a host that wants 443 for +# something else. Drop it and 443 is published and idle, which is what earlier +# releases did. +#FLUXER_PUBLIC_SCHEME=http +#FLUXER_PUBLIC_PORT=19080 +#FLUXER_HTTP_PORT=19080 +#FLUXER_HTTPS_PORT=127.0.0.1:443 + # A tunnel or another proxy in front of the stack needs no HTTPS publish at all. # tunnel.compose.yml ships beside this file and replaces Caddy's published ports -# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT +# with a single loopback HTTP publish, so nothing binds 443, and points the edge +# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT # still moves that one publish. Set the line below and plain docker compose # commands pick the file up, or add it to your own -f flags if you pass any. The # file uses the !override tag, which needs Compose 2.24.4 or newer. @@ -153,9 +182,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME LIVEKIT_API_KEY=fluxer LIVEKIT_API_SECRET=CHANGE_ME -# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME, -# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty. -# Set it only when LiveKit is served from another host. +# The URL browsers use for voice signalling. Compose builds it from +# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and +# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a +# leading http to ws itself. Set it only when LiveKit is served from another +# host. #FLUXER_LIVEKIT_URL= # Media ports. LiveKit advertises these in ICE candidates, so the host must diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index 2bb82639a..66498cdf8 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -18,6 +18,7 @@ x-fluxer-env: &fluxer-env FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env} FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https} FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443} + FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-} FLUXER_TRUST_CLIENT_IP_HEADER: "true" FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000} @@ -56,7 +57,7 @@ x-fluxer-env: &fluxer-env FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880 FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}' - FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit} + FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit} FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-} @@ -132,7 +133,7 @@ services: - "${FLUXER_HTTPS_PORT:-443}:443" - "${FLUXER_HTTPS_PORT:-443}:443/udp" environment: - FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}} + FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}} FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro @@ -494,6 +495,10 @@ services: environment: FLUXER_APP_PROXY_HOST: 0.0.0.0 FLUXER_APP_PROXY_PORT: "8080" + FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env} + FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https} + FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443} + FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-} DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer PUBLIC_BOOTSTRAP_API_ENDPOINT: /api PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api diff --git a/deploy/self-hosting/tunnel.compose.yml b/deploy/self-hosting/tunnel.compose.yml index 5872eea89..267d66d65 100644 --- a/deploy/self-hosting/tunnel.compose.yml +++ b/deploy/self-hosting/tunnel.compose.yml @@ -2,3 +2,5 @@ services: edge: ports: !override - "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80" + environment: + FLUXER_EDGE_SITE_ADDRESS: ":80" diff --git a/fluxer_app_proxy/src/config.rs b/fluxer_app_proxy/src/config.rs index be52f8e9a..cdb215743 100644 --- a/fluxer_app_proxy/src/config.rs +++ b/fluxer_app_proxy/src/config.rs @@ -547,23 +547,26 @@ fn resolve_time_freeze_enabled_from_env() -> bool { fn resolve_bootstrap_api_public_endpoint_from_env() -> Option { resolve_bootstrap_api_public_endpoint(|name| env::var(name).ok()) + .unwrap_or_else(|error| panic!("{error}")) } -fn resolve_bootstrap_api_public_endpoint(mut read_var: F) -> Option +fn resolve_bootstrap_api_public_endpoint(mut read_var: F) -> anyhow::Result> where F: FnMut(&str) -> Option, { - let endpoint = read_var("PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT") + let (base_domain, public_port) = cfg::resolve_public_domain_and_port(&mut read_var)?; + let Some(endpoint) = read_var("PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT") .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty())?; - let base_domain = read_var("FLUXER_BASE_DOMAIN").unwrap_or_default(); - let public_port = read_var("FLUXER_PUBLIC_PORT").and_then(|port| port.trim().parse().ok()); + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; - Some(cfg::normalize_public_endpoint( + Ok(Some(cfg::normalize_public_endpoint( &endpoint, &base_domain, public_port, - )) + ))) } fn resolve_time_freeze_enabled(mut read_var: F) -> bool @@ -635,6 +638,12 @@ mod tests { } fn resolve_bootstrap_endpoint_from_pairs(pairs: &[(&str, &str)]) -> Option { + try_resolve_bootstrap_endpoint_from_pairs(pairs).expect("the boot html endpoint resolves") + } + + fn try_resolve_bootstrap_endpoint_from_pairs( + pairs: &[(&str, &str)], + ) -> anyhow::Result> { let env: HashMap<&str, &str> = pairs.iter().copied().collect(); resolve_bootstrap_api_public_endpoint(|name| env.get(name).map(|value| value.to_string())) } @@ -696,6 +705,49 @@ mod tests { assert_eq!(resolve_bootstrap_endpoint_from_pairs(&[]), None); } + #[test] + fn the_public_origin_supplies_the_boot_html_port() { + assert_eq!( + resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "https://fluxer.example/api", + ), + ("FLUXER_PUBLIC_ORIGIN", "https://fluxer.example:19080"), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "443"), + ]), + Some("https://fluxer.example:19080/api".to_owned()) + ); + } + + #[test] + fn a_malformed_public_port_is_loud() { + let error = try_resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "http://fluxer.example/api", + ), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "not-a-port"), + ]) + .expect_err("a malformed port is refused"); + assert!(error.to_string().contains("FLUXER_PUBLIC_PORT")); + } + + #[test] + fn a_malformed_public_origin_is_loud() { + let error = try_resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "http://fluxer.example/api", + ), + ("FLUXER_PUBLIC_ORIGIN", "fluxer.example:19080"), + ]) + .expect_err("a malformed origin is refused"); + assert!(error.to_string().contains("FLUXER_PUBLIC_ORIGIN")); + } + #[test] fn csp_config_default_has_no_extra_sources() { let c = CspConfig::default(); diff --git a/fluxer_common/src/config.rs b/fluxer_common/src/config.rs index ca6dbc523..aefbc77ba 100644 --- a/fluxer_common/src/config.rs +++ b/fluxer_common/src/config.rs @@ -207,9 +207,85 @@ fn strip_trailing_dot(host: &str) -> &str { host.strip_suffix('.').unwrap_or(host) } +fn canonicalize_domain(value: &str) -> String { + strip_trailing_dot(value.trim().to_lowercase().as_str()).to_owned() +} + +fn default_port(scheme: &str) -> u16 { + if scheme == "https" { 443 } else { 80 } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublicOrigin { + pub scheme: String, + pub domain: String, + pub port: u16, +} + +pub fn parse_public_origin(origin: &str) -> Option { + let trimmed = origin.trim(); + if trimmed.is_empty() { + return None; + } + let parsed = reqwest::Url::parse(trimmed).ok()?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return None; + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + let domain = canonicalize_domain(parsed.host_str()?); + if domain.is_empty() { + return None; + } + Some(PublicOrigin { + scheme: scheme.to_owned(), + domain, + port: parsed.port().unwrap_or_else(|| default_port(scheme)), + }) +} + +pub fn resolve_public_domain_and_port(mut read_var: F) -> anyhow::Result<(String, Option)> +where + F: FnMut(&str) -> Option, +{ + let mut non_empty_var = |name: &str| { + read_var(name) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }; + let configured_port = match non_empty_var("FLUXER_PUBLIC_PORT") { + None => None, + Some(raw) => Some( + raw.parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| { + anyhow::anyhow!( + "FLUXER_PUBLIC_PORT must be a port between 1 and 65535, got {raw}" + ) + })?, + ), + }; + let configured_domain = + non_empty_var("FLUXER_BASE_DOMAIN").map(|value| canonicalize_domain(&value)); + let Some(origin) = non_empty_var("FLUXER_PUBLIC_ORIGIN") else { + return Ok((configured_domain.unwrap_or_default(), configured_port)); + }; + let parsed = parse_public_origin(&origin).ok_or_else(|| { + anyhow::anyhow!( + "FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got {origin}" + ) + })?; + Ok((parsed.domain, Some(parsed.port))) +} + pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Option) -> String { - let domain = base_domain.trim().to_lowercase(); - let domain = strip_trailing_dot(&domain); + let domain = canonicalize_domain(base_domain); let Some(port) = public_port.filter(|port| *port != 0) else { return url.to_owned(); }; @@ -219,8 +295,7 @@ pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Opti let Ok(parsed) = reqwest::Url::parse(url) else { return url.to_owned(); }; - let host = parsed.host_str().unwrap_or_default().to_lowercase(); - if strip_trailing_dot(&host) != domain { + if canonicalize_domain(parsed.host_str().unwrap_or_default()) != domain { return url.to_owned(); } if is_default_port(parsed.scheme(), port) { @@ -244,12 +319,13 @@ pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Opti format!("{}:{port}{}", &url[..authority_end], &url[authority_end..]) } +pub fn try_normalize_public_endpoint_from_env(url: &str) -> anyhow::Result { + let (base_domain, public_port) = resolve_public_domain_and_port(|name| env::var(name).ok())?; + Ok(normalize_public_endpoint(url, &base_domain, public_port)) +} + pub fn normalize_public_endpoint_from_env(url: &str) -> String { - normalize_public_endpoint( - url, - &read_env("FLUXER_BASE_DOMAIN", ""), - non_empty_env("FLUXER_PUBLIC_PORT").and_then(|port| port.parse().ok()), - ) + try_normalize_public_endpoint_from_env(url).unwrap_or_else(|error| panic!("{error}")) } #[cfg(test)] @@ -582,6 +658,144 @@ mod tests { } } + fn reader(vars: &[(&str, &str)]) -> impl FnMut(&str) -> Option { + let vars: Vec<(String, String)> = vars + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(); + move |name: &str| { + vars.iter() + .find_map(|(key, value)| (key == name).then(|| value.clone())) + } + } + + #[test] + fn parses_a_public_origin() { + assert_eq!( + Some(PublicOrigin { + scheme: "https".to_owned(), + domain: "chat.example.com".to_owned(), + port: 8443, + }), + parse_public_origin("https://chat.example.com:8443") + ); + assert_eq!( + Some(PublicOrigin { + scheme: "http".to_owned(), + domain: "chat.example.com".to_owned(), + port: 80, + }), + parse_public_origin("http://chat.example.com") + ); + assert_eq!( + Some(PublicOrigin { + scheme: "http".to_owned(), + domain: "[::1]".to_owned(), + port: 19080, + }), + parse_public_origin("http://[::1]:19080") + ); + } + + #[test] + fn an_explicit_default_port_normalizes_to_the_portless_form() { + let origin = parse_public_origin("https://chat.example.com:443").expect("origin parses"); + assert_eq!(443, origin.port); + assert_eq!( + "https://chat.example.com/admin/oauth2_callback", + normalize_public_endpoint( + "https://chat.example.com/admin/oauth2_callback", + &origin.domain, + Some(origin.port) + ) + ); + assert_eq!( + 80, + parse_public_origin("http://chat.example.com:80") + .expect("origin parses") + .port + ); + } + + #[test] + fn rejects_anything_that_is_not_a_bare_origin() { + for origin in [ + "", + " ", + "not a url", + "chat.example.com:8443", + "wss://chat.example.com", + "https://chat.example.com/media", + "https://chat.example.com?a=1", + "https://user:pw@chat.example.com", + ] { + assert_eq!(None, parse_public_origin(origin), "origin {origin}"); + } + } + + #[test] + fn the_origin_supplies_the_domain_and_the_port() { + let (domain, port) = resolve_public_domain_and_port(reader(&[( + "FLUXER_PUBLIC_ORIGIN", + "https://chat.example.com:29080", + )])) + .expect("origin resolves"); + assert_eq!("chat.example.com", domain); + assert_eq!(Some(29080), port); + } + + #[test] + fn the_origin_wins_over_the_named_variables() { + let (domain, port) = resolve_public_domain_and_port(reader(&[ + ("FLUXER_PUBLIC_ORIGIN", "https://chat.example.com:29080"), + ("FLUXER_BASE_DOMAIN", "other.example.com"), + ("FLUXER_PUBLIC_SCHEME", "http"), + ("FLUXER_PUBLIC_PORT", "443"), + ])) + .expect("the origin resolves"); + assert_eq!("chat.example.com", domain); + assert_eq!(Some(29080), port); + } + + #[test] + fn a_malformed_public_port_is_loud() { + for raw in ["abc", "0", "70000", "-1"] { + let error = resolve_public_domain_and_port(reader(&[ + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", raw), + ])) + .expect_err("a malformed port is refused"); + assert!( + error.to_string().contains("FLUXER_PUBLIC_PORT"), + "port {raw}" + ); + } + } + + #[test] + fn a_malformed_public_origin_is_loud() { + let error = resolve_public_domain_and_port(reader(&[( + "FLUXER_PUBLIC_ORIGIN", + "https://chat.example.com/app", + )])) + .expect_err("a malformed origin is refused"); + assert!(error.to_string().contains("FLUXER_PUBLIC_ORIGIN")); + } + + #[test] + fn without_an_origin_the_named_variables_are_used_as_they_are() { + let (domain, port) = resolve_public_domain_and_port(reader(&[ + ("FLUXER_BASE_DOMAIN", "Chat.Example.com."), + ("FLUXER_PUBLIC_PORT", "19080"), + ])) + .expect("named variables resolve"); + assert_eq!("chat.example.com", domain); + assert_eq!(Some(19080), port); + let (domain, port) = resolve_public_domain_and_port(reader(&[])).expect("empty resolves"); + assert_eq!("", domain); + assert_eq!(None, port); + } + #[test] fn trim_trailing_slash_works() { assert_eq!( diff --git a/fluxer_gifs/src/media_proxy.rs b/fluxer_gifs/src/media_proxy.rs index 5158f6736..9bf094316 100644 --- a/fluxer_gifs/src/media_proxy.rs +++ b/fluxer_gifs/src/media_proxy.rs @@ -90,6 +90,7 @@ mod tests { "FLUXER_MEDIA_PROXY_SECRET_KEY", "FLUXER_BASE_DOMAIN", "FLUXER_PUBLIC_PORT", + "FLUXER_PUBLIC_ORIGIN", ]; let saved = keys .iter() diff --git a/fluxer_media_proxy/src/config/mod.rs b/fluxer_media_proxy/src/config/mod.rs index 663690191..ea73c7809 100644 --- a/fluxer_media_proxy/src/config/mod.rs +++ b/fluxer_media_proxy/src/config/mod.rs @@ -121,6 +121,10 @@ impl Config { !secret_key.is_empty(), "FLUXER_MEDIA_PROXY_SECRET_KEY is required" ); + let (public_base_domain, public_port) = + fluxer_common::config::resolve_public_domain_and_port(|name| { + env.get(name).map(ToOwned::to_owned) + })?; Ok(Self { node_env: env.get("NODE_ENV").unwrap_or("development").to_owned(), @@ -134,8 +138,15 @@ impl Config { 8080, )?, secret_key, - public_endpoint: non_empty(env.get("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT")) - .map(|endpoint| endpoint.trim_end_matches('/').to_owned()), + public_endpoint: non_empty(env.get("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT")).map( + |endpoint| { + fluxer_common::config::normalize_public_endpoint( + endpoint.trim_end_matches('/'), + &public_base_domain, + public_port, + ) + }, + ), mode, read_only: parse_bool( "FLUXER_MEDIA_PROXY_READ_ONLY", diff --git a/fluxer_media_proxy/src/config/tests/mod.rs b/fluxer_media_proxy/src/config/tests/mod.rs index 8a74f8400..4daf28e03 100644 --- a/fluxer_media_proxy/src/config/tests/mod.rs +++ b/fluxer_media_proxy/src/config/tests/mod.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod public_endpoint; mod s3_read; use super::*; diff --git a/fluxer_media_proxy/src/config/tests/public_endpoint.rs b/fluxer_media_proxy/src/config/tests/public_endpoint.rs new file mode 100644 index 000000000..cb758e154 --- /dev/null +++ b/fluxer_media_proxy/src/config/tests/public_endpoint.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::env_with; +use crate::config::Config; + +const ENDPOINT: &str = "https://chat.example.com/media"; + +#[test] +fn a_non_default_public_port_reaches_the_public_endpoint() { + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", ENDPOINT), + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", "29080"), + ])) + .expect("config loads"); + assert_eq!( + Some("https://chat.example.com:29080/media".to_owned()), + cfg.public_endpoint + ); +} + +#[test] +fn the_public_origin_supplies_the_port() { + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", ENDPOINT), + ("FLUXER_PUBLIC_ORIGIN", "https://chat.example.com:29080"), + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", "443"), + ])) + .expect("config loads"); + assert_eq!( + Some("https://chat.example.com:29080/media".to_owned()), + cfg.public_endpoint + ); +} + +#[test] +fn an_unrelated_host_keeps_its_endpoint() { + let cfg = Config::load_from_iter(env_with(&[ + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + "https://cdn.other.example/media", + ), + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", "29080"), + ])) + .expect("config loads"); + assert_eq!( + Some("https://cdn.other.example/media".to_owned()), + cfg.public_endpoint + ); +} + +#[test] +fn a_default_public_port_keeps_the_endpoint_portless() { + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", ENDPOINT), + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", "443"), + ])) + .expect("config loads"); + assert_eq!(Some(ENDPOINT.to_owned()), cfg.public_endpoint); +} + +#[test] +fn a_malformed_public_port_is_refused() { + let error = Config::load_from_iter(env_with(&[ + ("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", ENDPOINT), + ("FLUXER_BASE_DOMAIN", "chat.example.com"), + ("FLUXER_PUBLIC_PORT", "not-a-port"), + ])) + .expect_err("a malformed port is refused"); + assert!(error.to_string().contains("FLUXER_PUBLIC_PORT")); +} diff --git a/fluxer_media_proxy/src/server/self_origin.rs b/fluxer_media_proxy/src/server/self_origin.rs index 33159e301..1367b18e2 100644 --- a/fluxer_media_proxy/src/server/self_origin.rs +++ b/fluxer_media_proxy/src/server/self_origin.rs @@ -192,6 +192,61 @@ mod tests { assert!(resolve(&app, &format!("{ENDPOINT}/channels/1/2")).is_none()); } + #[test] + fn a_non_default_public_port_reaches_the_signed_self_origin() { + let app = AppState::for_tests( + Config::load_from_iter([ + ( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + ), + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT".to_owned(), + format!("{ENDPOINT}/"), + ), + ( + "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), + "local".to_owned(), + ), + ( + "FLUXER_BASE_DOMAIN".to_owned(), + "chat.example.com".to_owned(), + ), + ("FLUXER_PUBLIC_PORT".to_owned(), "29080".to_owned()), + ]) + .expect("self origin test config"), + ); + let endpoint = app + .cfg + .public_endpoint + .clone() + .expect("a public endpoint is configured"); + assert_eq!("https://chat.example.com:29080/media", endpoint); + let target = "https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp"; + let proxied = fluxer_common::external_media_path::build_external_media_proxy_url( + &endpoint, + target, + app.cfg.secret_key.as_bytes(), + ) + .expect("proxy url"); + match resolve(&app, &proxied) { + Some(SelfOrigin::External { url }) => assert_eq!(target, url), + _ => panic!("its own signed url resolves to the origin url"), + } + match resolve( + &app, + &format!("{endpoint}/attachments/1544725486800732163/1544971349200470016/cat.gif"), + ) { + Some(SelfOrigin::Stored { key, .. }) => assert_eq!( + "attachments/1544725486800732163/1544971349200470016/cat.gif", + key + ), + _ => panic!("its own attachment paths resolve to stored objects"), + } + assert!(resolve(&app, "https://cdn.other.example/avatars/1/abc.png").is_none()); + assert!(resolve(&app, &format!("{ENDPOINT}/avatars/1/abc.png")).is_none()); + } + #[test] fn own_path_matches_the_endpoint_prefix() { assert_eq!( diff --git a/fluxer_unfurl/src/shard_impl.rs b/fluxer_unfurl/src/shard_impl.rs index 079690e47..91e749f68 100644 --- a/fluxer_unfurl/src/shard_impl.rs +++ b/fluxer_unfurl/src/shard_impl.rs @@ -333,7 +333,7 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::new(()); - const PUBLIC_ENDPOINT_ENV: [&str; 7] = [ + const PUBLIC_ENDPOINT_ENV: [&str; 8] = [ "FLUXER_MEDIA_PROXY_ENDPOINT", "FLUXER_MEDIA_PROXY_SECRET_KEY", "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", @@ -341,6 +341,7 @@ mod tests { "FLUXER_STATIC_CDN_ENDPOINT", "FLUXER_BASE_DOMAIN", "FLUXER_PUBLIC_PORT", + "FLUXER_PUBLIC_ORIGIN", ]; fn shard_from_env(vars: &[(&str, &str)]) -> UnfurlShard { diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 822cdc9cd..66237e43b 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -3,9 +3,11 @@ import {createECDH} from 'node:crypto'; import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides'; import { + buildUrl, type DerivedEndpoints, deriveEndpointsFromDomain, normalizePublicEndpoint, + parsePublicOrigin, } from '@fluxer/config/src/EndpointDerivation'; import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; @@ -26,6 +28,7 @@ function defaultConfig(): MasterConfig { env: 'development', domain: { base_domain: '', + public_origin: '', public_scheme: 'http', internal_scheme: 'http', public_port: 8088, @@ -476,6 +479,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000); assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000); assertIntegerInRange(config.services.api.request_timeout_ms, 'FLUXER_API_REQUEST_TIMEOUT_MS', 1_000, 3_600_000); + assertIntegerInRange(config.domain.public_port, 'FLUXER_PUBLIC_PORT', 1, 65_535); requireString(config.domain.base_domain, 'FLUXER_BASE_DOMAIN'); requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET'); requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET'); @@ -493,16 +497,51 @@ function normalizeConfig(config: MasterConfig): MasterConfig { return config; } +function applyPublicOrigin(config: MasterConfig): MasterConfig { + const raw = config.domain.public_origin.trim(); + if (raw.length === 0) { + return config; + } + const origin = parsePublicOrigin(raw); + if (!origin) { + throw new Error( + `FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got ${raw}`, + ); + } + return { + ...config, + domain: { + ...config.domain, + base_domain: origin.base_domain, + public_scheme: origin.public_scheme, + public_port: origin.public_port, + }, + }; +} + function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): MasterConfig { const {base_domain, public_port} = config.domain; const normalize = (url: string) => normalizePublicEndpoint(url, base_domain, public_port); + const normalizeOptional = (url: string | undefined) => (url === undefined ? undefined : normalize(url)); const normalizedEndpoints = {...endpoints}; for (const key of Object.keys(normalizedEndpoints) as Array) { normalizedEndpoints[key] = normalize(normalizedEndpoints[key]); } + const {bluesky, passkeys} = config.auth; + const {branding} = config.instance; + const {email, sms, voice} = config.integrations; return { ...config, + domain: { + ...config.domain, + public_origin: buildUrl(config.domain.public_scheme, base_domain, public_port), + }, endpoints: normalizedEndpoints, + s3: config.s3 && {...config.s3, presigned_url_base: normalizeOptional(config.s3.presigned_url_base)}, + s3_downloads: config.s3_downloads && { + ...config.s3_downloads, + presigned_url_base: normalizeOptional(config.s3_downloads.presigned_url_base), + }, services: { ...config.services, media_proxy: { @@ -512,12 +551,40 @@ function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): Mas endpoint: normalize(config.services.media_proxy.upload_relay.endpoint), }, }, + gateway: { + ...config.services.gateway, + media_proxy_endpoint: normalizeOptional(config.services.gateway.media_proxy_endpoint), + }, }, auth: { ...config.auth, passkeys: { - ...config.auth.passkeys, - additional_allowed_origins: config.auth.passkeys.additional_allowed_origins.map(normalize), + ...passkeys, + additional_allowed_origins: passkeys.additional_allowed_origins.map(normalize), + }, + bluesky: { + ...bluesky, + client_uri: normalize(bluesky.client_uri), + logo_uri: normalize(bluesky.logo_uri), + tos_uri: normalize(bluesky.tos_uri), + policy_uri: normalize(bluesky.policy_uri), + }, + }, + integrations: { + ...config.integrations, + email: {...email, app_base_url: normalize(email.app_base_url)}, + sms: {...sms, inbound_webhook_public_url: normalizeOptional(sms.inbound_webhook_public_url)}, + voice: {...voice, url: normalize(voice.url)}, + }, + instance: { + ...config.instance, + branding: { + ...branding, + icon_url: normalizeOptional(branding.icon_url), + symbol_url: normalizeOptional(branding.symbol_url), + logo_url: normalizeOptional(branding.logo_url), + wordmark_url: normalizeOptional(branding.wordmark_url), + favicon_url: normalizeOptional(branding.favicon_url), }, }, }; @@ -546,7 +613,7 @@ export async function loadConfig(): Promise { return cachedConfig; } const overrides = buildNamedFluxerEnvOverrides(process.env); - const merged = mergeConfig(defaultConfig(), overrides); + const merged = applyPublicOrigin(mergeConfig(defaultConfig(), overrides)); const normalized = normalizeConfig(merged); const derived = deriveEndpointsFromDomain(normalized.domain); const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})}; diff --git a/packages/config/src/EndpointDerivation.ts b/packages/config/src/EndpointDerivation.ts index 871c59e23..a5af49676 100644 --- a/packages/config/src/EndpointDerivation.ts +++ b/packages/config/src/EndpointDerivation.ts @@ -13,6 +13,14 @@ export interface DomainConfig { gift_domain?: string; } +type PublicOriginScheme = 'http' | 'https'; + +interface PublicOrigin { + public_scheme: PublicOriginScheme; + base_domain: string; + public_port: number; +} + export interface DerivedEndpoints { api: string; api_client: string; @@ -36,8 +44,45 @@ function isStandardPort(scheme: string, port: number): boolean { ); } -function stripTrailingDot(host: string): string { - return host.endsWith('.') ? host.slice(0, -1) : host; +export function canonicalizeDomain(value: string): string { + const trimmed = value.trim().toLowerCase(); + return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; +} + +function defaultPortForScheme(scheme: PublicOriginScheme): number { + return scheme === 'https' ? 443 : 80; +} + +export function parsePublicOrigin(origin: string): PublicOrigin | null { + const trimmed = origin.trim(); + if (trimmed.length === 0) { + return null; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + const scheme = parsed.protocol.slice(0, -1); + if (scheme !== 'http' && scheme !== 'https') { + return null; + } + if (parsed.pathname !== '/' || parsed.search.length > 0 || parsed.hash.length > 0) { + return null; + } + if (parsed.username.length > 0 || parsed.password.length > 0) { + return null; + } + const base_domain = canonicalizeDomain(parsed.hostname); + if (base_domain.length === 0) { + return null; + } + return { + public_scheme: scheme, + base_domain, + public_port: parsed.port.length === 0 ? defaultPortForScheme(scheme) : Number.parseInt(parsed.port, 10), + }; } export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string { @@ -47,7 +92,7 @@ export function buildUrl(scheme: string, domain: string, port?: number, path?: s } export function normalizePublicEndpoint(url: string, baseDomain: string, publicPort?: number): string { - const domain = stripTrailingDot(baseDomain.trim().toLowerCase()); + const domain = canonicalizeDomain(baseDomain); if (domain.length === 0 || !publicPort) { return url; } @@ -57,7 +102,7 @@ export function normalizePublicEndpoint(url: string, baseDomain: string, publicP } catch { return url; } - if (stripTrailingDot(parsed.hostname.toLowerCase()) !== domain) { + if (canonicalizeDomain(parsed.hostname) !== domain) { return url; } if (isStandardPort(parsed.protocol.slice(0, -1), publicPort)) { diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index dba2a9390..9cb6d935d 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -20,6 +20,7 @@ export interface MasterConfig { env: RuntimeEnv; domain: { base_domain: string; + public_origin: string; public_scheme: PublicScheme; internal_scheme: PublicScheme; public_port: number; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 8859cc5d5..8fd1b2d63 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -646,4 +646,183 @@ describe('ConfigLoader', () => { vi.stubEnv('FLUXER_ENV', 'test'); await expect(loadConfig()).rejects.toThrow(); }); + + test('inserts the public port into the LiveKit url', async () => { + stubMinimalEnv({FLUXER_LIVEKIT_URL: 'http://localhost/livekit'}); + + const config = await loadConfig(); + + expect(config.integrations.voice.url).toBe('http://localhost:8088/livekit'); + }); + + test('inserts the public port into every other public url the config carries', async () => { + stubMinimalEnv({ + FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: 'http://localhost/media', + FLUXER_S3_PUBLIC_ENDPOINT: 'http://localhost/s3', + FLUXER_EMAIL_APP_BASE_URL: 'http://localhost', + FLUXER_SMS_INBOUND_WEBHOOK_PUBLIC_URL: 'http://localhost/webhooks/sms', + FLUXER_AUTH_BLUESKY_CLIENT_URI: 'http://localhost', + FLUXER_AUTH_BLUESKY_TOS_URI: 'http://localhost/terms', + FLUXER_APP_ICON_URL: 'http://localhost/icon.png', + FLUXER_LIVEKIT_INTERNAL_URL: 'http://livekit:7880', + }); + + const config = await loadConfig(); + + expect(config.services.gateway.media_proxy_endpoint).toBe('http://localhost:8088/media'); + expect(config.s3?.presigned_url_base).toBe('http://localhost:8088/s3'); + expect(config.integrations.email.app_base_url).toBe('http://localhost:8088'); + expect(config.integrations.sms.inbound_webhook_public_url).toBe('http://localhost:8088/webhooks/sms'); + expect(config.auth.bluesky.client_uri).toBe('http://localhost:8088'); + expect(config.auth.bluesky.tos_uri).toBe('http://localhost:8088/terms'); + expect(config.instance.branding.icon_url).toBe('http://localhost:8088/icon.png'); + expect(config.integrations.voice.internal_url).toBe('http://livekit:7880'); + }); + + test('rejects a public port outside the valid range', async () => { + stubMinimalEnv({FLUXER_PUBLIC_PORT: '70000'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_PUBLIC_PORT must be an integer between 1 and 65535'); + }); +}); + +describe('FLUXER_PUBLIC_ORIGIN', () => { + beforeEach(() => { + resetConfig(); + clearFluxerEnv(); + }); + + afterEach(() => { + resetConfig(); + vi.unstubAllEnvs(); + }); + + test('a ported origin ports every derived endpoint and every compose override', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080', + FLUXER_MARKETING_ENDPOINT: 'https://chat.example.com:29080', + FLUXER_MEDIA_ENDPOINT: 'https://chat.example.com:29080/media', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example.com:29080/media', + FLUXER_LIVEKIT_URL: 'https://chat.example.com:29080/livekit', + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example.com:29080', + }); + + const config = await loadConfig(); + + expect(config.domain.public_port).toBe(29080); + expect(config.domain.public_origin).toBe('https://chat.example.com:29080'); + expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api'); + expect(config.endpoints.app).toBe('https://chat.example.com:29080'); + expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway'); + expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin'); + expect(config.endpoints.marketing).toBe('https://chat.example.com:29080'); + expect(config.endpoints.media).toBe('https://chat.example.com:29080/media'); + expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example.com:29080/media'); + expect(config.integrations.voice.url).toBe('https://chat.example.com:29080/livekit'); + expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example.com:29080']); + }); + + test('the origin port wins over a FLUXER_PUBLIC_PORT that disagrees', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080', + }); + + const config = await loadConfig(); + + expect(config.domain.public_port).toBe(29080); + expect(config.endpoints.app).toBe('https://chat.example.com:29080'); + expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api'); + expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway'); + expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin'); + }); + + test('accepts an origin whose port matches FLUXER_PUBLIC_PORT', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '29080', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080', + }); + + expect((await loadConfig()).endpoints.gateway).toBe('wss://chat.example.com:29080/gateway'); + }); + + test('normalizes an origin written with an explicit default port', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:443', + FLUXER_ADMIN_ENDPOINT: 'https://chat.example.com/admin', + }); + + const config = await loadConfig(); + + expect(config.domain.public_port).toBe(443); + expect(config.domain.public_origin).toBe('https://chat.example.com'); + expect(config.endpoints.admin).toBe('https://chat.example.com/admin'); + expect(config.endpoints.app).toBe('https://chat.example.com'); + expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway'); + }); + + test('takes the scheme and the domain from the origin when neither is set', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: '', + FLUXER_PUBLIC_SCHEME: '', + FLUXER_PUBLIC_PORT: '', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080', + }); + + const config = await loadConfig(); + + expect(config.domain.base_domain).toBe('chat.example.com'); + expect(config.domain.public_scheme).toBe('https'); + expect(config.domain.public_port).toBe(29080); + }); + + test('the origin scheme wins over a FLUXER_PUBLIC_SCHEME that disagrees', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'http', + FLUXER_PUBLIC_PORT: '', + FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com', + }); + + const config = await loadConfig(); + + expect(config.domain.public_scheme).toBe('https'); + expect(config.endpoints.app).toBe('https://chat.example.com'); + expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway'); + }); + + test('the origin host wins over a FLUXER_BASE_DOMAIN that disagrees', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '', + FLUXER_PUBLIC_ORIGIN: 'https://other.example.com', + }); + + const config = await loadConfig(); + + expect(config.domain.base_domain).toBe('other.example.com'); + expect(config.endpoints.app).toBe('https://other.example.com'); + }); + + test('refuses to boot on an origin that is not a bare origin', async () => { + stubMinimalEnv({FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com/app'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got https://chat.example.com/app', + ); + }); + + test('leaves the derived origin canonical when nothing is set', async () => { + stubMinimalEnv(); + expect((await loadConfig()).domain.public_origin).toBe('http://localhost:8088'); + }); }); diff --git a/packages/config/src/__tests__/EndpointDerivation.test.ts b/packages/config/src/__tests__/EndpointDerivation.test.ts index a0472fdbe..8a659a88c 100644 --- a/packages/config/src/__tests__/EndpointDerivation.test.ts +++ b/packages/config/src/__tests__/EndpointDerivation.test.ts @@ -2,10 +2,12 @@ import { buildUrl, + canonicalizeDomain, type DomainConfig, deriveDomain, deriveEndpointsFromDomain, normalizePublicEndpoint, + parsePublicOrigin, } from '@fluxer/config/src/EndpointDerivation'; import {describe, expect, test} from 'vitest'; @@ -394,3 +396,103 @@ describe('normalizePublicEndpoint', () => { expect(normalizePublicEndpoint('https://fluxer.dev/media', ' ', 8443)).toBe('https://fluxer.dev/media'); }); }); + +describe('canonicalizeDomain', () => { + test('lowercases, trims and drops the root dot', () => { + expect(canonicalizeDomain(' CHAT.Example.COM. ')).toBe('chat.example.com'); + }); + test('leaves an empty value empty', () => { + expect(canonicalizeDomain(' ')).toBe(''); + }); +}); + +describe('parsePublicOrigin', () => { + test('reads scheme, host and a non-standard port', () => { + expect(parsePublicOrigin('https://chat.example.com:8443')).toEqual({ + public_scheme: 'https', + base_domain: 'chat.example.com', + public_port: 8443, + }); + expect(parsePublicOrigin('http://chat.example.com:19080')).toEqual({ + public_scheme: 'http', + base_domain: 'chat.example.com', + public_port: 19080, + }); + }); + test('fills in the standard port for a portless origin', () => { + expect(parsePublicOrigin('https://chat.example.com')).toEqual({ + public_scheme: 'https', + base_domain: 'chat.example.com', + public_port: 443, + }); + expect(parsePublicOrigin('http://chat.example.com')).toEqual({ + public_scheme: 'http', + base_domain: 'chat.example.com', + public_port: 80, + }); + }); + test('normalizes an explicitly written standard port to the portless form', () => { + const origin = parsePublicOrigin('https://chat.example.com:443'); + expect(origin).toEqual({public_scheme: 'https', base_domain: 'chat.example.com', public_port: 443}); + expect(buildUrl(origin?.public_scheme ?? 'https', origin?.base_domain ?? '', origin?.public_port)).toBe( + 'https://chat.example.com', + ); + expect(parsePublicOrigin('http://chat.example.com:80')?.public_port).toBe(80); + }); + test('canonicalizes the host', () => { + expect(parsePublicOrigin(' https://CHAT.Example.com.:8443 ')).toEqual({ + public_scheme: 'https', + base_domain: 'chat.example.com', + public_port: 8443, + }); + }); + test('keeps an IPv6 literal bracketed', () => { + expect(parsePublicOrigin('http://[::1]:19080')).toEqual({ + public_scheme: 'http', + base_domain: '[::1]', + public_port: 19080, + }); + }); + test('accepts a bare trailing slash', () => { + expect(parsePublicOrigin('https://chat.example.com:8443/')?.public_port).toBe(8443); + }); + test('rejects anything that is not a bare origin', () => { + expect(parsePublicOrigin('')).toBeNull(); + expect(parsePublicOrigin(' ')).toBeNull(); + expect(parsePublicOrigin('not a url')).toBeNull(); + expect(parsePublicOrigin('chat.example.com:8443')).toBeNull(); + expect(parsePublicOrigin('wss://chat.example.com')).toBeNull(); + expect(parsePublicOrigin('https://chat.example.com/media')).toBeNull(); + expect(parsePublicOrigin('https://chat.example.com?a=1')).toBeNull(); + expect(parsePublicOrigin('https://chat.example.com#top')).toBeNull(); + expect(parsePublicOrigin('https://user:pw@chat.example.com')).toBeNull(); + }); +}); + +describe('endpoints derived from a public origin', () => { + test('an origin with a non-standard port ports every derived endpoint', () => { + const origin = parsePublicOrigin('https://chat.example.com:29080'); + const endpoints = deriveEndpointsFromDomain({ + base_domain: origin?.base_domain ?? '', + public_scheme: origin?.public_scheme ?? 'https', + internal_scheme: 'http', + public_port: origin?.public_port, + }); + expect(endpoints.api_client).toBe('https://chat.example.com:29080/api'); + expect(endpoints.app).toBe('https://chat.example.com:29080'); + expect(endpoints.gateway).toBe('wss://chat.example.com:29080/gateway'); + expect(endpoints.admin).toBe('https://chat.example.com:29080/admin'); + }); + test('an origin written with an explicit :443 derives portless endpoints', () => { + const origin = parsePublicOrigin('https://chat.example.com:443'); + const endpoints = deriveEndpointsFromDomain({ + base_domain: origin?.base_domain ?? '', + public_scheme: origin?.public_scheme ?? 'https', + internal_scheme: 'http', + public_port: origin?.public_port, + }); + expect(endpoints.admin).toBe('https://chat.example.com/admin'); + expect(endpoints.app).toBe('https://chat.example.com'); + expect(endpoints.gateway).toBe('wss://chat.example.com/gateway'); + }); +}); diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index dccb8a84c..05a3ddda3 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -14,6 +14,7 @@ interface NamedEnvOverride { const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_ENV: {path: ['env']}, FLUXER_BASE_DOMAIN: {path: ['domain', 'base_domain']}, + FLUXER_PUBLIC_ORIGIN: {path: ['domain', 'public_origin']}, FLUXER_PUBLIC_SCHEME: {path: ['domain', 'public_scheme']}, FLUXER_INTERNAL_SCHEME: {path: ['domain', 'internal_scheme']}, FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseInteger},