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

This commit is contained in:
Hampus authored and GitHub committed 2026-09-08 22:17:39 +02:00
1 parent 6c36d934f7
commit 8cc485cf81
17 files changed
+903 -61

No files matched your search

+70 -3
View File
@@ -3,9 +3,11 @@
import {createECDH} from 'node:crypto';
import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {
buildUrl,
type DerivedEndpoints,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
parsePublicOrigin,
} from '@fluxer/config/src/EndpointDerivation';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
@@ -26,6 +28,7 @@ function defaultConfig(): MasterConfig {
env: 'development',
domain: {
base_domain: '',
public_origin: '',
public_scheme: 'http',
internal_scheme: 'http',
public_port: 8088,
@@ -476,6 +479,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
assertIntegerInRange(config.services.api.request_timeout_ms, 'FLUXER_API_REQUEST_TIMEOUT_MS', 1_000, 3_600_000);
assertIntegerInRange(config.domain.public_port, 'FLUXER_PUBLIC_PORT', 1, 65_535);
requireString(config.domain.base_domain, 'FLUXER_BASE_DOMAIN');
requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET');
requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET');
@@ -493,16 +497,51 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
return config;
}
function applyPublicOrigin(config: MasterConfig): MasterConfig {
const raw = config.domain.public_origin.trim();
if (raw.length === 0) {
return config;
}
const origin = parsePublicOrigin(raw);
if (!origin) {
throw new Error(
`FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got ${raw}`,
);
}
return {
...config,
domain: {
...config.domain,
base_domain: origin.base_domain,
public_scheme: origin.public_scheme,
public_port: origin.public_port,
},
};
}
function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): MasterConfig {
const {base_domain, public_port} = config.domain;
const normalize = (url: string) => normalizePublicEndpoint(url, base_domain, public_port);
const normalizeOptional = (url: string | undefined) => (url === undefined ? undefined : normalize(url));
const normalizedEndpoints = {...endpoints};
for (const key of Object.keys(normalizedEndpoints) as Array<keyof DerivedEndpoints>) {
normalizedEndpoints[key] = normalize(normalizedEndpoints[key]);
}
const {bluesky, passkeys} = config.auth;
const {branding} = config.instance;
const {email, sms, voice} = config.integrations;
return {
...config,
domain: {
...config.domain,
public_origin: buildUrl(config.domain.public_scheme, base_domain, public_port),
},
endpoints: normalizedEndpoints,
s3: config.s3 && {...config.s3, presigned_url_base: normalizeOptional(config.s3.presigned_url_base)},
s3_downloads: config.s3_downloads && {
...config.s3_downloads,
presigned_url_base: normalizeOptional(config.s3_downloads.presigned_url_base),
},
services: {
...config.services,
media_proxy: {
@@ -512,12 +551,40 @@ function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): Mas
endpoint: normalize(config.services.media_proxy.upload_relay.endpoint),
},
},
gateway: {
...config.services.gateway,
media_proxy_endpoint: normalizeOptional(config.services.gateway.media_proxy_endpoint),
},
},
auth: {
...config.auth,
passkeys: {
...config.auth.passkeys,
additional_allowed_origins: config.auth.passkeys.additional_allowed_origins.map(normalize),
...passkeys,
additional_allowed_origins: passkeys.additional_allowed_origins.map(normalize),
},
bluesky: {
...bluesky,
client_uri: normalize(bluesky.client_uri),
logo_uri: normalize(bluesky.logo_uri),
tos_uri: normalize(bluesky.tos_uri),
policy_uri: normalize(bluesky.policy_uri),
},
},
integrations: {
...config.integrations,
email: {...email, app_base_url: normalize(email.app_base_url)},
sms: {...sms, inbound_webhook_public_url: normalizeOptional(sms.inbound_webhook_public_url)},
voice: {...voice, url: normalize(voice.url)},
},
instance: {
...config.instance,
branding: {
...branding,
icon_url: normalizeOptional(branding.icon_url),
symbol_url: normalizeOptional(branding.symbol_url),
logo_url: normalizeOptional(branding.logo_url),
wordmark_url: normalizeOptional(branding.wordmark_url),
favicon_url: normalizeOptional(branding.favicon_url),
},
},
};
@@ -546,7 +613,7 @@ export async function loadConfig(): Promise<MasterConfig> {
return cachedConfig;
}
const overrides = buildNamedFluxerEnvOverrides(process.env);
const merged = mergeConfig(defaultConfig(), overrides);
const merged = applyPublicOrigin(mergeConfig(defaultConfig(), overrides));
const normalized = normalizeConfig(merged);
const derived = deriveEndpointsFromDomain(normalized.domain);
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
+49 -4
View File
@@ -13,6 +13,14 @@ export interface DomainConfig {
gift_domain?: string;
}
type PublicOriginScheme = 'http' | 'https';
interface PublicOrigin {
public_scheme: PublicOriginScheme;
base_domain: string;
public_port: number;
}
export interface DerivedEndpoints {
api: string;
api_client: string;
@@ -36,8 +44,45 @@ function isStandardPort(scheme: string, port: number): boolean {
);
}
function stripTrailingDot(host: string): string {
return host.endsWith('.') ? host.slice(0, -1) : host;
export function canonicalizeDomain(value: string): string {
const trimmed = value.trim().toLowerCase();
return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
}
function defaultPortForScheme(scheme: PublicOriginScheme): number {
return scheme === 'https' ? 443 : 80;
}
export function parsePublicOrigin(origin: string): PublicOrigin | null {
const trimmed = origin.trim();
if (trimmed.length === 0) {
return null;
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
const scheme = parsed.protocol.slice(0, -1);
if (scheme !== 'http' && scheme !== 'https') {
return null;
}
if (parsed.pathname !== '/' || parsed.search.length > 0 || parsed.hash.length > 0) {
return null;
}
if (parsed.username.length > 0 || parsed.password.length > 0) {
return null;
}
const base_domain = canonicalizeDomain(parsed.hostname);
if (base_domain.length === 0) {
return null;
}
return {
public_scheme: scheme,
base_domain,
public_port: parsed.port.length === 0 ? defaultPortForScheme(scheme) : Number.parseInt(parsed.port, 10),
};
}
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
@@ -47,7 +92,7 @@ export function buildUrl(scheme: string, domain: string, port?: number, path?: s
}
export function normalizePublicEndpoint(url: string, baseDomain: string, publicPort?: number): string {
const domain = stripTrailingDot(baseDomain.trim().toLowerCase());
const domain = canonicalizeDomain(baseDomain);
if (domain.length === 0 || !publicPort) {
return url;
}
@@ -57,7 +102,7 @@ export function normalizePublicEndpoint(url: string, baseDomain: string, publicP
} catch {
return url;
}
if (stripTrailingDot(parsed.hostname.toLowerCase()) !== domain) {
if (canonicalizeDomain(parsed.hostname) !== domain) {
return url;
}
if (isStandardPort(parsed.protocol.slice(0, -1), publicPort)) {
+1
View File
@@ -20,6 +20,7 @@ export interface MasterConfig {
env: RuntimeEnv;
domain: {
base_domain: string;
public_origin: string;
public_scheme: PublicScheme;
internal_scheme: PublicScheme;
public_port: number;
@@ -646,4 +646,183 @@ describe('ConfigLoader', () => {
vi.stubEnv('FLUXER_ENV', 'test');
await expect(loadConfig()).rejects.toThrow();
});
test('inserts the public port into the LiveKit url', async () => {
stubMinimalEnv({FLUXER_LIVEKIT_URL: 'http://localhost/livekit'});
const config = await loadConfig();
expect(config.integrations.voice.url).toBe('http://localhost:8088/livekit');
});
test('inserts the public port into every other public url the config carries', async () => {
stubMinimalEnv({
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: 'http://localhost/media',
FLUXER_S3_PUBLIC_ENDPOINT: 'http://localhost/s3',
FLUXER_EMAIL_APP_BASE_URL: 'http://localhost',
FLUXER_SMS_INBOUND_WEBHOOK_PUBLIC_URL: 'http://localhost/webhooks/sms',
FLUXER_AUTH_BLUESKY_CLIENT_URI: 'http://localhost',
FLUXER_AUTH_BLUESKY_TOS_URI: 'http://localhost/terms',
FLUXER_APP_ICON_URL: 'http://localhost/icon.png',
FLUXER_LIVEKIT_INTERNAL_URL: 'http://livekit:7880',
});
const config = await loadConfig();
expect(config.services.gateway.media_proxy_endpoint).toBe('http://localhost:8088/media');
expect(config.s3?.presigned_url_base).toBe('http://localhost:8088/s3');
expect(config.integrations.email.app_base_url).toBe('http://localhost:8088');
expect(config.integrations.sms.inbound_webhook_public_url).toBe('http://localhost:8088/webhooks/sms');
expect(config.auth.bluesky.client_uri).toBe('http://localhost:8088');
expect(config.auth.bluesky.tos_uri).toBe('http://localhost:8088/terms');
expect(config.instance.branding.icon_url).toBe('http://localhost:8088/icon.png');
expect(config.integrations.voice.internal_url).toBe('http://livekit:7880');
});
test('rejects a public port outside the valid range', async () => {
stubMinimalEnv({FLUXER_PUBLIC_PORT: '70000'});
await expect(loadConfig()).rejects.toThrow('FLUXER_PUBLIC_PORT must be an integer between 1 and 65535');
});
});
describe('FLUXER_PUBLIC_ORIGIN', () => {
beforeEach(() => {
resetConfig();
clearFluxerEnv();
});
afterEach(() => {
resetConfig();
vi.unstubAllEnvs();
});
test('a ported origin ports every derived endpoint and every compose override', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
FLUXER_MARKETING_ENDPOINT: 'https://chat.example.com:29080',
FLUXER_MEDIA_ENDPOINT: 'https://chat.example.com:29080/media',
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: 'https://chat.example.com:29080/media',
FLUXER_LIVEKIT_URL: 'https://chat.example.com:29080/livekit',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://chat.example.com:29080',
});
const config = await loadConfig();
expect(config.domain.public_port).toBe(29080);
expect(config.domain.public_origin).toBe('https://chat.example.com:29080');
expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api');
expect(config.endpoints.app).toBe('https://chat.example.com:29080');
expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin');
expect(config.endpoints.marketing).toBe('https://chat.example.com:29080');
expect(config.endpoints.media).toBe('https://chat.example.com:29080/media');
expect(config.services.media_proxy.upload_relay.endpoint).toBe('https://chat.example.com:29080/media');
expect(config.integrations.voice.url).toBe('https://chat.example.com:29080/livekit');
expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example.com:29080']);
});
test('the origin port wins over a FLUXER_PUBLIC_PORT that disagrees', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '443',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
});
const config = await loadConfig();
expect(config.domain.public_port).toBe(29080);
expect(config.endpoints.app).toBe('https://chat.example.com:29080');
expect(config.endpoints.api_client).toBe('https://chat.example.com:29080/api');
expect(config.endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
expect(config.endpoints.admin).toBe('https://chat.example.com:29080/admin');
});
test('accepts an origin whose port matches FLUXER_PUBLIC_PORT', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '29080',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
});
expect((await loadConfig()).endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
});
test('normalizes an origin written with an explicit default port', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '443',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:443',
FLUXER_ADMIN_ENDPOINT: 'https://chat.example.com/admin',
});
const config = await loadConfig();
expect(config.domain.public_port).toBe(443);
expect(config.domain.public_origin).toBe('https://chat.example.com');
expect(config.endpoints.admin).toBe('https://chat.example.com/admin');
expect(config.endpoints.app).toBe('https://chat.example.com');
expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway');
});
test('takes the scheme and the domain from the origin when neither is set', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: '',
FLUXER_PUBLIC_SCHEME: '',
FLUXER_PUBLIC_PORT: '',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com:29080',
});
const config = await loadConfig();
expect(config.domain.base_domain).toBe('chat.example.com');
expect(config.domain.public_scheme).toBe('https');
expect(config.domain.public_port).toBe(29080);
});
test('the origin scheme wins over a FLUXER_PUBLIC_SCHEME that disagrees', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'http',
FLUXER_PUBLIC_PORT: '',
FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com',
});
const config = await loadConfig();
expect(config.domain.public_scheme).toBe('https');
expect(config.endpoints.app).toBe('https://chat.example.com');
expect(config.endpoints.gateway).toBe('wss://chat.example.com/gateway');
});
test('the origin host wins over a FLUXER_BASE_DOMAIN that disagrees', async () => {
stubMinimalEnv({
FLUXER_BASE_DOMAIN: 'chat.example.com',
FLUXER_PUBLIC_SCHEME: 'https',
FLUXER_PUBLIC_PORT: '',
FLUXER_PUBLIC_ORIGIN: 'https://other.example.com',
});
const config = await loadConfig();
expect(config.domain.base_domain).toBe('other.example.com');
expect(config.endpoints.app).toBe('https://other.example.com');
});
test('refuses to boot on an origin that is not a bare origin', async () => {
stubMinimalEnv({FLUXER_PUBLIC_ORIGIN: 'https://chat.example.com/app'});
await expect(loadConfig()).rejects.toThrow(
'FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got https://chat.example.com/app',
);
});
test('leaves the derived origin canonical when nothing is set', async () => {
stubMinimalEnv();
expect((await loadConfig()).domain.public_origin).toBe('http://localhost:8088');
});
});
@@ -2,10 +2,12 @@
import {
buildUrl,
canonicalizeDomain,
type DomainConfig,
deriveDomain,
deriveEndpointsFromDomain,
normalizePublicEndpoint,
parsePublicOrigin,
} from '@fluxer/config/src/EndpointDerivation';
import {describe, expect, test} from 'vitest';
@@ -394,3 +396,103 @@ describe('normalizePublicEndpoint', () => {
expect(normalizePublicEndpoint('https://fluxer.dev/media', ' ', 8443)).toBe('https://fluxer.dev/media');
});
});
describe('canonicalizeDomain', () => {
test('lowercases, trims and drops the root dot', () => {
expect(canonicalizeDomain(' CHAT.Example.COM. ')).toBe('chat.example.com');
});
test('leaves an empty value empty', () => {
expect(canonicalizeDomain(' ')).toBe('');
});
});
describe('parsePublicOrigin', () => {
test('reads scheme, host and a non-standard port', () => {
expect(parsePublicOrigin('https://chat.example.com:8443')).toEqual({
public_scheme: 'https',
base_domain: 'chat.example.com',
public_port: 8443,
});
expect(parsePublicOrigin('http://chat.example.com:19080')).toEqual({
public_scheme: 'http',
base_domain: 'chat.example.com',
public_port: 19080,
});
});
test('fills in the standard port for a portless origin', () => {
expect(parsePublicOrigin('https://chat.example.com')).toEqual({
public_scheme: 'https',
base_domain: 'chat.example.com',
public_port: 443,
});
expect(parsePublicOrigin('http://chat.example.com')).toEqual({
public_scheme: 'http',
base_domain: 'chat.example.com',
public_port: 80,
});
});
test('normalizes an explicitly written standard port to the portless form', () => {
const origin = parsePublicOrigin('https://chat.example.com:443');
expect(origin).toEqual({public_scheme: 'https', base_domain: 'chat.example.com', public_port: 443});
expect(buildUrl(origin?.public_scheme ?? 'https', origin?.base_domain ?? '', origin?.public_port)).toBe(
'https://chat.example.com',
);
expect(parsePublicOrigin('http://chat.example.com:80')?.public_port).toBe(80);
});
test('canonicalizes the host', () => {
expect(parsePublicOrigin(' https://CHAT.Example.com.:8443 ')).toEqual({
public_scheme: 'https',
base_domain: 'chat.example.com',
public_port: 8443,
});
});
test('keeps an IPv6 literal bracketed', () => {
expect(parsePublicOrigin('http://[::1]:19080')).toEqual({
public_scheme: 'http',
base_domain: '[::1]',
public_port: 19080,
});
});
test('accepts a bare trailing slash', () => {
expect(parsePublicOrigin('https://chat.example.com:8443/')?.public_port).toBe(8443);
});
test('rejects anything that is not a bare origin', () => {
expect(parsePublicOrigin('')).toBeNull();
expect(parsePublicOrigin(' ')).toBeNull();
expect(parsePublicOrigin('not a url')).toBeNull();
expect(parsePublicOrigin('chat.example.com:8443')).toBeNull();
expect(parsePublicOrigin('wss://chat.example.com')).toBeNull();
expect(parsePublicOrigin('https://chat.example.com/media')).toBeNull();
expect(parsePublicOrigin('https://chat.example.com?a=1')).toBeNull();
expect(parsePublicOrigin('https://chat.example.com#top')).toBeNull();
expect(parsePublicOrigin('https://user:[email protected]')).toBeNull();
});
});
describe('endpoints derived from a public origin', () => {
test('an origin with a non-standard port ports every derived endpoint', () => {
const origin = parsePublicOrigin('https://chat.example.com:29080');
const endpoints = deriveEndpointsFromDomain({
base_domain: origin?.base_domain ?? '',
public_scheme: origin?.public_scheme ?? 'https',
internal_scheme: 'http',
public_port: origin?.public_port,
});
expect(endpoints.api_client).toBe('https://chat.example.com:29080/api');
expect(endpoints.app).toBe('https://chat.example.com:29080');
expect(endpoints.gateway).toBe('wss://chat.example.com:29080/gateway');
expect(endpoints.admin).toBe('https://chat.example.com:29080/admin');
});
test('an origin written with an explicit :443 derives portless endpoints', () => {
const origin = parsePublicOrigin('https://chat.example.com:443');
const endpoints = deriveEndpointsFromDomain({
base_domain: origin?.base_domain ?? '',
public_scheme: origin?.public_scheme ?? 'https',
internal_scheme: 'http',
public_port: origin?.public_port,
});
expect(endpoints.admin).toBe('https://chat.example.com/admin');
expect(endpoints.app).toBe('https://chat.example.com');
expect(endpoints.gateway).toBe('wss://chat.example.com/gateway');
});
});
@@ -14,6 +14,7 @@ interface NamedEnvOverride {
const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_ENV: {path: ['env']},
FLUXER_BASE_DOMAIN: {path: ['domain', 'base_domain']},
FLUXER_PUBLIC_ORIGIN: {path: ['domain', 'public_origin']},
FLUXER_PUBLIC_SCHEME: {path: ['domain', 'public_scheme']},
FLUXER_INTERNAL_SCHEME: {path: ['domain', 'internal_scheme']},
FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseInteger},