From bc40073a02df164c7ca27d189ee8af37d9d0e727 Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 1 Sep 2026 20:47:18 +0200 Subject: [PATCH] fix(config): carry the public port into derived endpoints (#2329) --- Cargo.lock | 1 + fluxer_admin/Cargo.toml | 1 + fluxer_admin/Dockerfile | 3 +- fluxer_admin/src/config/mod.rs | 138 +++++- .../src/api/voice/VoiceDataInitializer.ts | 19 +- .../voice/tests/VoiceLivekitEndpoint.test.ts | 48 ++ fluxer_app_proxy/src/bootstrap.rs | 156 ++++++- fluxer_app_proxy/src/config.rs | 87 +++- fluxer_common/src/config.rs | 298 ++++++++++++ .../src/testdata/public_endpoint_vectors.json | 440 ++++++++++++++++++ .../src/gateway/fluxer_gateway_config.erl | 41 +- .../src/gateway/gateway_public_endpoint.erl | 97 ++++ .../test/fluxer_gateway_config_tests.erl | 46 ++ .../test/gateway_public_endpoint_tests.erl | 248 ++++++++++ fluxer_gifs/src/media_proxy.rs | 55 ++- fluxer_unfurl/src/shard_impl.rs | 87 +++- packages/config/src/ConfigLoader.ts | 38 +- packages/config/src/EndpointDerivation.ts | 47 +- .../config/src/__tests__/ConfigLoader.test.ts | 99 ++++ .../src/__tests__/EndpointDerivation.test.ts | 93 ++++ .../__tests__/PublicEndpointVectors.test.ts | 50 ++ 21 files changed, 2037 insertions(+), 55 deletions(-) create mode 100644 fluxer_api/src/api/voice/tests/VoiceLivekitEndpoint.test.ts create mode 100644 fluxer_common/src/testdata/public_endpoint_vectors.json create mode 100644 fluxer_gateway/src/gateway/gateway_public_endpoint.erl create mode 100644 fluxer_gateway/test/gateway_public_endpoint_tests.erl create mode 100644 packages/config/src/__tests__/PublicEndpointVectors.test.ts diff --git a/Cargo.lock b/Cargo.lock index fe91d8e2d..556f66b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,6 +1958,7 @@ dependencies = [ "base64", "chrono", "cookie", + "fluxer_common", "hmac 0.13.0", "maud", "openapiv3", diff --git a/fluxer_admin/Cargo.toml b/fluxer_admin/Cargo.toml index 5d55e9f02..f9859cad6 100644 --- a/fluxer_admin/Cargo.toml +++ b/fluxer_admin/Cargo.toml @@ -11,6 +11,7 @@ axum = { version = "0.8.9", features = ["macros"] } base64 = "0.22.1" chrono = { version = "0.4", default-features = false, features = ["serde"] } cookie = "0.18.1" +fluxer_common = { path = "../fluxer_common" } hmac = "0.13.0" maud = { version = "0.27.0", features = ["axum"] } rand = "0.10" diff --git a/fluxer_admin/Dockerfile b/fluxer_admin/Dockerfile index f8fcf9507..c3ad53ac1 100644 --- a/fluxer_admin/Dockerfile +++ b/fluxer_admin/Dockerfile @@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \ COPY Cargo.lock Cargo.lock COPY fluxer_admin fluxer_admin +COPY fluxer_common fluxer_common COPY packages/fonts/manifest.json packages/fonts/manifest.json COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt @@ -31,7 +32,7 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono RUN printf '%s\n' \ '[workspace]' \ - 'members = ["fluxer_admin"]' \ + 'members = ["fluxer_admin", "fluxer_common"]' \ 'resolver = "2"' \ '' \ '[workspace.package]' \ diff --git a/fluxer_admin/src/config/mod.rs b/fluxer_admin/src/config/mod.rs index 277e6cbf1..6abbd1df8 100644 --- a/fluxer_admin/src/config/mod.rs +++ b/fluxer_admin/src/config/mod.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use fluxer_common::config::normalize_public_endpoint_from_env; use std::env; const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789"; @@ -42,14 +43,14 @@ pub enum RuntimeEnv { impl AdminConfig { pub fn from_env() -> Self { let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", "")); - let admin_endpoint = trim_trailing_slash(&read_env( + let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env( "FLUXER_ADMIN_ENDPOINT", "https://admin.fluxer.app", - )); - let oauth_redirect_uri = read_env_preferred( + ))); + let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred( &["FLUXER_ADMIN_OAUTH_REDIRECT_URI"], &format!("{admin_endpoint}/oauth2_callback"), - ); + )); Self { env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")), @@ -63,17 +64,19 @@ impl AdminConfig { "FLUXER_API_ENDPOINT", "https://api.fluxer.app", )), - media_endpoint: trim_trailing_slash(&read_env( + media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env( "FLUXER_MEDIA_ENDPOINT", "https://media.fluxer.app", + ))), + static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash( + &read_env("FLUXER_STATIC_CDN_ENDPOINT", ""), )), - static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")), admin_endpoint, - web_app_endpoint: trim_trailing_slash(&read_env( + web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env( "FLUXER_APP_ENDPOINT", "https://app.fluxer.app", - )), + ))), kv_url: read_env("FLUXER_KV_URL", ""), oauth_client_id: read_env( "FLUXER_ADMIN_OAUTH_CLIENT_ID", @@ -166,6 +169,39 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const MANAGED_ENV: [&str; 11] = [ + "FLUXER_ENV", + "FLUXER_ADMIN_HOST", + "FLUXER_ADMIN_PORT", + "FLUXER_ADMIN_ENDPOINT", + "FLUXER_ADMIN_OAUTH_CLIENT_ID", + "FLUXER_ADMIN_OAUTH_REDIRECT_URI", + "FLUXER_MASTER_CONFIG", + "FLUXER_APP_ENDPOINT", + "FLUXER_MEDIA_ENDPOINT", + "FLUXER_STATIC_CDN_ENDPOINT", + "FLUXER_BASE_DOMAIN", + ]; + + fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig { + let _guard = ENV_LOCK.lock().unwrap(); + for name in MANAGED_ENV { + unsafe { env::remove_var(name) }; + } + unsafe { env::remove_var("FLUXER_PUBLIC_PORT") }; + for (name, value) in vars { + unsafe { env::set_var(name, value) }; + } + let config = AdminConfig::from_env(); + for (name, _) in vars { + unsafe { env::remove_var(name) }; + } + config + } #[test] fn normalize_base_path_strips_trailing_slashes() { @@ -287,18 +323,7 @@ mod tests { #[test] fn from_env_uses_defaults() { - for var in &[ - "FLUXER_ENV", - "FLUXER_ADMIN_HOST", - "FLUXER_ADMIN_PORT", - "FLUXER_ADMIN_ENDPOINT", - "FLUXER_ADMIN_OAUTH_CLIENT_ID", - "FLUXER_ADMIN_OAUTH_REDIRECT_URI", - "FLUXER_MASTER_CONFIG", - ] { - unsafe { env::remove_var(var) }; - } - let config = AdminConfig::from_env(); + let config = config_from_env(&[]); assert_eq!(config.env, RuntimeEnv::Development); assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.port, 3020); @@ -308,4 +333,77 @@ mod tests { "https://admin.fluxer.app/oauth2_callback" ); } + + #[test] + fn a_non_default_public_port_reaches_the_public_endpoints() { + let config = config_from_env(&[ + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"), + ("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"), + ("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"), + ("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"), + ( + "FLUXER_ADMIN_OAUTH_REDIRECT_URI", + "http://fluxer.example/admin/oauth2_callback", + ), + ]); + + assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin"); + assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media"); + assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080"); + assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net"); + assert_eq!( + config.oauth_redirect_uri, + format!("{}/oauth2_callback", config.admin_endpoint) + ); + } + + #[test] + fn a_default_public_port_leaves_the_public_endpoints_alone() { + let config = config_from_env(&[ + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "443"), + ("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"), + ("FLUXER_APP_ENDPOINT", "https://fluxer.example"), + ("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"), + ("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"), + ( + "FLUXER_ADMIN_OAUTH_REDIRECT_URI", + "https://fluxer.example/admin/oauth2_callback", + ), + ]); + + assert_eq!(config.admin_endpoint, "https://fluxer.example/admin"); + assert_eq!(config.media_endpoint, "https://fluxer.example/media"); + assert_eq!(config.web_app_endpoint, "https://fluxer.example"); + assert_eq!(config.static_cdn_endpoint, "https://fluxer.example"); + assert_eq!( + config.oauth_redirect_uri, + "https://fluxer.example/admin/oauth2_callback" + ); + } + + #[test] + fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() { + let config = config_from_env(&[ + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"), + ( + "FLUXER_ADMIN_OAUTH_REDIRECT_URI", + "http://fluxer.example/admin/oauth2_callback", + ), + ]); + + let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint( + "http://fluxer.example/admin", + "fluxer.example", + Some(19080), + ); + assert_eq!( + config.oauth_redirect_uri, + format!("{api_admin_endpoint}/oauth2_callback") + ); + } } diff --git a/fluxer_api/src/api/voice/VoiceDataInitializer.ts b/fluxer_api/src/api/voice/VoiceDataInitializer.ts index 1f98d132d..e5934c09e 100644 --- a/fluxer_api/src/api/voice/VoiceDataInitializer.ts +++ b/fluxer_api/src/api/voice/VoiceDataInitializer.ts @@ -4,6 +4,15 @@ import {Config} from '../Config'; import {Logger} from '../Logger'; import {VoiceRepository} from './VoiceRepository'; +export function resolveLivekitEndpoint(configuredUrl: string | undefined, apiPublicUrl: string): string { + if (configuredUrl) { + return configuredUrl; + } + const apiPublic = new URL(apiPublicUrl); + const protocol = apiPublic.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${apiPublic.host}/livekit`; +} + export class VoiceDataInitializer { async initialize(): Promise { if (!Config.voice.enabled || !Config.voice.defaultRegion) { @@ -19,7 +28,7 @@ export class VoiceDataInitializer { try { const repository = new VoiceRepository(); const serverId = `${defaultRegion.id}-server-1`; - const livekitEndpoint = this.resolveLivekitEndpoint(); + const livekitEndpoint = resolveLivekitEndpoint(Config.voice.url, Config.endpoints.apiPublic); const existingRegions = await repository.listRegions(); if (existingRegions.length === 0) { Logger.info('[VoiceDataInitializer] Creating default voice region from config...'); @@ -91,12 +100,4 @@ export class VoiceDataInitializer { Logger.error({error}, '[VoiceDataInitializer] Failed to initialise config-managed voice topology'); } } - - private resolveLivekitEndpoint(): string { - if (Config.voice.url) { - return Config.voice.url; - } - const protocol = new URL(Config.endpoints.apiPublic).protocol.slice(0, -1) === 'https' ? 'wss' : 'ws'; - return `${protocol}://${new URL(Config.endpoints.apiPublic).hostname}/livekit`; - } } diff --git a/fluxer_api/src/api/voice/tests/VoiceLivekitEndpoint.test.ts b/fluxer_api/src/api/voice/tests/VoiceLivekitEndpoint.test.ts new file mode 100644 index 000000000..4a6621227 --- /dev/null +++ b/fluxer_api/src/api/voice/tests/VoiceLivekitEndpoint.test.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {resolveLivekitEndpoint} from '../VoiceDataInitializer'; + +describe('resolveLivekitEndpoint', () => { + it('keeps the public port when the instance does not serve on the default port', () => { + expect(resolveLivekitEndpoint(undefined, 'http://inplace.localhost:19480/api')).toBe( + 'ws://inplace.localhost:19480/livekit', + ); + expect(resolveLivekitEndpoint('', 'https://fluxer.example.com:8443/api')).toBe( + 'wss://fluxer.example.com:8443/livekit', + ); + }); + + it('omits the port when the public endpoint uses the default port for its scheme', () => { + expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com/api')).toBe( + 'wss://fluxer.example.com/livekit', + ); + expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com:443/api')).toBe( + 'wss://fluxer.example.com/livekit', + ); + expect(resolveLivekitEndpoint(undefined, 'http://fluxer.example.com:80/api')).toBe( + 'ws://fluxer.example.com/livekit', + ); + }); + + it('maps https to wss and http to ws', () => { + expect(resolveLivekitEndpoint(undefined, 'https://fluxer.example.com/api')).toBe( + 'wss://fluxer.example.com/livekit', + ); + expect(resolveLivekitEndpoint(undefined, 'http://fluxer.example.com/api')).toBe('ws://fluxer.example.com/livekit'); + }); + + it('brackets ipv6 literals so the derived endpoint stays parseable', () => { + expect(resolveLivekitEndpoint(undefined, 'http://[::1]:19480/api')).toBe('ws://[::1]:19480/livekit'); + expect(resolveLivekitEndpoint(undefined, 'https://[2001:db8::1]/api')).toBe('wss://[2001:db8::1]/livekit'); + const derived = new URL(resolveLivekitEndpoint(undefined, 'http://[::1]:19480/api')); + expect(derived.host).toBe('[::1]:19480'); + expect(derived.pathname).toBe('/livekit'); + }); + + it('returns the configured voice url unchanged when one is set', () => { + expect(resolveLivekitEndpoint('wss://voice.example.com', 'http://inplace.localhost:19480/api')).toBe( + 'wss://voice.example.com', + ); + }); +}); diff --git a/fluxer_app_proxy/src/bootstrap.rs b/fluxer_app_proxy/src/bootstrap.rs index 1dd4b6a89..13cc60107 100644 --- a/fluxer_app_proxy/src/bootstrap.rs +++ b/fluxer_app_proxy/src/bootstrap.rs @@ -2,6 +2,7 @@ use crate::config::AppProxyConfig; use crate::discovery_cache::DiscoveryResponse; +use reqwest::Url; use serde::Serialize; #[derive(Serialize)] @@ -43,11 +44,14 @@ pub fn build_bootstrap_script( geoip: &serde_json::Value, nonce: &str, ) -> String { + let api_public_endpoint = + api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery); + let payload = BootstrapPayload { config: BootstrapConfig { release_channel: config.release_channel.as_str(), bootstrap_api_endpoint: &config.bootstrap_api_endpoint, - bootstrap_api_public_endpoint: config.bootstrap_api_public_endpoint.as_deref(), + bootstrap_api_public_endpoint: api_public_endpoint, }, instance: &discovery.data, geoip, @@ -56,7 +60,7 @@ pub fn build_bootstrap_script( let legacy = LegacyConfig { release_channel: config.release_channel.as_str(), bootstrap_api_endpoint: &config.bootstrap_api_endpoint, - bootstrap_api_public_endpoint: config.bootstrap_api_public_endpoint.as_deref(), + bootstrap_api_public_endpoint: api_public_endpoint, }; let bootstrap_json = escape_json_for_script(&serde_json::to_string(&payload).unwrap()); @@ -67,6 +71,50 @@ pub fn build_bootstrap_script( ) } +fn api_public_endpoint<'a>( + configured: Option<&'a str>, + discovery: &'a DiscoveryResponse, +) -> Option<&'a str> { + let configured = configured?; + let Some(discovered) = discovered_api_public(discovery) else { + return Some(configured); + }; + if has_explicit_port(configured) || !has_explicit_port(discovered) { + return Some(configured); + } + let (Ok(configured_url), Ok(discovered_url)) = (Url::parse(configured), Url::parse(discovered)) + else { + return Some(configured); + }; + if configured_url.scheme() != discovered_url.scheme() + || configured_url.host_str() != discovered_url.host_str() + || configured_url.path() != discovered_url.path() + { + return Some(configured); + } + Some(discovered) +} + +fn discovered_api_public(discovery: &DiscoveryResponse) -> Option<&str> { + discovery + .data + .get("endpoints") + .and_then(|endpoints| endpoints.get("api_public")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn has_explicit_port(url: &str) -> bool { + let Some(scheme_end) = url.find("://") else { + return false; + }; + let rest = &url[scheme_end + 3..]; + let authority_end = rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len()); + let host = rest[..authority_end].rsplit('@').next().unwrap_or_default(); + host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':') +} + const MEDIA_PRECONNECT_TAG: &str = r#""#; const STATIC_PRECONNECT_TAGS: [&str; 2] = [ r#""#, @@ -411,4 +459,108 @@ mod tests { let json = serde_json::to_string(&payload).unwrap(); assert!(!json.contains("bootstrapApiPublicEndpoint")); } + + fn discovery_offering(api_public: &str) -> DiscoveryResponse { + DiscoveryResponse { + data: serde_json::json!({"endpoints": {"api_public": api_public}}), + } + } + + #[test] + fn the_discovered_port_repairs_a_portless_configured_endpoint() { + let discovery = discovery_offering("https://chat.example.test:8443/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test:8443/api") + ); + } + + #[test] + fn a_configured_endpoint_that_already_carries_a_port_is_left_alone() { + let discovery = discovery_offering("https://chat.example.test:8443/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test:9443/api"), &discovery), + Some("https://chat.example.test:9443/api") + ); + } + + #[test] + fn a_discovered_endpoint_on_another_host_is_ignored() { + let discovery = discovery_offering("https://api.example.test:8443/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn a_discovered_endpoint_on_another_path_is_ignored() { + let discovery = discovery_offering("https://chat.example.test:8443/v9/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn a_discovered_endpoint_on_another_scheme_is_ignored() { + let discovery = discovery_offering("http://chat.example.test:8443/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn a_discovery_document_without_a_snapshot_leaves_the_configured_endpoint_alone() { + let discovery = DiscoveryResponse { + data: serde_json::json!({}), + }; + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn a_discovery_document_without_a_public_api_endpoint_changes_nothing() { + let discovery = DiscoveryResponse { + data: serde_json::json!({"endpoints": {"api_public": " "}}), + }; + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn a_portless_discovered_endpoint_leaves_a_default_install_alone() { + let discovery = discovery_offering("https://chat.example.test/api"); + assert_eq!( + api_public_endpoint(Some("https://chat.example.test/api"), &discovery), + Some("https://chat.example.test/api") + ); + } + + #[test] + fn an_unconfigured_public_endpoint_is_never_invented_from_discovery() { + let discovery = discovery_offering("https://chat.example.test:8443/api"); + assert_eq!(api_public_endpoint(None, &discovery), None); + } + + #[test] + fn the_boot_script_hands_the_repaired_endpoint_to_both_globals() { + let discovery = discovery_offering("https://chat.example.test:8443/api"); + let mut config = AppProxyConfig::from_env(); + config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned()); + let script = + build_bootstrap_script(&config, &discovery, &serde_json::json!({}), "scriptnonce"); + assert!( + script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#) + ); + assert!(script.contains( + r#""PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT":"https://chat.example.test:8443/api""# + )); + assert!(!script.contains(r#""https://chat.example.test/api""#)); + } } diff --git a/fluxer_app_proxy/src/config.rs b/fluxer_app_proxy/src/config.rs index 9a12a24e8..bae9894c2 100644 --- a/fluxer_app_proxy/src/config.rs +++ b/fluxer_app_proxy/src/config.rs @@ -200,9 +200,7 @@ impl AppProxyConfig { env!("CARGO_PKG_VERSION"), ), bootstrap_api_endpoint: cfg::read_env("PUBLIC_BOOTSTRAP_API_ENDPOINT", "/api"), - bootstrap_api_public_endpoint: cfg::non_empty_env( - "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", - ), + bootstrap_api_public_endpoint: resolve_bootstrap_api_public_endpoint_from_env(), csp: CspConfig::from_env(), geoip_source, geoip_s3_config, @@ -275,6 +273,27 @@ fn resolve_postgres_prepared_statements_from_env() -> bool { resolve_postgres_prepared_statements(|name| env::var(name).ok()) } +fn resolve_bootstrap_api_public_endpoint_from_env() -> Option { + resolve_bootstrap_api_public_endpoint(|name| env::var(name).ok()) +} + +fn resolve_bootstrap_api_public_endpoint(mut read_var: F) -> Option +where + F: FnMut(&str) -> Option, +{ + let 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()); + + Some(cfg::normalize_public_endpoint( + &endpoint, + &base_domain, + public_port, + )) +} + fn resolve_time_freeze_enabled(mut read_var: F) -> bool where F: FnMut(&str) -> Option, @@ -373,6 +392,68 @@ mod tests { resolve_postgres_prepared_statements(|name| env.get(name).map(|value| value.to_string())) } + fn resolve_bootstrap_endpoint_from_pairs(pairs: &[(&str, &str)]) -> Option { + let env: HashMap<&str, &str> = pairs.iter().copied().collect(); + resolve_bootstrap_api_public_endpoint(|name| env.get(name).map(|value| value.to_string())) + } + + #[test] + fn a_non_default_public_port_reaches_the_boot_html_api_endpoint() { + assert_eq!( + resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "http://fluxer.example/api", + ), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ]), + Some("http://fluxer.example:19080/api".to_owned()) + ); + } + + #[test] + fn a_default_public_port_leaves_the_boot_html_api_endpoint_alone() { + assert_eq!( + resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "https://fluxer.example/api", + ), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "443"), + ]), + Some("https://fluxer.example/api".to_owned()) + ); + } + + #[test] + fn the_boot_html_api_endpoint_keeps_a_port_it_already_carries() { + assert_eq!( + resolve_bootstrap_endpoint_from_pairs(&[ + ( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "http://fluxer.example:19080/api", + ), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ]), + Some("http://fluxer.example:19080/api".to_owned()) + ); + } + + #[test] + fn the_boot_html_api_endpoint_is_untouched_without_a_base_domain_and_port() { + assert_eq!( + resolve_bootstrap_endpoint_from_pairs(&[( + "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT", + "http://fluxer.example/api", + )]), + Some("http://fluxer.example/api".to_owned()) + ); + assert_eq!(resolve_bootstrap_endpoint_from_pairs(&[]), None); + } + #[test] fn csp_config_default_has_no_extra_sources() { let c = CspConfig::default(); diff --git a/fluxer_common/src/config.rs b/fluxer_common/src/config.rs index a40f7645c..ca6dbc523 100644 --- a/fluxer_common/src/config.rs +++ b/fluxer_common/src/config.rs @@ -196,6 +196,62 @@ pub fn trim_trailing_slash(value: &str) -> String { value.trim_end_matches('/').to_owned() } +fn is_default_port(scheme: &str, port: u16) -> bool { + matches!( + (scheme, port), + ("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443) + ) +} + +fn strip_trailing_dot(host: &str) -> &str { + host.strip_suffix('.').unwrap_or(host) +} + +pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Option) -> String { + let domain = base_domain.trim().to_lowercase(); + let domain = strip_trailing_dot(&domain); + let Some(port) = public_port.filter(|port| *port != 0) else { + return url.to_owned(); + }; + if domain.is_empty() { + return url.to_owned(); + } + 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 { + return url.to_owned(); + } + if is_default_port(parsed.scheme(), port) { + return url.to_owned(); + } + let Some(scheme_end) = url.find("://") else { + return url.to_owned(); + }; + let authority_start = scheme_end + 3; + if url[authority_start..].starts_with('/') { + return url.to_owned(); + } + let authority_end = url[authority_start..] + .find(['/', '\\', '?', '#']) + .map_or(url.len(), |index| authority_start + index); + let authority = &url[authority_start..authority_end]; + let host = authority.rsplit('@').next().unwrap_or_default(); + if host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':') { + return url.to_owned(); + } + format!("{}:{port}{}", &url[..authority_end], &url[authority_end..]) +} + +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()), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -284,6 +340,248 @@ mod tests { assert_eq!(normalize_base_path("/"), ""); } + #[test] + fn default_https_install_is_untouched() { + for url in [ + "https://fluxer.example", + "https://fluxer.example/media", + "https://fluxer.example/admin/oauth2_callback", + "wss://fluxer.example/gateway", + ] { + assert_eq!( + url, + normalize_public_endpoint(url, "fluxer.example", Some(443)) + ); + } + } + + #[test] + fn default_http_install_is_untouched() { + for url in [ + "http://fluxer.example", + "http://fluxer.example/media", + "ws://fluxer.example/gateway", + ] { + assert_eq!( + url, + normalize_public_endpoint(url, "fluxer.example", Some(80)) + ); + } + } + + #[test] + fn inserts_a_non_default_port_for_the_base_domain() { + assert_eq!( + "http://fluxer.example:19080/media", + normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080)) + ); + assert_eq!( + "http://fluxer.example:19080", + normalize_public_endpoint("http://fluxer.example", "fluxer.example", Some(19080)) + ); + assert_eq!( + "https://fluxer.example:8443/admin/oauth2_callback", + normalize_public_endpoint( + "https://fluxer.example/admin/oauth2_callback", + "fluxer.example", + Some(8443) + ) + ); + } + + #[test] + fn a_default_port_for_the_urls_own_scheme_is_never_inserted() { + assert_eq!( + "ws://fluxer.example/gateway", + normalize_public_endpoint("ws://fluxer.example/gateway", "fluxer.example", Some(80)) + ); + assert_eq!( + "wss://fluxer.example/gateway", + normalize_public_endpoint("wss://fluxer.example/gateway", "fluxer.example", Some(443)) + ); + assert_eq!( + "http://fluxer.example:443/media", + normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(443)) + ); + assert_eq!( + "https://fluxer.example:80/media", + normalize_public_endpoint("https://fluxer.example/media", "fluxer.example", Some(80)) + ); + } + + #[test] + fn another_host_is_never_touched() { + for url in [ + "https://cdn.example.net/assets", + "https://media.example.net", + "http://api:8080", + "http://media-proxy:8080", + ] { + assert_eq!( + url, + normalize_public_endpoint(url, "fluxer.example", Some(19080)) + ); + } + assert_eq!( + "https://sub.fluxer.example/media", + normalize_public_endpoint( + "https://sub.fluxer.example/media", + "fluxer.example", + Some(19080) + ) + ); + } + + #[test] + fn an_explicit_port_is_never_touched() { + for url in [ + "http://fluxer.example:19080/media", + "http://fluxer.example:8080/media", + "https://fluxer.example:443/media", + "http://fluxer.example:80/media", + "http://user:pass@fluxer.example:19080/media", + ] { + assert_eq!( + url, + normalize_public_endpoint(url, "fluxer.example", Some(19080)) + ); + } + } + + #[test] + fn is_idempotent() { + let once = + normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080)); + let twice = normalize_public_endpoint(&once, "fluxer.example", Some(19080)); + assert_eq!("http://fluxer.example:19080/media", once); + assert_eq!(once, twice); + } + + #[test] + fn an_unset_port_leaves_every_url_alone() { + assert_eq!( + "http://fluxer.example/media", + normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", None) + ); + assert_eq!( + "http://fluxer.example/media", + normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(0)) + ); + } + + #[test] + fn an_empty_base_domain_leaves_every_url_alone() { + assert_eq!( + "http://fluxer.example/media", + normalize_public_endpoint("http://fluxer.example/media", "", Some(19080)) + ); + assert_eq!( + "http://fluxer.example/media", + normalize_public_endpoint("http://fluxer.example/media", " ", Some(19080)) + ); + } + + #[test] + fn a_url_that_does_not_parse_is_returned_unchanged() { + for url in ["", "/api", "fluxer.example/media", "not a url", "://x"] { + assert_eq!( + url, + normalize_public_endpoint(url, "fluxer.example", Some(19080)) + ); + } + } + + #[test] + fn matches_the_host_case_insensitively_and_ignores_a_trailing_dot() { + assert_eq!( + "http://FLUXER.example:19080/Media", + normalize_public_endpoint("http://FLUXER.example/Media", "Fluxer.Example", Some(19080)) + ); + assert_eq!( + "http://fluxer.example.:19080/media", + normalize_public_endpoint( + "http://fluxer.example./media", + "fluxer.example", + Some(19080) + ) + ); + assert_eq!( + "http://fluxer.example:19080/media", + normalize_public_endpoint( + "http://fluxer.example/media", + "fluxer.example.", + Some(19080) + ) + ); + } + + #[test] + fn preserves_path_query_fragment_and_trailing_slash() { + assert_eq!( + "http://fluxer.example:19080/", + normalize_public_endpoint("http://fluxer.example/", "fluxer.example", Some(19080)) + ); + assert_eq!( + "http://fluxer.example:19080?a=1", + normalize_public_endpoint("http://fluxer.example?a=1", "fluxer.example", Some(19080)) + ); + assert_eq!( + "http://fluxer.example:19080#top", + normalize_public_endpoint("http://fluxer.example#top", "fluxer.example", Some(19080)) + ); + assert_eq!( + "http://fluxer.example:19080/media/x.png?v=1#frag", + normalize_public_endpoint( + "http://fluxer.example/media/x.png?v=1#frag", + "fluxer.example", + Some(19080) + ) + ); + } + + #[test] + fn keeps_credentials_and_ipv6_literals_intact() { + assert_eq!( + "http://user:pass@fluxer.example:19080/media", + normalize_public_endpoint( + "http://user:pass@fluxer.example/media", + "fluxer.example", + Some(19080) + ) + ); + assert_eq!( + "http://[::1]:19080/media", + normalize_public_endpoint("http://[::1]/media", "[::1]", Some(19080)) + ); + assert_eq!( + "http://[::1]:8080/media", + normalize_public_endpoint("http://[::1]:8080/media", "[::1]", Some(19080)) + ); + } + + #[test] + fn matches_the_typescript_normalizer_on_the_shared_vectors() { + let raw = include_str!("testdata/public_endpoint_vectors.json"); + let vectors: serde_json::Value = serde_json::from_str(raw).expect("vectors parse as json"); + let vectors = vectors.as_array().expect("vectors are an array"); + assert!(!vectors.is_empty()); + for vector in vectors { + let url = vector["url"].as_str().expect("vector carries a url"); + let base_domain = vector["base_domain"] + .as_str() + .expect("vector carries a base domain"); + let public_port = vector["public_port"].as_u64().map(|port| port as u16); + let expected = vector["normalized"] + .as_str() + .expect("vector carries a normalized url"); + assert_eq!( + expected, + normalize_public_endpoint(url, base_domain, public_port), + "vector {url} @ {base_domain} port {public_port:?}" + ); + } + } + #[test] fn trim_trailing_slash_works() { assert_eq!( diff --git a/fluxer_common/src/testdata/public_endpoint_vectors.json b/fluxer_common/src/testdata/public_endpoint_vectors.json new file mode 100644 index 000000000..44b7178bb --- /dev/null +++ b/fluxer_common/src/testdata/public_endpoint_vectors.json @@ -0,0 +1,440 @@ +[ + { + "url": "https://fluxer.example", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "https://fluxer.example" + }, + { + "url": "https://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "https://fluxer.example/media" + }, + { + "url": "https://fluxer.example/admin", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "https://fluxer.example/admin" + }, + { + "url": "https://fluxer.example/admin/oauth2_callback", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "https://fluxer.example/admin/oauth2_callback" + }, + { + "url": "wss://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "wss://fluxer.example/gateway" + }, + { + "url": "http://fluxer.example", + "base_domain": "fluxer.example", + "public_port": 80, + "normalized": "http://fluxer.example" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 80, + "normalized": "http://fluxer.example/media" + }, + { + "url": "ws://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 80, + "normalized": "ws://fluxer.example/gateway" + }, + { + "url": "http://fluxer.example", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080" + }, + { + "url": "http://fluxer.example/", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/media" + }, + { + "url": "http://fluxer.example/api", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/api" + }, + { + "url": "http://fluxer.example/admin", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/admin" + }, + { + "url": "http://fluxer.example/admin/oauth2_callback", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/admin/oauth2_callback" + }, + { + "url": "ws://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "ws://fluxer.example:19080/gateway" + }, + { + "url": "https://fluxer.example/admin/oauth2_callback", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https://fluxer.example:8443/admin/oauth2_callback" + }, + { + "url": "wss://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "wss://fluxer.example:8443/gateway" + }, + { + "url": "https://fluxer.example", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https://fluxer.example:8443" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "http://fluxer.example:443/media" + }, + { + "url": "https://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 80, + "normalized": "https://fluxer.example:80/media" + }, + { + "url": "ws://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 443, + "normalized": "ws://fluxer.example:443/gateway" + }, + { + "url": "wss://fluxer.example/gateway", + "base_domain": "fluxer.example", + "public_port": 80, + "normalized": "wss://fluxer.example:80/gateway" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": null, + "normalized": "http://fluxer.example/media" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 0, + "normalized": "http://fluxer.example/media" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "", + "public_port": 19080, + "normalized": "http://fluxer.example/media" + }, + { + "url": "http://fluxer.example/media", + "base_domain": " ", + "public_port": 19080, + "normalized": "http://fluxer.example/media" + }, + { + "url": "http://fluxer.example:19080/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/media" + }, + { + "url": "http://fluxer.example:8080/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:8080/media" + }, + { + "url": "https://fluxer.example:443/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "https://fluxer.example:443/media" + }, + { + "url": "http://fluxer.example:80/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:80/media" + }, + { + "url": "https://fluxer.example:8443/admin/oauth2_callback", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https://fluxer.example:8443/admin/oauth2_callback" + }, + { + "url": "ws://fluxer.example:19080/gateway", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "ws://fluxer.example:19080/gateway" + }, + { + "url": "http://fluxer.example:8080?a=1", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:8080?a=1" + }, + { + "url": "https://cdn.example.net/assets", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "https://cdn.example.net/assets" + }, + { + "url": "https://media.example.net", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "https://media.example.net" + }, + { + "url": "https://sub.fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "https://sub.fluxer.example/media" + }, + { + "url": "https://fluxer.example/media", + "base_domain": "example", + "public_port": 19080, + "normalized": "https://fluxer.example/media" + }, + { + "url": "https://fluxer.example.evil.net/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "https://fluxer.example.evil.net/media" + }, + { + "url": "http://api:8080", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://api:8080" + }, + { + "url": "http://media-proxy:8080", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://media-proxy:8080" + }, + { + "url": "http://FLUXER.example/Media", + "base_domain": "Fluxer.Example", + "public_port": 19080, + "normalized": "http://FLUXER.example:19080/Media" + }, + { + "url": "HTTPS://fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "HTTPS://fluxer.example:8443/media" + }, + { + "url": "http://fluxer.example./media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example.:19080/media" + }, + { + "url": "http://fluxer.example/media", + "base_domain": "fluxer.example.", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/media" + }, + { + "url": "http://fluxer.example./media", + "base_domain": "fluxer.example.", + "public_port": 19080, + "normalized": "http://fluxer.example.:19080/media" + }, + { + "url": "http://fluxer.example?a=1", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080?a=1" + }, + { + "url": "http://fluxer.example#top", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080#top" + }, + { + "url": "http://fluxer.example/media/x.png?v=1#frag", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/media/x.png?v=1#frag" + }, + { + "url": "http://fluxer.example/media/", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/media/" + }, + { + "url": "http://fluxer.example/a%20b", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080/a%20b" + }, + { + "url": "http://fluxer.example#/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080#/media" + }, + { + "url": "http://user:pass@fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://user:pass@fluxer.example:19080/media" + }, + { + "url": "http://user:pass@fluxer.example:19080/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://user:pass@fluxer.example:19080/media" + }, + { + "url": "http://user:p@ss@fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://user:p@ss@fluxer.example:19080/media" + }, + { + "url": "http://[::1]/media", + "base_domain": "[::1]", + "public_port": 19080, + "normalized": "http://[::1]:19080/media" + }, + { + "url": "http://[::1]:8080/media", + "base_domain": "[::1]", + "public_port": 19080, + "normalized": "http://[::1]:8080/media" + }, + { + "url": "http://127.0.0.1/media", + "base_domain": "127.0.0.1", + "public_port": 19080, + "normalized": "http://127.0.0.1:19080/media" + }, + { + "url": "http://[2001:db8::1]/media", + "base_domain": "[2001:db8::1]", + "public_port": 19080, + "normalized": "http://[2001:db8::1]:19080/media" + }, + { + "url": "http://[2001:DB8::1]/media", + "base_domain": "[2001:db8::1]", + "public_port": 19080, + "normalized": "http://[2001:DB8::1]:19080/media" + }, + { + "url": "", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "" + }, + { + "url": "/api", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "/api" + }, + { + "url": "fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "fluxer.example/media" + }, + { + "url": "not a url", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "not a url" + }, + { + "url": "://x", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "://x" + }, + { + "url": "file:///x", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "file:///x" + }, + { + "url": "mailto:a@fluxer.example", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "mailto:a@fluxer.example" + }, + { + "url": "http://fluxer.example\\evil", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "http://fluxer.example:19080\\evil" + }, + { + "url": "https://fluxer.example:/media", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https://fluxer.example:/media" + }, + { + "url": "//fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 19080, + "normalized": "//fluxer.example/media" + }, + { + "url": "https:fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https:fluxer.example/media" + }, + { + "url": "https:/fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https:/fluxer.example/media" + }, + { + "url": "https:////fluxer.example/media", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "https:////fluxer.example/media" + }, + { + "url": "android:apk-key-hash:9NrCFDLVR8_ObZC-EWzaRA", + "base_domain": "fluxer.example", + "public_port": 8443, + "normalized": "android:apk-key-hash:9NrCFDLVR8_ObZC-EWzaRA" + } +] diff --git a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl index 6ca7906b4..e26d5c795 100644 --- a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl +++ b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl @@ -29,6 +29,7 @@ env_config() -> #{ <<"env">> => env_binary("FLUXER_ENV", <<"development">>), <<"internal">> => env_internal_config(), + <<"public">> => env_public_config(), <<"proxy">> => env_proxy_config(), <<"services">> => env_services_config(), <<"auth">> => env_auth_config(), @@ -42,6 +43,14 @@ env_internal_config() -> <<"api">> => env_binary("FLUXER_INTERNAL_API_ENDPOINT", <<"http://127.0.0.1:8080">>) }. +-spec env_public_config() -> map(). +env_public_config() -> + #{ + <<"base_domain">> => env_optional_binary("FLUXER_BASE_DOMAIN"), + <<"scheme">> => env_optional_binary("FLUXER_PUBLIC_SCHEME"), + <<"port">> => env_optional_binary("FLUXER_PUBLIC_PORT") + }. + -spec env_proxy_config() -> map(). env_proxy_config() -> #{ @@ -185,12 +194,13 @@ build_config(RawConfig) -> Apns = get_map(Push, [<<"apns">>]), Fcm = get_map(Push, [<<"fcm">>]), Proxy = get_map(RawConfig, [<<"proxy">>]), + Public = get_map(RawConfig, [<<"public">>]), lists:foldl(fun maps:merge/2, #{}, [ build_core_config(Service, Internal, Nats, Proxy), - build_push_config(Service), + build_push_config(Service, Public), build_sharding_config(Service), build_http_config(Service), - build_cluster_config(Service), + build_cluster_config(Service, Public), build_vapid_config(Vapid), build_apns_config(Apns), build_fcm_config(Fcm), @@ -215,8 +225,8 @@ build_core_config(Service, Internal, Nats, Proxy) -> ) }. --spec build_push_config(map()) -> config(). -build_push_config(Service) -> +-spec build_push_config(map(), map()) -> config(). +build_push_config(Service, Public) -> #{ push_enabled => get_bool(Service, <<"push_enabled">>, true), push_user_guild_settings_cache_mb => @@ -228,8 +238,8 @@ build_push_config(Service) -> push_badge_counts_cache_mb => get_int(Service, <<"push_badge_counts_cache_mb">>, 256), push_badge_counts_cache_ttl_seconds => get_int(Service, <<"push_badge_counts_cache_ttl_seconds">>, 60), - static_cdn_endpoint => get_binary( - Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">> + static_cdn_endpoint => public_endpoint( + get_binary(Service, <<"static_cdn_endpoint">>, <<"http://localhost:8088">>), Public ), push_dispatcher_max_inflight => get_int( Service, <<"push_dispatcher_max_inflight">>, 16 @@ -276,8 +286,8 @@ build_http_config(Service) -> get_int(Service, <<"gateway_http_cleanup_max_age_ms">>, 300000) }. --spec build_cluster_config(map()) -> config(). -build_cluster_config(Service) -> +-spec build_cluster_config(map(), map()) -> config(). +build_cluster_config(Service, Public) -> #{ cluster_enabled => get_bool(Service, <<"cluster_enabled">>, false), cluster_discovery_dns_name => @@ -290,7 +300,9 @@ build_cluster_config(Service) -> get_int(Service, <<"cluster_discovery_poll_interval_ms">>, 5000), cluster_static_peers => parse_node_list(get_optional_binary(Service, <<"cluster_static_peers">>)), - media_proxy_endpoint => get_optional_binary(Service, <<"media_proxy_endpoint">>) + media_proxy_endpoint => public_endpoint( + get_optional_binary(Service, <<"media_proxy_endpoint">>), Public + ) }. -spec build_vapid_config(map()) -> config(). @@ -601,6 +613,17 @@ normalize_gateway_role(Value) when is_list(Value) -> normalize_gateway_role(_) -> all. +-spec public_endpoint(binary() | undefined, map()) -> binary() | undefined. +public_endpoint(undefined, _Public) -> + undefined; +public_endpoint(Url, Public) -> + gateway_public_endpoint:normalize( + Url, + get_optional_binary(Public, <<"base_domain">>), + get_optional_binary(Public, <<"scheme">>), + get_optional_int(Public, <<"port">>) + ). + -spec optional_string(binary() | undefined) -> string() | undefined. optional_string(undefined) -> undefined; optional_string(Bin) when is_binary(Bin) -> binary_to_list(Bin). diff --git a/fluxer_gateway/src/gateway/gateway_public_endpoint.erl b/fluxer_gateway/src/gateway/gateway_public_endpoint.erl new file mode 100644 index 000000000..07dfe18a8 --- /dev/null +++ b/fluxer_gateway/src/gateway/gateway_public_endpoint.erl @@ -0,0 +1,97 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(gateway_public_endpoint). +-typing([eqwalizer]). + +-export([normalize/4]). + +-spec normalize(binary(), binary() | undefined, binary() | undefined, integer() | undefined) -> + binary(). +normalize(Url, BaseDomain, _PublicScheme, PublicPort) when + is_binary(Url), is_binary(BaseDomain), is_integer(PublicPort), PublicPort > 0 +-> + case base_domain(BaseDomain) of + <<>> -> Url; + Domain -> insert_port(Url, Domain, PublicPort) + end; +normalize(Url, _BaseDomain, _PublicScheme, _PublicPort) -> + Url. + +-spec base_domain(binary()) -> binary(). +base_domain(BaseDomain) -> + case string:trim(BaseDomain) of + Trimmed when is_binary(Trimmed) -> trim_root_dot(Trimmed); + _ -> <<>> + end. + +-spec insert_port(binary(), binary(), integer()) -> binary(). +insert_port(Url, Domain, Port) -> + case binary:split(Url, <<"://">>) of + [Scheme, Rest] -> insert_authority_port(Url, Scheme, Rest, Domain, Port); + [_] -> Url + end. + +-spec insert_authority_port(binary(), binary(), binary(), binary(), integer()) -> binary(). +insert_authority_port(Url, _Scheme, <<"/", _/binary>>, _Domain, _Port) -> + Url; +insert_authority_port(Url, Scheme, Rest, Domain, Port) -> + {Authority, Tail} = split_authority(Rest), + case insertable(Scheme, Authority, Domain, Port) of + true -> join_authority_port(Scheme, Authority, Tail, Port); + false -> Url + end. + +-spec insertable(binary(), binary(), binary(), integer()) -> boolean(). +insertable(Scheme, Authority, Domain, Port) -> + Host = after_last(Authority, <<"@">>), + Default = default_port(Scheme), + is_integer(Default) andalso Port =/= Default andalso not has_port(Host) andalso + same_host(Host, Domain). + +-spec join_authority_port(binary(), binary(), binary(), integer()) -> binary(). +join_authority_port(Scheme, Authority, Tail, Port) -> + PortBin = integer_to_binary(Port), + <>. + +-spec split_authority(binary()) -> {binary(), binary()}. +split_authority(Rest) -> + case binary:match(Rest, [<<"/">>, <<"\\">>, <<"?">>, <<"#">>]) of + {Pos, _} -> split_binary(Rest, Pos); + nomatch -> {Rest, <<>>} + end. + +-spec after_last(binary(), binary()) -> binary(). +after_last(Bin, Separator) -> + case binary:split(Bin, Separator) of + [_, Rest] -> after_last(Rest, Separator); + [Tail] -> Tail + end. + +-spec has_port(binary()) -> boolean(). +has_port(Host) -> + binary:match(after_last(Host, <<"]">>), <<":">>) =/= nomatch. + +-spec same_host(binary(), binary()) -> boolean(). +same_host(<<"[", _/binary>> = Host, Domain) -> + string:equal(Host, Domain, true); +same_host(Host, Domain) -> + string:equal(trim_root_dot(Host), Domain, true). + +-spec trim_root_dot(binary()) -> binary(). +trim_root_dot(<<>>) -> + <<>>; +trim_root_dot(Host) -> + case binary:last(Host) of + $. -> binary:part(Host, 0, byte_size(Host) - 1); + _ -> Host + end. + +-spec default_port(binary()) -> integer() | undefined. +default_port(Scheme) -> + case string:lowercase(Scheme) of + <<"http">> -> 80; + <<"https">> -> 443; + <<"ws">> -> 80; + <<"wss">> -> 443; + _ -> undefined + end. diff --git a/fluxer_gateway/test/fluxer_gateway_config_tests.erl b/fluxer_gateway/test/fluxer_gateway_config_tests.erl index 3c2fca551..21b4d425a 100644 --- a/fluxer_gateway/test/fluxer_gateway_config_tests.erl +++ b/fluxer_gateway/test/fluxer_gateway_config_tests.erl @@ -150,6 +150,52 @@ optional_string_test() -> ?assertEqual("hello", fluxer_gateway_config:optional_string(<<"hello">>)), ?assertEqual("", fluxer_gateway_config:optional_string(<<>>)). +public_endpoints_env_non_default_port_test() -> + with_envs( + [ + {"FLUXER_BASE_DOMAIN", "fluxer.example"}, + {"FLUXER_PUBLIC_SCHEME", "https"}, + {"FLUXER_PUBLIC_PORT", "8443"}, + {"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"}, + {"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://fluxer.example"} + ], + fun() -> + Config = fluxer_gateway_config:load(), + ?assertEqual( + <<"https://fluxer.example:8443/media">>, + maps:get(media_proxy_endpoint, Config) + ), + ?assertEqual( + <<"https://fluxer.example:8443">>, maps:get(static_cdn_endpoint, Config) + ) + end + ). + +public_endpoints_env_default_port_test() -> + with_envs( + [ + {"FLUXER_BASE_DOMAIN", "fluxer.example"}, + {"FLUXER_PUBLIC_SCHEME", "https"}, + {"FLUXER_PUBLIC_PORT", "443"}, + {"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT", "https://fluxer.example/media"}, + {"FLUXER_GATEWAY_STATIC_CDN_ENDPOINT", "https://cdn.othercdn.net"} + ], + fun() -> + Config = fluxer_gateway_config:load(), + ?assertEqual( + <<"https://fluxer.example/media">>, maps:get(media_proxy_endpoint, Config) + ), + ?assertEqual( + <<"https://cdn.othercdn.net">>, maps:get(static_cdn_endpoint, Config) + ) + end + ). + +public_endpoints_defaults_test() -> + Config = fluxer_gateway_config:build_config(#{}), + ?assertEqual(undefined, maps:get(media_proxy_endpoint, Config)), + ?assertEqual(<<"http://localhost:8088">>, maps:get(static_cdn_endpoint, Config)). + with_envs([], Fun) -> Fun(); with_envs([{Name, Value} | Rest], Fun) -> diff --git a/fluxer_gateway/test/gateway_public_endpoint_tests.erl b/fluxer_gateway/test/gateway_public_endpoint_tests.erl new file mode 100644 index 000000000..90d0df1b0 --- /dev/null +++ b/fluxer_gateway/test/gateway_public_endpoint_tests.erl @@ -0,0 +1,248 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(gateway_public_endpoint_tests). +-typing([eqwalizer]). +-include_lib("eunit/include/eunit.hrl"). + +-define(VECTORS_PATH, "../fluxer_common/src/testdata/public_endpoint_vectors.json"). + +normalize_default_https_install_test() -> + ?assertEqual( + <<"https://fluxer.example/media">>, + normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 443) + ), + ?assertEqual( + <<"https://fluxer.example">>, + normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 443) + ), + ?assertEqual( + <<"wss://fluxer.example/gateway">>, + normalize(<<"wss://fluxer.example/gateway">>, <<"fluxer.example">>, <<"https">>, 443) + ). + +normalize_default_http_install_test() -> + ?assertEqual( + <<"http://fluxer.example/media">>, + normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 80) + ), + ?assertEqual( + <<"ws://fluxer.example/gateway">>, + normalize(<<"ws://fluxer.example/gateway">>, <<"fluxer.example">>, <<"http">>, 80) + ). + +normalize_inserts_non_default_port_test() -> + ?assertEqual( + <<"https://fluxer.example:8443/media">>, + normalize(<<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443) + ), + ?assertEqual( + <<"https://fluxer.example:8443">>, + normalize(<<"https://fluxer.example">>, <<"fluxer.example">>, <<"https">>, 8443) + ), + ?assertEqual( + <<"http://fluxer.example:8080/media">>, + normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 8080) + ), + ?assertEqual( + <<"http://fluxer.example:443/media">>, + normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 443) + ). + +normalize_preserves_url_parts_test() -> + ?assertEqual( + <<"https://fluxer.example:8443/">>, + normalize(<<"https://fluxer.example/">>, <<"fluxer.example">>, <<"https">>, 8443) + ), + ?assertEqual( + <<"https://fluxer.example:8443/Media/Path?q=A%20b#Frag">>, + normalize( + <<"https://fluxer.example/Media/Path?q=A%20b#Frag">>, + <<"fluxer.example">>, + <<"https">>, + 8443 + ) + ), + ?assertEqual( + <<"https://user@fluxer.example:8443/media">>, + normalize( + <<"https://user@fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443 + ) + ). + +normalize_host_matching_test() -> + ?assertEqual( + <<"https://Fluxer.EXAMPLE.:8443/media">>, + normalize(<<"https://Fluxer.EXAMPLE./media">>, <<"fluxer.example">>, <<"https">>, 8443) + ), + ?assertEqual( + <<"https://cdn.othercdn.net/assets">>, + normalize( + <<"https://cdn.othercdn.net/assets">>, <<"fluxer.example">>, <<"https">>, 8443 + ) + ), + ?assertEqual( + <<"https://media.fluxer.example/media">>, + normalize( + <<"https://media.fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443 + ) + ). + +normalize_keeps_explicit_port_test() -> + ?assertEqual( + <<"https://fluxer.example:9443/media">>, + normalize( + <<"https://fluxer.example:9443/media">>, <<"fluxer.example">>, <<"https">>, 8443 + ) + ), + ?assertEqual( + <<"https://fluxer.example:8443/media">>, + normalize( + <<"https://fluxer.example:8443/media">>, <<"fluxer.example">>, <<"https">>, 8443 + ) + ), + ?assertEqual( + <<"https://fluxer.example:/media">>, + normalize(<<"https://fluxer.example:/media">>, <<"fluxer.example">>, <<"https">>, 8443) + ). + +normalize_missing_inputs_test() -> + ?assertEqual( + <<"https://fluxer.example/media">>, + normalize( + <<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, undefined + ) + ), + ?assertEqual( + <<"https://fluxer.example/media">>, + normalize(<<"https://fluxer.example/media">>, undefined, <<"https">>, 8443) + ), + ?assertEqual( + <<"https://fluxer.example/media">>, + normalize(<<"https://fluxer.example/media">>, <<>>, <<"https">>, 8443) + ). + +normalize_unparsable_url_test() -> + ?assertEqual( + <<"not a url">>, + normalize(<<"not a url">>, <<"fluxer.example">>, <<"https">>, 8443) + ), + ?assertEqual(<<>>, normalize(<<>>, <<"fluxer.example">>, <<"https">>, 8443)), + ?assertEqual( + <<"//fluxer.example/media">>, + normalize(<<"//fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443) + ). + +normalize_ipv6_host_test() -> + ?assertEqual( + <<"http://[::1]:19080/media">>, + normalize(<<"http://[::1]/media">>, <<"[::1]">>, <<"http">>, 19080) + ), + ?assertEqual( + <<"http://[2001:DB8::1]:19080/media">>, + normalize(<<"http://[2001:DB8::1]/media">>, <<"[2001:db8::1]">>, <<"http">>, 19080) + ), + ?assertEqual( + <<"http://[::1]:8080/media">>, + normalize(<<"http://[::1]:8080/media">>, <<"[::1]">>, <<"http">>, 19080) + ). + +normalize_userinfo_at_sign_test() -> + ?assertEqual( + <<"http://user:p@ss@fluxer.example:19080/media">>, + normalize( + <<"http://user:p@ss@fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 19080 + ) + ), + ?assertEqual( + <<"http://user:p@ss@fluxer.example:19080/media">>, + normalize( + <<"http://user:p@ss@fluxer.example:19080/media">>, + <<"fluxer.example">>, + <<"http">>, + 19080 + ) + ). + +normalize_backslash_authority_test() -> + ?assertEqual( + <<"http://fluxer.example:19080\\evil">>, + normalize(<<"http://fluxer.example\\evil">>, <<"fluxer.example">>, <<"http">>, 19080) + ). + +normalize_zero_port_test() -> + ?assertEqual( + <<"http://fluxer.example/media">>, + normalize(<<"http://fluxer.example/media">>, <<"fluxer.example">>, <<"http">>, 0) + ). + +normalize_single_root_dot_test() -> + ?assertEqual( + <<"http://fluxer.example.:19080/media">>, + normalize(<<"http://fluxer.example./media">>, <<"fluxer.example.">>, <<"http">>, 19080) + ), + ?assertEqual( + <<"http://fluxer.example../media">>, + normalize(<<"http://fluxer.example../media">>, <<"fluxer.example">>, <<"http">>, 19080) + ), + ?assertEqual( + <<"http://fluxer.example/media">>, + normalize(<<"http://fluxer.example/media">>, <<"fluxer.example..">>, <<"http">>, 19080) + ). + +normalize_unsupported_scheme_test() -> + ?assertEqual( + <<"file:///media">>, + normalize(<<"file:///media">>, <<"fluxer.example">>, <<"http">>, 19080) + ), + ?assertEqual( + <<"mailto:a@fluxer.example">>, + normalize(<<"mailto:a@fluxer.example">>, <<"fluxer.example">>, <<"http">>, 19080) + ). + +normalize_matches_shared_vectors_test() -> + Vectors = read_vectors(), + ?assertMatch([_ | _], Vectors), + lists:foreach(fun run_vector/1, Vectors). + +read_vectors() -> + case file:read_file(?VECTORS_PATH) of + {ok, Contents} -> + decode_vectors(Contents); + {error, Reason} -> + erlang:error({public_endpoint_vectors_unreadable, ?VECTORS_PATH, Reason}) + end. + +decode_vectors(Contents) -> + case json:decode(Contents) of + [_ | _] = Vectors -> Vectors; + _ -> erlang:error({public_endpoint_vectors_empty, ?VECTORS_PATH}) + end. + +run_vector( + #{<<"url">> := Url, <<"base_domain">> := BaseDomain, <<"normalized">> := Expected} = Vector +) when is_binary(Url), is_binary(BaseDomain), is_binary(Expected) -> + Port = vector_port(Vector), + ?assertEqual( + {Url, BaseDomain, Port, Expected}, + {Url, BaseDomain, Port, normalize(Url, BaseDomain, undefined, Port)} + ); +run_vector(Vector) -> + erlang:error({public_endpoint_vector_malformed, Vector}). + +vector_port(#{<<"public_port">> := null}) -> + undefined; +vector_port(#{<<"public_port">> := Port}) when is_integer(Port) -> + Port; +vector_port(Vector) -> + erlang:error({public_endpoint_vector_malformed, Vector}). + +normalize_is_idempotent_test() -> + Once = normalize( + <<"https://fluxer.example/media">>, <<"fluxer.example">>, <<"https">>, 8443 + ), + Twice = normalize(Once, <<"fluxer.example">>, <<"https">>, 8443), + ?assertEqual(<<"https://fluxer.example:8443/media">>, Once), + ?assertEqual(Once, Twice). + +normalize(Url, BaseDomain, Scheme, Port) -> + gateway_public_endpoint:normalize(Url, BaseDomain, Scheme, Port). diff --git a/fluxer_gifs/src/media_proxy.rs b/fluxer_gifs/src/media_proxy.rs index 4b463de7e..5158f6736 100644 --- a/fluxer_gifs/src/media_proxy.rs +++ b/fluxer_gifs/src/media_proxy.rs @@ -39,7 +39,9 @@ impl MediaProxyUrlBuilder { anyhow::bail!("gifs shard requires FLUXER_MEDIA_PROXY_SECRET_KEY"); } - let endpoint = endpoint.trim_end_matches('/').to_owned(); + let endpoint = fluxer_common::config::normalize_public_endpoint_from_env( + endpoint.trim_end_matches('/'), + ); let endpoint_host = Url::parse(&endpoint) .ok() .and_then(|parsed| parsed.host_str().map(ToOwned::to_owned)); @@ -86,6 +88,8 @@ mod tests { "FLUXER_MEDIA_ENDPOINT", "FLUXER_MEDIA_PROXY_ENDPOINT", "FLUXER_MEDIA_PROXY_SECRET_KEY", + "FLUXER_BASE_DOMAIN", + "FLUXER_PUBLIC_PORT", ]; let saved = keys .iter() @@ -187,6 +191,55 @@ mod tests { ) } + #[test] + fn from_env_inserts_a_non_default_public_port() -> anyhow::Result<()> { + with_media_proxy_env( + &[ + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + Some("http://fluxer.example/media"), + ), + ("FLUXER_MEDIA_PROXY_SECRET_KEY", Some("secret")), + ("FLUXER_BASE_DOMAIN", Some("fluxer.example")), + ("FLUXER_PUBLIC_PORT", Some("19080")), + ], + || { + let builder = MediaProxyUrlBuilder::from_env()?; + + assert_eq!(builder.endpoint, "http://fluxer.example:19080/media"); + assert_eq!(builder.endpoint_host.as_deref(), Some("fluxer.example")); + assert!( + builder + .external_proxy_url("https://img.example.net/a.webp") + .expect("proxy url") + .starts_with("http://fluxer.example:19080/media/external/") + ); + Ok(()) + }, + ) + } + + #[test] + fn from_env_leaves_a_default_public_port_alone() -> anyhow::Result<()> { + with_media_proxy_env( + &[ + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + Some("https://fluxer.example/media"), + ), + ("FLUXER_MEDIA_PROXY_SECRET_KEY", Some("secret")), + ("FLUXER_BASE_DOMAIN", Some("fluxer.example")), + ("FLUXER_PUBLIC_PORT", Some("443")), + ], + || { + let builder = MediaProxyUrlBuilder::from_env()?; + + assert_eq!(builder.endpoint, "https://fluxer.example/media"); + Ok(()) + }, + ) + } + #[test] fn from_env_rejects_internal_proxy_endpoint_without_public_endpoint() -> anyhow::Result<()> { with_media_proxy_env( diff --git a/fluxer_unfurl/src/shard_impl.rs b/fluxer_unfurl/src/shard_impl.rs index 8333f3aa0..90be90c47 100644 --- a/fluxer_unfurl/src/shard_impl.rs +++ b/fluxer_unfurl/src/shard_impl.rs @@ -40,10 +40,13 @@ impl UnfurlShard { .filter(|v| !v.is_empty()); let media_proxy_public_endpoint = std::env::var("FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT") .ok() - .filter(|v| !v.is_empty()); - let static_cdn_endpoint = std::env::var("FLUXER_UNFURL_STATIC_CDN_ENDPOINT") - .or_else(|_| std::env::var("FLUXER_STATIC_CDN_ENDPOINT")) - .unwrap_or_default(); + .filter(|v| !v.is_empty()) + .map(|v| fluxer_common::config::normalize_public_endpoint_from_env(&v)); + let static_cdn_endpoint = fluxer_common::config::normalize_public_endpoint_from_env( + &std::env::var("FLUXER_UNFURL_STATIC_CDN_ENDPOINT") + .or_else(|_| std::env::var("FLUXER_STATIC_CDN_ENDPOINT")) + .unwrap_or_default(), + ); let (media_proxy_endpoint, media_proxy_secret) = match ( media_proxy_endpoint, media_proxy_secret, @@ -301,9 +304,85 @@ impl ShardService for UnfurlShard { mod tests { use super::*; use crate::types::MessageEmbed; + use std::sync::Mutex; const BLOCKED_URL: &str = "http://127.0.0.1/unreachable"; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PUBLIC_ENDPOINT_ENV: [&str; 7] = [ + "FLUXER_MEDIA_PROXY_ENDPOINT", + "FLUXER_MEDIA_PROXY_SECRET_KEY", + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + "FLUXER_UNFURL_STATIC_CDN_ENDPOINT", + "FLUXER_STATIC_CDN_ENDPOINT", + "FLUXER_BASE_DOMAIN", + "FLUXER_PUBLIC_PORT", + ]; + + fn shard_from_env(vars: &[(&str, &str)]) -> UnfurlShard { + let _guard = ENV_LOCK.lock().unwrap(); + for name in PUBLIC_ENDPOINT_ENV { + unsafe { std::env::remove_var(name) }; + } + for (name, value) in vars { + unsafe { std::env::set_var(name, value) }; + } + let shard = UnfurlShard::new(long_lifetime()); + for (name, _) in vars { + unsafe { std::env::remove_var(name) }; + } + shard + } + + #[test] + fn a_non_default_public_port_reaches_the_public_media_and_static_endpoints() { + let shard = shard_from_env(&[ + ("FLUXER_MEDIA_PROXY_ENDPOINT", "http://media-proxy:8080"), + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + "http://fluxer.example/media", + ), + ("FLUXER_STATIC_CDN_ENDPOINT", "http://fluxer.example"), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "19080"), + ]); + + assert_eq!(shard.static_cdn_endpoint, "http://fluxer.example:19080"); + assert!( + shard + .media_proxy + .external_proxy_url("https://img.example.net/a.png") + .expect("proxy url") + .starts_with("http://fluxer.example:19080/media/external/") + ); + } + + #[test] + fn a_default_public_port_leaves_the_public_media_and_static_endpoints_alone() { + let shard = shard_from_env(&[ + ("FLUXER_MEDIA_PROXY_ENDPOINT", "http://media-proxy:8080"), + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ( + "FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT", + "https://fluxer.example/media", + ), + ("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"), + ("FLUXER_BASE_DOMAIN", "fluxer.example"), + ("FLUXER_PUBLIC_PORT", "443"), + ]); + + assert_eq!(shard.static_cdn_endpoint, "https://fluxer.example"); + assert!( + shard + .media_proxy + .external_proxy_url("https://img.example.net/a.png") + .expect("proxy url") + .starts_with("https://fluxer.example/media/external/") + ); + } + fn unfurl(url: &str, youtube_api_key: Option<&str>) -> UnfurlRequest { UnfurlRequest::Unfurl { url: url.to_owned(), diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 5e5c5e157..11d61eda8 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides'; -import {deriveEndpointsFromDomain} from '@fluxer/config/src/EndpointDerivation'; +import { + type DerivedEndpoints, + deriveEndpointsFromDomain, + normalizePublicEndpoint, +} from '@fluxer/config/src/EndpointDerivation'; import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; type ConfigObject = Record; @@ -433,6 +437,36 @@ function normalizeConfig(config: MasterConfig): MasterConfig { return config; } +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 normalizedEndpoints = {...endpoints}; + for (const key of Object.keys(normalizedEndpoints) as Array) { + normalizedEndpoints[key] = normalize(normalizedEndpoints[key]); + } + return { + ...config, + endpoints: normalizedEndpoints, + services: { + ...config.services, + media_proxy: { + ...config.services.media_proxy, + upload_relay: { + ...config.services.media_proxy.upload_relay, + endpoint: normalize(config.services.media_proxy.upload_relay.endpoint), + }, + }, + }, + auth: { + ...config.auth, + passkeys: { + ...config.auth.passkeys, + additional_allowed_origins: config.auth.passkeys.additional_allowed_origins.map(normalize), + }, + }, + }; +} + export async function loadConfig(): Promise { if (cachedConfig) { return cachedConfig; @@ -441,7 +475,7 @@ export async function loadConfig(): Promise { const normalized = normalizeConfig(merged); const derived = deriveEndpointsFromDomain(normalized.domain); const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})}; - cachedConfig = {...normalized, endpoints}; + cachedConfig = applyPublicPort(normalized, endpoints); return cachedConfig; } diff --git a/packages/config/src/EndpointDerivation.ts b/packages/config/src/EndpointDerivation.ts index 94ae40ad3..facd3081a 100644 --- a/packages/config/src/EndpointDerivation.ts +++ b/packages/config/src/EndpointDerivation.ts @@ -24,17 +24,56 @@ export interface DerivedEndpoints { gift: string; } -export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string { - const isStandardPort = +function isStandardPort(scheme: string, port: number): boolean { + return ( (scheme === 'http' && port === 80) || (scheme === 'https' && port === 443) || (scheme === 'ws' && port === 80) || - (scheme === 'wss' && port === 443); - const portPart = port && !isStandardPort ? `:${port}` : ''; + (scheme === 'wss' && port === 443) + ); +} + +function stripTrailingDot(host: string): string { + return host.endsWith('.') ? host.slice(0, -1) : host; +} + +export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string { + const portPart = port && !isStandardPort(scheme, port) ? `:${port}` : ''; const pathPart = path || ''; return `${scheme}://${domain}${portPart}${pathPart}`; } +export function normalizePublicEndpoint(url: string, baseDomain: string, publicPort?: number): string { + const domain = stripTrailingDot(baseDomain.trim().toLowerCase()); + if (domain.length === 0 || !publicPort) { + return url; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url; + } + if (stripTrailingDot(parsed.hostname.toLowerCase()) !== domain) { + return url; + } + if (isStandardPort(parsed.protocol.slice(0, -1), publicPort)) { + return url; + } + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1 || url.startsWith('/', schemeEnd + 3)) { + return url; + } + const authorityStart = schemeEnd + 3; + const relativeEnd = url.slice(authorityStart).search(/[/\\?#]/); + const authorityEnd = relativeEnd === -1 ? url.length : authorityStart + relativeEnd; + const host = url.slice(authorityStart, authorityEnd).split('@').pop() ?? ''; + if (host.slice(host.lastIndexOf(']') + 1).includes(':')) { + return url; + } + return `${url.slice(0, authorityEnd)}:${publicPort}${url.slice(authorityEnd)}`; +} + export function deriveDomain( endpointType: | 'api' diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 4dfb85e39..0ca684610 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -102,6 +102,105 @@ describe('ConfigLoader', () => { expect(config.endpoints.app).toBe('http://localhost:8088'); }); + test('inserts the public port into portless endpoints on a non-standard port', async () => { + stubMinimalEnv({ + FLUXER_MARKETING_ENDPOINT: 'http://localhost', + FLUXER_MEDIA_ENDPOINT: 'http://localhost/media', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost/media', + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'http://localhost', + }); + + const config = await loadConfig(); + + expect(config.endpoints.marketing).toBe('http://localhost:8088'); + expect(config.endpoints.media).toBe('http://localhost:8088/media'); + expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media'); + expect(config.auth.passkeys.additional_allowed_origins).toEqual(['http://localhost:8088']); + }); + + test('leaves a default https install untouched', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + FLUXER_MARKETING_ENDPOINT: 'https://chat.example', + FLUXER_MEDIA_ENDPOINT: 'https://chat.example/media', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example/media', + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example', + }); + + const config = await loadConfig(); + + expect(config.endpoints.marketing).toBe('https://chat.example'); + expect(config.endpoints.media).toBe('https://chat.example/media'); + expect(config.endpoints.api).toBe('https://chat.example/api'); + expect(config.endpoints.gateway).toBe('wss://chat.example/gateway'); + expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example/media'); + expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example']); + }); + + test('leaves a default http install untouched', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example', + FLUXER_PUBLIC_SCHEME: 'http', + FLUXER_PUBLIC_PORT: '80', + FLUXER_MARKETING_ENDPOINT: 'http://chat.example', + FLUXER_MEDIA_ENDPOINT: 'http://chat.example/media', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://chat.example/media', + }); + + const config = await loadConfig(); + + expect(config.endpoints.marketing).toBe('http://chat.example'); + expect(config.endpoints.media).toBe('http://chat.example/media'); + expect(config.endpoints.gateway).toBe('ws://chat.example/gateway'); + expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://chat.example/media'); + }); + + test('leaves foreign hosts and already ported endpoints untouched', async () => { + stubMinimalEnv({ + FLUXER_STATIC_CDN_ENDPOINT: 'https://cdn.example.net', + FLUXER_MEDIA_ENDPOINT: 'https://media.example.net/media', + FLUXER_MARKETING_ENDPOINT: 'http://localhost:9999', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'http://localhost:8088/media', + }); + + const config = await loadConfig(); + + expect(config.endpoints.static_cdn).toBe('https://cdn.example.net'); + expect(config.endpoints.media).toBe('https://media.example.net/media'); + expect(config.endpoints.marketing).toBe('http://localhost:9999'); + expect(config.services.media_proxy.upload_relay.endpoint).toBe('http://localhost:8088/media'); + }); + + test('normalizes each passkey origin independently', async () => { + stubMinimalEnv({ + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: + 'http://localhost,http://localhost:3000,https://desktop.example.net,android:apk-key-hash:abc', + }); + + const config = await loadConfig(); + + expect(config.auth.passkeys.additional_allowed_origins).toEqual([ + 'http://localhost:8088', + 'http://localhost:3000', + 'https://desktop.example.net', + 'android:apk-key-hash:abc', + ]); + }); + + test('leaves the default passkey origins untouched', async () => { + stubMinimalEnv(); + const config = await loadConfig(); + expect(config.auth.passkeys.additional_allowed_origins).toEqual([ + 'https://fluxer.app', + 'https://web.fluxer.app', + 'https://web.canary.fluxer.app', + 'android:apk-key-hash:keSY4bimyLqZQV7bKXgpa2xYuqXi0qZJzsYtp6gpx7w', + 'android:apk-key-hash:zRmCKDKo3uCX2GDZISjJx8Rzo3J-Y3Gbp7s7mAaUH28', + ]); + }); + test('parses typed named environment variables', async () => { stubMinimalEnv({ FLUXER_API_PORT: '9090', diff --git a/packages/config/src/__tests__/EndpointDerivation.test.ts b/packages/config/src/__tests__/EndpointDerivation.test.ts index 100c22581..a0472fdbe 100644 --- a/packages/config/src/__tests__/EndpointDerivation.test.ts +++ b/packages/config/src/__tests__/EndpointDerivation.test.ts @@ -5,6 +5,7 @@ import { type DomainConfig, deriveDomain, deriveEndpointsFromDomain, + normalizePublicEndpoint, } from '@fluxer/config/src/EndpointDerivation'; import {describe, expect, test} from 'vitest'; @@ -301,3 +302,95 @@ describe('deriveEndpointsFromDomain', () => { }); }); }); + +describe('normalizePublicEndpoint', () => { + test('leaves a default https install untouched', () => { + expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 443)).toBe('https://fluxer.dev'); + expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 443)).toBe('https://fluxer.dev/media'); + expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 443)).toBe('wss://fluxer.dev/gateway'); + }); + test('leaves a default http install untouched', () => { + expect(normalizePublicEndpoint('http://fluxer.dev', 'fluxer.dev', 80)).toBe('http://fluxer.dev'); + expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 80)).toBe('http://fluxer.dev/media'); + expect(normalizePublicEndpoint('ws://fluxer.dev/gateway', 'fluxer.dev', 80)).toBe('ws://fluxer.dev/gateway'); + }); + test('inserts a non-standard port', () => { + expect(normalizePublicEndpoint('https://fluxer.dev', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443'); + expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:8443/media', + ); + expect(normalizePublicEndpoint('wss://fluxer.dev/gateway', 'fluxer.dev', 8443)).toBe( + 'wss://fluxer.dev:8443/gateway', + ); + }); + test('judges standard ports against the url scheme, not the public scheme', () => { + expect(normalizePublicEndpoint('http://fluxer.dev/media', 'fluxer.dev', 443)).toBe('http://fluxer.dev:443/media'); + expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 80)).toBe('https://fluxer.dev:80/media'); + }); + test('leaves a foreign host untouched', () => { + expect(normalizePublicEndpoint('https://cdn.example.net/media', 'fluxer.dev', 8443)).toBe( + 'https://cdn.example.net/media', + ); + expect(normalizePublicEndpoint('https://sub.fluxer.dev', 'fluxer.dev', 8443)).toBe('https://sub.fluxer.dev'); + }); + test('leaves an already ported url untouched', () => { + expect(normalizePublicEndpoint('https://fluxer.dev:8443/media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:8443/media', + ); + expect(normalizePublicEndpoint('https://fluxer.dev:9000/media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:9000/media', + ); + expect(normalizePublicEndpoint('https://fluxer.dev:443/media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:443/media', + ); + }); + test('is idempotent', () => { + const once = normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev', 8443); + expect(normalizePublicEndpoint(once, 'fluxer.dev', 8443)).toBe(once); + }); + test('preserves path, query, fragment, trailing slash, and case', () => { + expect(normalizePublicEndpoint('https://fluxer.dev/Media/', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:8443/Media/', + ); + expect(normalizePublicEndpoint('https://fluxer.dev/media?a=B#Frag', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:8443/media?a=B#Frag', + ); + expect(normalizePublicEndpoint('https://fluxer.dev?a=B', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443?a=B'); + expect(normalizePublicEndpoint('https://fluxer.dev#Frag', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:8443#Frag'); + expect(normalizePublicEndpoint('https://user:pw@fluxer.dev/media', 'fluxer.dev', 8443)).toBe( + 'https://user:pw@fluxer.dev:8443/media', + ); + }); + test('matches the host case-insensitively and ignores a trailing dot', () => { + expect(normalizePublicEndpoint('https://FLUXER.dev/media', 'fluxer.dev', 8443)).toBe( + 'https://FLUXER.dev:8443/media', + ); + expect(normalizePublicEndpoint('https://fluxer.dev./media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev.:8443/media', + ); + expect(normalizePublicEndpoint('https://fluxer.dev/media', 'FLUXER.dev.', 8443)).toBe( + 'https://fluxer.dev:8443/media', + ); + }); + test('leaves unparseable and non-http values untouched', () => { + expect(normalizePublicEndpoint('not a url', 'fluxer.dev', 8443)).toBe('not a url'); + expect(normalizePublicEndpoint('', 'fluxer.dev', 8443)).toBe(''); + expect(normalizePublicEndpoint('android:apk-key-hash:abc', 'fluxer.dev', 8443)).toBe('android:apk-key-hash:abc'); + expect(normalizePublicEndpoint('https://fluxer.dev:/media', 'fluxer.dev', 8443)).toBe('https://fluxer.dev:/media'); + }); + test('leaves malformed authorities untouched', () => { + expect(normalizePublicEndpoint('https:fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:fluxer.dev/media'); + expect(normalizePublicEndpoint('https:/fluxer.dev/media', 'fluxer.dev', 8443)).toBe('https:/fluxer.dev/media'); + expect(normalizePublicEndpoint('https:////fluxer.dev/media', 'fluxer.dev', 8443)).toBe( + 'https:////fluxer.dev/media', + ); + expect(normalizePublicEndpoint('https://fluxer.dev\\media', 'fluxer.dev', 8443)).toBe( + 'https://fluxer.dev:8443\\media', + ); + }); + test('leaves everything untouched without a usable port or base domain', () => { + expect(normalizePublicEndpoint('https://fluxer.dev/media', 'fluxer.dev')).toBe('https://fluxer.dev/media'); + expect(normalizePublicEndpoint('https://fluxer.dev/media', '', 8443)).toBe('https://fluxer.dev/media'); + expect(normalizePublicEndpoint('https://fluxer.dev/media', ' ', 8443)).toBe('https://fluxer.dev/media'); + }); +}); diff --git a/packages/config/src/__tests__/PublicEndpointVectors.test.ts b/packages/config/src/__tests__/PublicEndpointVectors.test.ts new file mode 100644 index 000000000..8afd1de06 --- /dev/null +++ b/packages/config/src/__tests__/PublicEndpointVectors.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {normalizePublicEndpoint} from '@fluxer/config/src/EndpointDerivation'; +import {describe, expect, test} from 'vitest'; + +interface PublicEndpointVector { + url: string; + base_domain: string; + public_port: number | null; + normalized: string; +} + +const VECTORS_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../fluxer_common/src/testdata/public_endpoint_vectors.json', +); + +function readVectors(): Array { + const parsed: unknown = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`no public endpoint vectors in ${VECTORS_PATH}`); + } + return parsed.map((vector, index) => { + if ( + typeof vector?.url !== 'string' || + typeof vector?.base_domain !== 'string' || + typeof vector?.normalized !== 'string' || + (vector?.public_port !== null && typeof vector?.public_port !== 'number') + ) { + throw new Error(`malformed public endpoint vector at index ${index} in ${VECTORS_PATH}`); + } + return vector as PublicEndpointVector; + }); +} + +const vectors = readVectors(); + +describe('normalizePublicEndpoint shared vectors', () => { + test('reads the vector file the Rust conformance test reads', () => { + expect(vectors.length).toBeGreaterThan(0); + }); + test.each(vectors)('$url @ $base_domain port $public_port', (vector) => { + expect(normalizePublicEndpoint(vector.url, vector.base_domain, vector.public_port ?? undefined)).toBe( + vector.normalized, + ); + }); +});