From d73551d780f84287bcc6a6074fdc3f0bd8434161 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:09:05 +0200 Subject: [PATCH] fix(pwa): tolerate broken IPv6 routes and surface provider error codes (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pwa): tolerate broken IPv6 routes and surface provider error codes Node's happy-eyeballs racing gives each address attempt only 250 ms, so a dual-stack provider hostname behind an IPv4-only VPN namespace (Gluetun, WireGuard) exhausts every attempt and the web backend answered with a bare 502. Raise the per-attempt budget to 2500 ms at startup — keeping the IPv6->IPv4 fallback automatic — while an explicit --network-family-autoselection-attempt-timeout from NODE_OPTIONS still wins. Provider proxy failures now log the target hostname plus the underlying Node error codes (never the URL query, which carries credentials) and return the primary code in the error body, so the app shows "Bad Gateway (ETIMEDOUT)" instead of an unexplained 502. Closes #1400 Co-Authored-By: Claude Fable 5 * fix(pwa): surface proxy network codes in import/refresh toasts, document runtime contract Codex: /parse connection failures arrive as HTTP 500 whose body carries the new code field, but fetchFromUrl()/refreshPlaylist() mapped only the HTTP status, so the toast stayed generic. Append the code to the translated message (regression-covered for both flows). Greptile: record the web-backend happy-eyeballs/diagnostics runtime contract in CLAUDE.md's monorepo structure section. Co-Authored-By: Claude Fable 5 * fix(pwa): drop provider reason phrases from logs, honor underscore flag spellings Codex round 2: the HTTP reason phrase is provider-controlled and can echo the credential-bearing request URL, so the failure log now carries only the numeric status; and Node treats underscores and dashes interchangeably in flag names, so the explicit-override check normalizes spelling before matching (verified live: --network_family_autoselection_attempt_timeout applies in both CLI and NODE_OPTIONS forms). Co-Authored-By: Claude Fable 5 * fix(pwa): match the timeout flag as a complete NODE_OPTIONS token Codex round 3 (P3): a raw substring search also fired on the flag text embedded in another option's value, silently skipping the 2500 ms default. Tokenize NODE_OPTIONS on whitespace and match the normalized option name exactly or with '='. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/pwa-ipv6-ipv4-fallback.md | 11 ++ CLAUDE.md | 2 +- .../app/network-family-autoselection.spec.ts | 158 +++++++++++++++ .../src/app/network-family-autoselection.ts | 88 +++++++++ .../src/app/provider-error.spec.ts | 180 ++++++++++++++++++ apps/web-backend/src/app/provider-error.ts | 139 ++++++++++++++ .../src/app/web-backend-app.spec.ts | 147 +++++++++++++- apps/web-backend/src/app/web-backend-app.ts | 54 +++--- apps/web-backend/src/main.ts | 11 ++ apps/web/src/app/services/pwa.service.spec.ts | 84 +++++++- apps/web/src/app/services/pwa.service.ts | 25 ++- docker/README.md | 31 +++ 12 files changed, 892 insertions(+), 38 deletions(-) create mode 100644 .changes/pwa-ipv6-ipv4-fallback.md create mode 100644 apps/web-backend/src/app/network-family-autoselection.spec.ts create mode 100644 apps/web-backend/src/app/network-family-autoselection.ts create mode 100644 apps/web-backend/src/app/provider-error.spec.ts create mode 100644 apps/web-backend/src/app/provider-error.ts diff --git a/.changes/pwa-ipv6-ipv4-fallback.md b/.changes/pwa-ipv6-ipv4-fallback.md new file mode 100644 index 000000000..70af96192 --- /dev/null +++ b/.changes/pwa-ipv6-ipv4-fallback.md @@ -0,0 +1,11 @@ +--- +type: fix +area: pwa +issues: [1400] +--- + +The self-hosted web backend now gives each provider connection attempt enough +time to fall back from an unreachable IPv6 route to working IPv4 — common +behind VPN containers like Gluetun — instead of failing with a bare +"Bad Gateway". Provider errors now name the underlying network code (for +example ETIMEDOUT) in the app and in the container logs. diff --git a/CLAUDE.md b/CLAUDE.md index 4105d8e85..20ce30bc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,7 +290,7 @@ This is an Nx monorepo with the following structure: - **apps/web** - Angular application (frontend, shared by Electron and PWA) - **apps/electron-backend** - Electron main process -- **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints) +- **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints). At startup it raises Node's happy-eyeballs per-attempt connection timeout to 2500 ms (`network-family-autoselection.ts`) so dual-stack provider hostnames fall back to IPv4 behind IPv6-less VPN/Docker networks; an explicit `--network-family-autoselection-attempt-timeout` passed via `NODE_OPTIONS`/CLI always wins. Outbound provider failures are logged hostname-only with the underlying Node error codes and return the primary code in the error body (`provider-error.ts`) — the proxied URL query carries credentials and must never be logged - **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend - **apps/web-e2e** - Playwright E2E tests against the web app - **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app diff --git a/apps/web-backend/src/app/network-family-autoselection.spec.ts b/apps/web-backend/src/app/network-family-autoselection.spec.ts new file mode 100644 index 000000000..cc9158de7 --- /dev/null +++ b/apps/web-backend/src/app/network-family-autoselection.spec.ts @@ -0,0 +1,158 @@ +import net from 'node:net'; +import { + applyDefaultAutoSelectFamilyAttemptTimeout, + AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG, + DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS, + hasExplicitAttemptTimeoutFlag, +} from './network-family-autoselection'; + +describe('network family autoselection tuning', () => { + it('raises the attempt timeout when the operator set nothing', () => { + const setAttemptTimeout = jest.fn(); + + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [], + nodeOptions: '', + setAttemptTimeout, + }); + + expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS); + expect(setAttemptTimeout).toHaveBeenCalledWith( + DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS + ); + }); + + it('keeps an explicit --network-family-autoselection-attempt-timeout= flag', () => { + const setAttemptTimeout = jest.fn(); + + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=1000`], + nodeOptions: '', + setAttemptTimeout, + }); + + expect(applied).toBeNull(); + expect(setAttemptTimeout).not.toHaveBeenCalled(); + }); + + it('keeps the flag when it is passed as a separate argument', () => { + const setAttemptTimeout = jest.fn(); + + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG, '1000'], + nodeOptions: '', + setAttemptTimeout, + }); + + expect(applied).toBeNull(); + expect(setAttemptTimeout).not.toHaveBeenCalled(); + }); + + it('keeps the flag when it arrives through NODE_OPTIONS', () => { + const setAttemptTimeout = jest.fn(); + + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [], + nodeOptions: `--dns-result-order=ipv4first ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=500`, + setAttemptTimeout, + }); + + expect(applied).toBeNull(); + expect(setAttemptTimeout).not.toHaveBeenCalled(); + }); + + it('keeps the underscore spelling Node also accepts', () => { + const setAttemptTimeout = jest.fn(); + + const viaArgv = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: ['--network_family_autoselection_attempt_timeout=500'], + nodeOptions: '', + setAttemptTimeout, + }); + const viaNodeOptions = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [], + nodeOptions: '--network_family_autoselection_attempt_timeout=700', + setAttemptTimeout, + }); + + expect(viaArgv).toBeNull(); + expect(viaNodeOptions).toBeNull(); + expect(setAttemptTimeout).not.toHaveBeenCalled(); + }); + + it('detects the flag in both argv shapes and NODE_OPTIONS', () => { + expect( + hasExplicitAttemptTimeoutFlag( + [`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=250`], + '' + ) + ).toBe(true); + expect( + hasExplicitAttemptTimeoutFlag( + [AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG], + '' + ) + ).toBe(true); + expect( + hasExplicitAttemptTimeoutFlag( + ['--network_family-autoselection_attempt-timeout=250'], + '' + ) + ).toBe(true); + expect( + hasExplicitAttemptTimeoutFlag( + [], + `${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} 250` + ) + ).toBe(true); + expect( + hasExplicitAttemptTimeoutFlag( + ['--no-network-family-autoselection'], + '--dns-result-order=ipv4first' + ) + ).toBe(false); + }); + + it('ignores the flag text embedded in another option value or name', () => { + const setAttemptTimeout = jest.fn(); + + // The flag string inside another option's VALUE is not the option. + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [], + nodeOptions: + '--title=--network-family-autoselection-attempt-timeout-worker', + setAttemptTimeout, + }); + + expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS); + expect(setAttemptTimeout).toHaveBeenCalledWith( + DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS + ); + + // A longer option NAME that merely starts with the flag text is a + // different option. + expect( + hasExplicitAttemptTimeoutFlag( + ['--network-family-autoselection-attempt-timeout-worker=5'], + '' + ) + ).toBe(false); + }); + + it('applies the timeout to the real net default when nothing is injected', () => { + const original = net.getDefaultAutoSelectFamilyAttemptTimeout(); + try { + const applied = applyDefaultAutoSelectFamilyAttemptTimeout({ + execArgv: [], + nodeOptions: '', + }); + + expect(applied).toBe(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS); + expect(net.getDefaultAutoSelectFamilyAttemptTimeout()).toBe( + DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS + ); + } finally { + net.setDefaultAutoSelectFamilyAttemptTimeout(original); + } + }); +}); diff --git a/apps/web-backend/src/app/network-family-autoselection.ts b/apps/web-backend/src/app/network-family-autoselection.ts new file mode 100644 index 000000000..effee8f60 --- /dev/null +++ b/apps/web-backend/src/app/network-family-autoselection.ts @@ -0,0 +1,88 @@ +import net from 'node:net'; + +/** + * Node enables "Happy Eyeballs" connection racing (`autoSelectFamily`) by + * default and gives every address attempt only 250 ms before moving on to + * the next one. Behind VPN or Docker networks a perfectly working IPv4 + * handshake routinely needs more than that, so a dual-stack provider + * hostname with an unreachable IPv6 route exhausts all attempts + * (`ENETUNREACH` on IPv6, `ETIMEDOUT` on IPv4) and surfaces as a bare + * 502 (#1400). Raising the per-attempt budget keeps the IPv6→IPv4 fallback + * intact — unlike `--no-network-family-autoselection`, which would break + * IPv6-only deployments — while tolerating slow links. + */ +export const DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 2500; + +export const AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG = + '--network-family-autoselection-attempt-timeout'; + +export interface ApplyAutoSelectFamilyAttemptTimeoutOptions { + readonly execArgv?: readonly string[]; + readonly nodeOptions?: string; + readonly setAttemptTimeout?: (timeoutMs: number) => void; +} + +/** + * Node accepts underscores interchangeably with dashes in flag names + * (`process.allowedNodeEnvironmentFlags` semantics), so + * `--network_family_autoselection_attempt_timeout=500` is just as explicit + * as the canonical spelling and must be honored too. + */ +function normalizeFlagSpelling(value: string): string { + return value.replace(/_/g, '-'); +} + +/** + * The flag must match as a complete option token — a raw substring search + * would also fire on the flag text embedded in another option's value + * (e.g. `--title=--network-family-autoselection-attempt-timeout-worker`) + * and silently skip the 2500 ms default. + */ +function matchesAttemptTimeoutFlag(token: string): boolean { + const normalized = normalizeFlagSpelling(token); + return ( + normalized === AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG || + normalized.startsWith(`${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG}=`) + ); +} + +/** + * An operator who tunes the timeout through Node's own CLI flag (directly or + * via `NODE_OPTIONS`) must keep the final word: the flag is applied before + * user code runs, so overwriting it here would silently undo their setting. + */ +export function hasExplicitAttemptTimeoutFlag( + execArgv: readonly string[], + nodeOptions: string +): boolean { + return ( + execArgv.some(matchesAttemptTimeoutFlag) || + nodeOptions.split(/\s+/).some(matchesAttemptTimeoutFlag) + ); +} + +/** + * Raises the process-wide happy-eyeballs attempt timeout unless the operator + * already set one explicitly. Returns the applied value, or `null` when the + * explicit flag won (or the running Node build lacks the setter). + */ +export function applyDefaultAutoSelectFamilyAttemptTimeout( + options: ApplyAutoSelectFamilyAttemptTimeoutOptions = {} +): number | null { + const execArgv = options.execArgv ?? process.execArgv; + const nodeOptions = + options.nodeOptions ?? process.env['NODE_OPTIONS'] ?? ''; + if (hasExplicitAttemptTimeoutFlag(execArgv, nodeOptions)) { + return null; + } + + const setAttemptTimeout = + options.setAttemptTimeout ?? + net.setDefaultAutoSelectFamilyAttemptTimeout; + if (typeof setAttemptTimeout !== 'function') { + return null; + } + + setAttemptTimeout(DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS); + return DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS; +} diff --git a/apps/web-backend/src/app/provider-error.spec.ts b/apps/web-backend/src/app/provider-error.spec.ts new file mode 100644 index 000000000..df1c1aef2 --- /dev/null +++ b/apps/web-backend/src/app/provider-error.spec.ts @@ -0,0 +1,180 @@ +import { + collectProviderErrorCodes, + logProviderRequestFailure, + normalizeProviderError, +} from './provider-error'; + +function errorWithCode(message: string, code: string): Error { + return Object.assign(new Error(message), { code }); +} + +describe('collectProviderErrorCodes', () => { + it('collects the aggregate code and the per-address member codes', () => { + const aggregate = Object.assign( + new AggregateError([ + errorWithCode('connect ETIMEDOUT 104.21.0.1:80', 'ETIMEDOUT'), + errorWithCode( + 'connect ENETUNREACH 2606:4700::1:80', + 'ENETUNREACH' + ), + ]), + { code: 'ETIMEDOUT' } + ); + + expect(collectProviderErrorCodes(aggregate)).toEqual([ + 'ETIMEDOUT', + 'ENETUNREACH', + ]); + }); + + it('walks the cause chain', () => { + const wrapped = new Error('request failed', { + cause: errorWithCode('getaddrinfo ENOTFOUND host', 'ENOTFOUND'), + }); + + expect(collectProviderErrorCodes(wrapped)).toEqual(['ENOTFOUND']); + }); + + it('survives cyclic error graphs', () => { + const first = errorWithCode('first', 'ECONNRESET') as Error & { + cause?: unknown; + }; + const second = new Error('second', { cause: first }); + first.cause = second; + + expect(collectProviderErrorCodes(second)).toEqual(['ECONNRESET']); + }); + + it('ignores non-string and empty codes', () => { + expect( + collectProviderErrorCodes( + Object.assign(new Error('numeric'), { code: 502 }) + ) + ).toEqual([]); + expect( + collectProviderErrorCodes( + Object.assign(new Error('empty'), { code: '' }) + ) + ).toEqual([]); + expect(collectProviderErrorCodes(undefined)).toEqual([]); + expect(collectProviderErrorCodes('ETIMEDOUT')).toEqual([]); + }); +}); + +describe('normalizeProviderError', () => { + it('keeps the HTTP response status and statusText untouched', () => { + const httpError = Object.assign(new Error('Forbidden'), { + code: 'ERR_BAD_REQUEST', + response: { status: 403, statusText: 'Forbidden' }, + }); + + expect(normalizeProviderError(httpError)).toEqual({ + message: 'Forbidden', + status: 403, + }); + }); + + it('keeps a statusText-only response on the HTTP branch', () => { + const partial = Object.assign(new Error('partial'), { + response: { statusText: 'Gateway Timeout' }, + }); + + expect(normalizeProviderError(partial)).toEqual({ + message: 'Gateway Timeout', + status: 502, + }); + }); + + it('names the network code in message and body for non-HTTP failures', () => { + expect( + normalizeProviderError(errorWithCode('timeout', 'ETIMEDOUT')) + ).toEqual({ + message: 'Bad Gateway (ETIMEDOUT)', + status: 502, + code: 'ETIMEDOUT', + }); + }); + + it('falls back to a bare bad gateway when no code exists', () => { + expect(normalizeProviderError(new Error('boom'))).toEqual({ + message: 'Bad Gateway', + status: 502, + }); + }); +}); + +describe('logProviderRequestFailure', () => { + it('logs hostname and codes but never the URL query string', () => { + const logger = jest.fn(); + + logProviderRequestFailure({ + error: errorWithCode('timeout', 'ETIMEDOUT'), + route: '/xtream', + url: 'http://provider.example/player_api.php?username=user&password=pass', + logger, + }); + + expect(logger).toHaveBeenCalledTimes(1); + const line = logger.mock.calls[0][0] as string; + expect(line).toContain('/xtream'); + expect(line).toContain('provider.example'); + expect(line).toContain('ETIMEDOUT'); + expect(line).not.toContain('username'); + expect(line).not.toContain('password'); + expect(line).not.toContain('player_api.php'); + }); + + it('describes HTTP failures by numeric status only — the reason phrase is provider-controlled', () => { + const logger = jest.fn(); + + logProviderRequestFailure({ + error: Object.assign(new Error('Unauthorized'), { + response: { + status: 401, + statusText: + 'Unauthorized username=alice&password=secret-pass', + }, + }), + route: '/stalker', + url: new URL('http://portal.example/portal.php'), + logger, + }); + + const line = logger.mock.calls[0][0] as string; + expect(line).toContain('HTTP 401'); + expect(line).not.toContain('alice'); + expect(line).not.toContain('secret-pass'); + }); + + it('logs a generic HTTP error for a statusText-only response', () => { + const logger = jest.fn(); + + logProviderRequestFailure({ + error: Object.assign(new Error('partial'), { + response: { statusText: 'Gateway Timeout' }, + }), + route: '/stalker', + url: new URL('http://portal.example/portal.php'), + logger, + }); + + const line = logger.mock.calls[0][0] as string; + expect(line).toContain('HTTP error'); + expect(line).not.toContain('Gateway Timeout'); + }); + + it('tolerates unparseable URLs and codeless errors', () => { + const logger = jest.fn(); + + logProviderRequestFailure({ + error: new Error('boom'), + route: '/parse', + url: 'not a url', + logger, + }); + + const line = logger.mock.calls[0][0] as string; + expect(line).toContain(''); + expect(line).toContain('unknown network error'); + }); +}); diff --git a/apps/web-backend/src/app/provider-error.ts b/apps/web-backend/src/app/provider-error.ts new file mode 100644 index 000000000..275e78706 --- /dev/null +++ b/apps/web-backend/src/app/provider-error.ts @@ -0,0 +1,139 @@ +/** + * Provider-facing error normalization for the web backend proxy routes. + * + * Outbound provider failures used to collapse into a bare `502 Bad Gateway`, + * hiding the network error codes (`ETIMEDOUT`, `ENETUNREACH`, ...) that + * explain what actually went wrong (#1400). These helpers surface those codes + * in the JSON error body and in a server-side log line. The log line carries + * only the provider hostname — never the full URL, whose query string + * routinely holds Xtream credentials. + */ + +export interface ProviderError extends Error { + readonly code?: unknown; + readonly cause?: unknown; + readonly errors?: unknown; + readonly response?: { + readonly status?: number; + readonly statusText?: string; + }; +} + +export interface NormalizedProviderError { + readonly message: string; + readonly status: number; + readonly code?: string; +} + +const MAX_COLLECTED_CODES = 5; +const MAX_TRAVERSAL_DEPTH = 4; + +/** + * Collects the `code` properties of an error, its `cause` chain, and any + * `AggregateError` members — Node's happy-eyeballs failure is an + * `AggregateError` whose members carry the per-address codes that tell the + * IPv6-vs-IPv4 story. Bounded so a hostile or cyclic error graph cannot spin. + */ +export function collectProviderErrorCodes(error: unknown): string[] { + const codes: string[] = []; + visitErrorNode(error, 0, codes, new Set()); + return codes; +} + +function visitErrorNode( + error: unknown, + depth: number, + codes: string[], + seen: Set +): void { + if ( + !error || + typeof error !== 'object' || + depth > MAX_TRAVERSAL_DEPTH || + seen.has(error) || + codes.length >= MAX_COLLECTED_CODES + ) { + return; + } + seen.add(error); + + const candidate = error as ProviderError; + if ( + typeof candidate.code === 'string' && + candidate.code.length > 0 && + !codes.includes(candidate.code) + ) { + codes.push(candidate.code); + } + + if (Array.isArray(candidate.errors)) { + for (const nested of candidate.errors) { + visitErrorNode(nested, depth + 1, codes, seen); + } + } + + visitErrorNode(candidate.cause, depth + 1, codes, seen); +} + +export function normalizeProviderError( + error: unknown +): NormalizedProviderError { + const providerError = error as ProviderError | null | undefined; + const response = providerError?.response; + if ( + response && + (response.status !== undefined || response.statusText !== undefined) + ) { + return { + message: response.statusText ?? 'Bad Gateway', + status: response.status ?? 502, + }; + } + + const code = collectProviderErrorCodes(error)[0]; + return { + message: code ? `Bad Gateway (${code})` : 'Bad Gateway', + status: 502, + ...(code ? { code } : {}), + }; +} + +export function logProviderRequestFailure(options: { + readonly error: unknown; + readonly route: string; + readonly url: URL | string; + readonly logger?: (message: string) => void; +}): void { + const log = options.logger ?? console.error; + log( + `[web-backend] ${options.route} request to ${safeHostname(options.url)} failed: ${describeProviderFailure(options.error)}` + ); +} + +function describeProviderFailure(error: unknown): string { + const providerError = error as ProviderError | null | undefined; + const response = providerError?.response; + if ( + response && + (response.status !== undefined || response.statusText !== undefined) + ) { + // The reason phrase is provider-controlled text and can echo the + // credential-bearing request URL; only the numeric status may be + // logged. (The statusText still reaches the client response body, + // which the provider could see anyway.) + return response.status !== undefined + ? `HTTP ${response.status}` + : 'HTTP error'; + } + + const codes = collectProviderErrorCodes(error); + return codes.length > 0 ? codes.join(', ') : 'unknown network error'; +} + +function safeHostname(url: URL | string): string { + try { + return (url instanceof URL ? url : new URL(url)).hostname; + } catch { + return ''; + } +} diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index ae87b0794..9c90818dd 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -46,6 +46,10 @@ class StubHttpClient implements WebBackendHttpClient { this.queuedResponses.push({ data: null, error: new Error(message) }); } + queueNetworkError(error: Error): void { + this.queuedResponses.push({ data: null, error }); + } + async get( url: string, options: WebBackendHttpGetOptions = {} @@ -496,9 +500,9 @@ https://stream.example/live.m3u8`); `${baseUrl}/stalker?targetId=${targetId}&action=get_categories` ); - expect( - httpClient.requests[0].headers - ).not.toHaveProperty('Cookie'); + expect(httpClient.requests[0].headers).not.toHaveProperty( + 'Cookie' + ); expect(httpClient.requests[0].headers).toMatchObject( STALKER_IDENTITY_HEADERS ); @@ -586,7 +590,9 @@ https://stream.example/live.m3u8`); `${baseUrl}/stalker?targetId=${bareQueryTarget}&action=create_link&cmd=${encodeURIComponent('/media/2.mpg')}` ); - expect(httpClient.requests.map((request) => request.url)).toEqual([ + expect( + httpClient.requests.map((request) => request.url) + ).toEqual([ 'http://stalker.example/portal.php?action=create_link&cmd=/media/1.mpg&JsHttpRequest=1-xml', 'http://stalker.example/load.php?action=create_link&cmd=/media/2.mpg&JsHttpRequest=1-xml', ]); @@ -646,6 +652,139 @@ https://stream.example/live.m3u8`); ); }); + it('surfaces happy-eyeballs network codes on xtream failures', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueNetworkError( + Object.assign( + new AggregateError([ + Object.assign( + new Error('connect ETIMEDOUT 104.21.0.1:80'), + { code: 'ETIMEDOUT' } + ), + Object.assign( + new Error('connect ENETUNREACH 2606:4700::1:80'), + { code: 'ENETUNREACH' } + ), + ]), + { code: 'ETIMEDOUT' } + ) + ); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&action=get_account_info&username=secret-user&password=secret-pass` + ); + + await expect(response.json()).resolves.toEqual({ + message: 'Bad Gateway (ETIMEDOUT)', + status: 502, + code: 'ETIMEDOUT', + }); + } + ); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const logLine = errorSpy.mock.calls[0][0] as string; + expect(logLine).toContain('/xtream'); + expect(logLine).toContain('xtream.example'); + expect(logLine).toContain('ETIMEDOUT, ENETUNREACH'); + expect(logLine).not.toContain('secret-user'); + expect(logLine).not.toContain('secret-pass'); + } finally { + errorSpy.mockRestore(); + } + }); + + it('surfaces network error codes on stalker failures', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueNetworkError( + Object.assign(new Error('connect ENETUNREACH'), { + code: 'ENETUNREACH', + }) + ); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); + const response = await fetch( + `${baseUrl}/stalker?targetId=${targetId}&action=handshake` + ); + + await expect(response.json()).resolves.toEqual({ + message: 'Bad Gateway (ENETUNREACH)', + status: 502, + code: 'ENETUNREACH', + }); + } + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('surfaces network error codes on playlist parse failures', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueNetworkError( + Object.assign(new Error('connect ETIMEDOUT'), { + code: 'ETIMEDOUT', + }) + ); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/list.m3u' + ); + const response = await fetch( + `${baseUrl}/parse?targetId=${targetId}` + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + message: 'Error, something went wrong (ETIMEDOUT)', + status: 500, + code: 'ETIMEDOUT', + }); + } + ); + } finally { + errorSpy.mockRestore(); + } + }); + it('returns provider parse errors as JSON instead of executable text', async () => { const httpClient = new StubHttpClient(); httpClient.queueFailure(502, ''); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index bb8ce34bb..90f50363b 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -13,6 +13,12 @@ import { normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; +import { + collectProviderErrorCodes, + logProviderRequestFailure, + normalizeProviderError, + ProviderError, +} from './provider-error'; export interface WebBackendHttpGetOptions { readonly headers?: Record; @@ -27,16 +33,10 @@ export interface WebBackendHttpClient { ): Promise<{ data: T }>; } -interface ProviderError extends Error { - readonly response?: { - readonly status?: number; - readonly statusText?: string; - }; -} - interface PlaylistParseError { readonly message: string; readonly status: number; + readonly code?: string; } export interface WebBackendAppOptions { @@ -176,6 +176,7 @@ export function createWebBackendApp( res.json(result); } catch (error) { + logProviderRequestFailure({ error, route: '/parse-xml', url }); const providerError = normalizeProviderError(error); res.status(providerError.status).json(providerError); } @@ -193,10 +194,11 @@ export function createWebBackendApp( const url = new URL(registeredUrl.href); try { - const providerUrlError = await normalizeAndValidateXtreamProviderUrl( - url, - providerUrlPolicy - ); + const providerUrlError = + await normalizeAndValidateXtreamProviderUrl( + url, + providerUrlPolicy + ); if (providerUrlError) { res.status(providerUrlError.status).json(providerUrlError); return; @@ -216,6 +218,7 @@ export function createWebBackendApp( payload: response.data, }); } catch (error) { + logProviderRequestFailure({ error, route: '/xtream', url }); res.json(normalizeProviderError(error)); } }); @@ -274,6 +277,7 @@ export function createWebBackendApp( payload: response.data, }); } catch (error) { + logProviderRequestFailure({ error, route: '/stalker', url }); res.json(normalizeProviderError(error)); } }); @@ -487,12 +491,21 @@ async function handlePlaylistParse(options: { url: options.url, }); } catch (error) { + logProviderRequestFailure({ error, route: '/parse', url: options.url }); const providerError = error as ProviderError; + if (providerError?.response?.statusText !== undefined) { + return { + status: providerError.response.status ?? 500, + message: providerError.response.statusText, + }; + } + const code = collectProviderErrorCodes(error)[0]; return { - status: providerError.response?.status ?? 500, - message: - providerError.response?.statusText ?? - 'Error, something went wrong', + status: providerError?.response?.status ?? 500, + message: code + ? `Error, something went wrong (${code})` + : 'Error, something went wrong', + ...(code ? { code } : {}), }; } } @@ -577,17 +590,6 @@ function getLastUrlSegment(value: string): string { return segment.length > 0 ? segment : 'Playlist without title'; } -function normalizeProviderError(error: unknown): { - readonly message: string; - readonly status: number; -} { - const providerError = error as ProviderError; - return { - message: providerError.response?.statusText ?? 'Bad Gateway', - status: providerError.response?.status ?? 502, - }; -} - function createGuid(): string { return Math.random().toString(36).slice(2); } diff --git a/apps/web-backend/src/main.ts b/apps/web-backend/src/main.ts index 3fcb96289..b2cd3dbbb 100644 --- a/apps/web-backend/src/main.ts +++ b/apps/web-backend/src/main.ts @@ -1,5 +1,16 @@ +import { + applyDefaultAutoSelectFamilyAttemptTimeout, + AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG, +} from './app/network-family-autoselection'; import { createWebBackendApp } from './app/web-backend-app'; +const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout(); +if (attemptTimeout !== null) { + console.log( + `[web-backend] connection attempt timeout set to ${attemptTimeout} ms for IPv6->IPv4 fallback; pass ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} via NODE_OPTIONS to override` + ); +} + const port = Number(process.env['PORT'] ?? 3000); const app = createWebBackendApp(); diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index 3052800b0..cbb262674 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -7,7 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { EMPTY } from 'rxjs'; +import { config as rxjsConfig, EMPTY } from 'rxjs'; import { PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, @@ -79,6 +79,84 @@ describe('PwaService', () => { expect(http.match(() => true)).toHaveLength(0); }); + it('appends the proxy network code to the URL-import failure toast', async () => { + // The /parse proxy reports connection-level failures as HTTP 500 with + // a `code` field in the body (#1400). The toast must carry that code — + // status-only mapping used to collapse ETIMEDOUT into the generic + // fetch error. The rethrow after the snackbar has no downstream error + // observer, so park RxJS's unhandled-error hook for the test. + const unhandled: unknown[] = []; + const previousHandler = rxjsConfig.onUnhandledError; + rxjsConfig.onUnhandledError = (error) => unhandled.push(error); + + try { + service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + url: 'https://provider.example/list.m3u', + }); + + await Promise.resolve(); + http.expectOne((req) => + req.url.endsWith('/provider-targets') + ).flush({ targetId: 'target-1' }); + await new Promise((resolve) => setTimeout(resolve)); + + http.expectOne((req) => req.url.endsWith('/parse')).flush( + { + message: 'Error, something went wrong (ETIMEDOUT)', + status: 500, + code: 'ETIMEDOUT', + }, + { status: 500, statusText: 'Internal Server Error' } + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED (ETIMEDOUT)', + 'Close', + { duration: 5000 } + ); + } finally { + rxjsConfig.onUnhandledError = previousHandler; + } + }); + + it('appends the proxy network code to the playlist-refresh failure toast', async () => { + const unhandled: unknown[] = []; + const previousHandler = rxjsConfig.onUnhandledError; + rxjsConfig.onUnhandledError = (error) => unhandled.push(error); + + try { + service.sendIpcEvent(PLAYLIST_UPDATE, { + id: 'playlist-1', + url: 'https://provider.example/list.m3u', + }); + + await Promise.resolve(); + http.expectOne((req) => + req.url.endsWith('/provider-targets') + ).flush({ targetId: 'target-1' }); + await new Promise((resolve) => setTimeout(resolve)); + + http.expectOne((req) => req.url.endsWith('/parse')).flush( + { + message: 'Error, something went wrong (ENETUNREACH)', + status: 500, + code: 'ENETUNREACH', + }, + { status: 500, statusText: 'Internal Server Error' } + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED (ENETUNREACH)', + 'CLOSE', + { duration: 5000 } + ); + } finally { + rxjsConfig.onUnhandledError = previousHandler; + } + }); + it('surfaces the stalker proxy error envelope as an HTTP error instead of undefined', async () => { // The web-backend converts an upstream 404 into HTTP 200 with a // `{ message, status }` body and NO `payload` key. Unwrapping @@ -152,9 +230,7 @@ describe('PwaService', () => { '00:1A:79:AA:BB:CC' ); expect(requestUrl.searchParams.get('token')).toBe('TOKEN123'); - expect(requestUrl.searchParams.get('serialNumber')).toBe( - 'SN1234' - ); + expect(requestUrl.searchParams.get('serialNumber')).toBe('SN1234'); expect(requestUrl.searchParams.get('action')).toBe( 'get_ordered_list' ); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 6f9b9ba8d..c4a2c6f9f 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -158,7 +158,10 @@ export class PwaService extends DataService { .pipe( catchError((error) => { this.snackBar.open( - this.getPlaylistRefreshErrorMessage(error), + this.appendProviderErrorCode( + this.getPlaylistRefreshErrorMessage(error), + error + ), this.translateService.instant('CLOSE'), { duration: 5000, @@ -220,8 +223,11 @@ export class PwaService extends DataService { .pipe( catchError((error) => { this.snackBar.open( - this.getErrorMessageByStatusCode( - this.extractHttpStatusCode(error) + this.appendProviderErrorCode( + this.getErrorMessageByStatusCode( + this.extractHttpStatusCode(error) + ), + error ), 'Close', { @@ -269,6 +275,19 @@ export class PwaService extends DataService { return this.translateService.instant(messageKey); } + /** + * The web backend attaches the underlying Node network code (ETIMEDOUT, + * ENETUNREACH, ...) to proxy error bodies; without it the toast collapses + * every connection failure into the same generic fetch error (#1400). + */ + private appendProviderErrorCode(message: string, error: unknown): string { + const body = (error as { error?: { code?: unknown } } | null)?.error; + const code = typeof body?.code === 'string' ? body.code : ''; + return code && !message.includes(`(${code})`) + ? `${message} (${code})` + : message; + } + private extractHttpStatusCode(error: unknown): number | null { if ( error && diff --git a/docker/README.md b/docker/README.md index 1c4876d22..4206c18bb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -123,6 +123,37 @@ define a health check against `/api/health`. If nginx or the backend exits after startup, the entrypoint exits the container so Docker Compose can apply the `restart: unless-stopped` policy. +## IPv6, VPNs, And Connection Fallback + +Node races IPv6 and IPv4 addresses when a provider hostname has both ("happy +eyeballs"), and its stock per-attempt budget of 250 ms is too short for many +VPN and container networks. The classic symptom is a provider that answers +`wget` from inside the container but fails in IPTVnator with +`Bad Gateway (ETIMEDOUT)` — typically behind an IPv4-only VPN namespace such +as Gluetun/WireGuard, where the IPv6 route is unreachable and the working +IPv4 handshake needs more than 250 ms. + +The backend therefore raises the per-attempt connection budget to 2500 ms at +startup. This keeps the IPv6→IPv4 fallback fully automatic in both +directions. If you pass +`--network-family-autoselection-attempt-timeout` yourself through +`NODE_OPTIONS`, your value wins and the backend leaves it untouched. + +If a provider still fails, check the container logs first: outbound provider +failures are logged with the target hostname and the underlying Node error +codes (`ETIMEDOUT`, `ENETUNREACH`, `ENOTFOUND`, ...), and the same code is +returned to the app in the error message. As a last resort you can pin the +legacy behavior entirely: + +```yaml +services: + iptvnator: + environment: + # Disables IPv6/IPv4 racing completely; only for networks where + # IPv6 can never work. Breaks IPv6-only deployments. + NODE_OPTIONS: '--dns-result-order=ipv4first --no-network-family-autoselection' +``` + ## Local Validation ```bash