fix(self-host): tie the public address to a single origin (#2605)

This commit is contained in:
Hampus
2026-09-08 22:17:39 +02:00
committed by GitHub
parent 6c36d934f7
commit 8cc485cf81
17 changed files with 903 additions and 61 deletions
+64 -33
View File
@@ -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
+7 -2
View File
@@ -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
+2
View File
@@ -2,3 +2,5 @@ services:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
+59 -7
View File
@@ -547,23 +547,26 @@ fn resolve_time_freeze_enabled_from_env() -> bool {
fn resolve_bootstrap_api_public_endpoint_from_env() -> Option<String> {
resolve_bootstrap_api_public_endpoint(|name| env::var(name).ok())
.unwrap_or_else(|error| panic!("{error}"))
}
fn resolve_bootstrap_api_public_endpoint<F>(mut read_var: F) -> Option<String>
fn resolve_bootstrap_api_public_endpoint<F>(mut read_var: F) -> anyhow::Result<Option<String>>
where
F: FnMut(&str) -> Option<String>,
{
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<F>(mut read_var: F) -> bool
@@ -635,6 +638,12 @@ mod tests {
}
fn resolve_bootstrap_endpoint_from_pairs(pairs: &[(&str, &str)]) -> Option<String> {
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<Option<String>> {
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();
+223 -9
View File
@@ -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<PublicOrigin> {
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<F>(mut read_var: F) -> anyhow::Result<(String, Option<u16>)>
where
F: FnMut(&str) -> Option<String>,
{
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::<u16>()
.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<u16>) -> 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<String> {
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<String> {
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:[email protected]",
] {
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!(
+1
View File
@@ -90,6 +90,7 @@ mod tests {
"FLUXER_MEDIA_PROXY_SECRET_KEY",
"FLUXER_BASE_DOMAIN",
"FLUXER_PUBLIC_PORT",
"FLUXER_PUBLIC_ORIGIN",
];
let saved = keys
.iter()
+13 -2
View File
@@ -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",
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod public_endpoint;
mod s3_read;
use super::*;
@@ -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"));
}
@@ -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!(
+2 -1
View File
@@ -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 {
+70 -3
View File
@@ -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<keyof DerivedEndpoints>) {
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<MasterConfig> {
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 ?? {})};
+49 -4
View File
@@ -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)) {
+1
View File
@@ -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;
@@ -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');
});
});
@@ -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:[email protected]')).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');
});
});
@@ -14,6 +14,7 @@ interface NamedEnvOverride {
const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
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},