fix(xtream): detect HTTP portals during explicit connection tests (#1588)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 15:30:22 +02:00
1 parent 0700ba966e
commit 7d1265d566
52 files changed
+2185 -138

No files matched your search

+2
View File
@@ -96,3 +96,5 @@ export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
export * from './lib/catchup-download.interface';
export * from './lib/xtream-connection-test';
@@ -1,3 +1,4 @@
import type { XtreamConnectionFailure } from './xtream-connection-test';
import type {
CatchupDownloadMetadata,
DownloadRecoveryResult,
@@ -306,6 +307,7 @@ export interface ElectronBridgeStalkerRequestPayload {
}
export interface ElectronBridgeXtreamRequestPayload {
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
@@ -314,6 +316,7 @@ export interface ElectronBridgeXtreamRequestPayload {
}
export interface ElectronBridgeXtreamResponse {
connectionFailure?: XtreamConnectionFailure;
payload: unknown;
action: string;
}
@@ -0,0 +1,178 @@
import {
describeXtreamConnectionFailure,
xtreamHttpAlternative,
} from './xtream-connection-test';
describe('Xtream connection transport evidence', () => {
it.each([
[
'https://panel.test/get.php?username=x&password=y',
'http://panel.test',
],
[
'https://panel.test:443/base/player_api.php',
'http://panel.test/base',
],
['https://panel.test:8443/base/', 'http://panel.test:8443/base'],
['http://panel.test', null],
])('builds only the same-host alternative for %s', (input, expected) => {
expect(xtreamHttpAlternative(input)).toBe(expected);
});
it.each(['ECONNREFUSED', 'ERR_SSL_WRONG_VERSION_NUMBER'])(
'accepts initial %s but excludes redirect failures',
(code) => {
expect(
describeXtreamConnectionFailure({ code }, false).canTryHttp
).toBe(true);
expect(
describeXtreamConnectionFailure({ code }, true).canTryHttp
).toBe(false);
}
);
it.each([
'ssl3_get_record:wrong version number',
'OPENSSL_internal:WRONG_VERSION_NUMBER',
])('recognizes a plaintext HTTP listener: %s', (message) => {
expect(
describeXtreamConnectionFailure(
{
code: 'EPROTO',
message,
},
false
).canTryHttp
).toBe(true);
});
it.each([
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'ERR_CANCELED',
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'ERR_TLS_CERT_ALTNAME_INVALID',
'EPROTO',
])('never authorizes HTTP for %s', (code) => {
expect(
describeXtreamConnectionFailure({ code }, false).canTryHttp
).toBe(false);
});
it.each([
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'INVALID_CA',
'HOSTNAME_MISMATCH',
'PATH_LENGTH_EXCEEDED',
'INVALID_PURPOSE',
'UNABLE_TO_GET_CRL',
])(
'recognizes certificate verification failure %s without allowing HTTP',
(code) => {
expect(describeXtreamConnectionFailure({ code }, false)).toEqual({
kind: 'tls',
canTryHttp: false,
});
}
);
it.each([undefined, 'ECONNREFUSED'])(
'requires all address failures to be positive (aggregate code=%s)',
(code) => {
const refused = { code: 'ECONNREFUSED' };
const aggregate = (other: unknown) => ({
code,
errors: [refused, other],
});
expect(
describeXtreamConnectionFailure(aggregate(refused), false)
.canTryHttp
).toBe(true);
for (const other of [
{ code: 'ETIMEDOUT' },
{ code: 'ENOTFOUND' },
{},
null,
]) {
expect(
describeXtreamConnectionFailure(aggregate(other), false)
.canTryHttp
).toBe(false);
}
expect(
describeXtreamConnectionFailure(aggregate(refused), true)
.canTryHttp
).toBe(false);
}
);
it('traverses wrappers and shared aggregate causes without losing TLS or HTTP evidence', () => {
const errors = [{ code: 'ECONNREFUSED' }, { code: 'ECONNREFUSED' }];
const aggregate = { errors };
expect(
describeXtreamConnectionFailure({ errors, cause: aggregate }, false)
.canTryHttp
).toBe(true);
expect(
describeXtreamConnectionFailure(
{
code: 'ECONNREFUSED',
cause: { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' },
},
false
)
).toEqual({ kind: 'tls', canTryHttp: false });
expect(
describeXtreamConnectionFailure(
{ cause: { response: { status: 403 }, cause: aggregate } },
false
)
).toEqual({ kind: 'http', status: 403, canTryHttp: false });
});
it('fails closed for empty aggregates, cycles and oversized cause trees', () => {
expect(
describeXtreamConnectionFailure(
{ code: 'ECONNREFUSED', errors: [] },
false
).canTryHttp
).toBe(false);
const cycle: { code: string; cause?: unknown } = {
code: 'ECONNREFUSED',
};
cycle.cause = cycle;
expect(describeXtreamConnectionFailure(cycle, false).canTryHttp).toBe(
false
);
let deep: unknown = { code: 'ECONNREFUSED' };
for (let index = 0; index < 100; index++) deep = { cause: deep };
expect(describeXtreamConnectionFailure(deep, false).canTryHttp).toBe(
false
);
expect(
describeXtreamConnectionFailure(
{
errors: Array.from({ length: 100 }, () => ({
code: 'ECONNREFUSED',
})),
},
false
).canTryHttp
).toBe(false);
});
it('keeps HTTP status without provider text or credentials', () => {
expect(
describeXtreamConnectionFailure(
{
response: { status: 403 },
message: 'secret',
code: 'ECONNREFUSED',
},
false
)
).toEqual({ kind: 'http', status: 403, canTryHttp: false });
});
});
@@ -0,0 +1,103 @@
import { normalizeXtreamServerUrl } from './xtream-portal.utils';
export interface XtreamConnectionFailure {
kind: 'http' | 'tls' | 'connection' | 'unknown';
status?: number;
canTryHttp: boolean;
}
interface TransportFailureLike {
code?: unknown;
status?: unknown;
message?: unknown;
response?: { status?: unknown };
errors?: unknown;
cause?: unknown;
}
const unknownFailure = (): XtreamConnectionFailure => ({
kind: 'unknown',
canTryHttp: false,
});
const TLS_VERIFY_CODES = new Set([
'EPROTO',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
'HOSTNAME_MISMATCH',
'INVALID_PURPOSE',
]);
/** Small, credential-free evidence produced by the transport, never by a panel. */
export function describeXtreamConnectionFailure(
error: unknown,
initialResponded: boolean
): XtreamConnectionFailure {
const active = new WeakSet<object>();
const memo = new WeakMap<object, XtreamConnectionFailure>();
let remaining = 64;
const visit = (value: unknown): XtreamConnectionFailure => {
if (!value || typeof value !== 'object') return unknownFailure();
const cached = memo.get(value);
if (cached) return cached;
if (active.has(value) || --remaining < 0) return unknownFailure();
active.add(value);
const candidate = value as TransportFailureLike;
const own = describeSingleFailure(candidate);
const evidence: XtreamConnectionFailure[] = own ? [own] : [];
// Node may summarize mixed IPv4/IPv6 failures with the first error's
// code. Require positive evidence from every address and nested cause.
if (candidate.errors !== undefined) {
if (Array.isArray(candidate.errors) && candidate.errors.length) {
if (candidate.errors.length > 64)
evidence.push(unknownFailure());
evidence.push(...candidate.errors.slice(0, 64).map(visit));
} else evidence.push(unknownFailure());
}
if (candidate.cause !== undefined)
evidence.push(visit(candidate.cause));
const detail =
evidence.find((e) => e.kind === 'http') ??
evidence.find((e) => e.kind === 'tls') ??
evidence.find((e) => e.kind === 'connection') ??
unknownFailure();
const result = {
...detail,
canTryHttp:
evidence.length > 0 && evidence.every((e) => e.canTryHttp),
};
active.delete(value);
memo.set(value, result);
return result;
};
const result = visit(error);
return { ...result, canTryHttp: !initialResponded && result.canTryHttp };
}
function describeSingleFailure(
candidate: TransportFailureLike
): XtreamConnectionFailure | null {
const status = candidate.response?.status ?? candidate.status;
if (typeof status === 'number' && status >= 100 && status <= 599)
return { kind: 'http', status, canTryHttp: false };
const code = typeof candidate.code === 'string' ? candidate.code : '';
if (!code) return null;
const wrongVersion =
code === 'ERR_SSL_WRONG_VERSION_NUMBER' ||
(code === 'EPROTO' &&
typeof candidate.message === 'string' &&
/wrong[ _]version[ _]number/i.test(candidate.message));
const tls = /CERT|TLS|SSL|CRL/.test(code) || TLS_VERIFY_CODES.has(code);
return {
kind: tls ? 'tls' : 'connection',
canTryHttp: code === 'ECONNREFUSED' || wrongVersion,
};
}
export function xtreamHttpAlternative(serverUrl: string): string | null {
const url = new URL(normalizeXtreamServerUrl(serverUrl));
if (url.protocol !== 'https:') return null;
// URL normalizes an explicit :443 away. Nonstandard ports stay explicit.
url.protocol = 'http:';
return normalizeXtreamServerUrl(url.href);
}