mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): detect HTTP portals during explicit connection tests (#1588)
This commit is contained in:
1 parent
0700ba966e
commit
7d1265d566
52 files changed
+2185
-138
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user