mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(self-host): tie the public address to a single origin (#2605)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,3 +2,5 @@ services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":80"
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -90,6 +90,7 @@ mod tests {
|
||||
"FLUXER_MEDIA_PROXY_SECRET_KEY",
|
||||
"FLUXER_BASE_DOMAIN",
|
||||
"FLUXER_PUBLIC_PORT",
|
||||
"FLUXER_PUBLIC_ORIGIN",
|
||||
];
|
||||
let saved = keys
|
||||
.iter()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? {})};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user