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