Files
iptvnator/apps/web-backend/src/app/web-backend-app.ts
T
4grayandClaude Fable 5.1 01c423ac43 fix(portals): stop treating a slow panel as a dead host; IPv4 fallback budget in Electron (#1621)
* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process

The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): stop treating a slow panel as a dead one in the host guard

axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.

Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.

The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): let an accepted connection clear the host-failure streak

Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): credit an accepted connection when it happens, not when the request settles

Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.

Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): decide the proxy exemption with axios' own resolution

Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.

The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:50:50 +02:00

674 lines
24 KiB
TypeScript

import cors from 'cors';
import express, { Express, Request, Response } from 'express';
import { createHash } from 'node:crypto';
import zlib from 'node:zlib';
import epgParser from 'epg-parser';
import parser from 'iptv-playlist-parser';
import {
HostConnectivityGuard,
HostRequestToken,
} from '@iptvnator/shared/host-health';
import {
buildStalkerIdentityRequestContext,
buildStalkerRequestUrl,
normalizeXtreamServerUrl,
describeXtreamConnectionFailure,
} from '@iptvnator/shared/interfaces';
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
import {
admitProviderRequest,
observeProviderRequest,
PROVIDER_REQUEST_TIMEOUT_MS,
releaseProviderRequest,
reportProviderRequestConnected,
reportProviderRequestFailure,
reportProviderRequestSuccess,
resetProviderHost,
} from './host-guard';
import {
collectProviderErrorCodes,
logProviderRequestFailure,
normalizeProviderError,
ProviderError,
} from './provider-error';
import {
ValidatedHttpClient,
WebBackendHttpClient,
} from './validated-http-client';
import { ProviderRequestError } from './provider-request-error';
import {
ProviderUrlPolicy,
providerUrlErrorBody,
resolveHostname,
validateProviderUrl,
} from './provider-url-policy';
export type {
WebBackendHttpClient,
WebBackendHttpGetOptions,
} from './validated-http-client';
interface PlaylistParseError {
readonly message: string;
readonly status: number;
readonly code?: string;
}
export interface WebBackendAppOptions {
readonly allowPrivateNetworkTargets?: boolean;
readonly clientOrigins?: string[];
readonly guid?: () => string;
/**
* Per-host circuit breaker for the proxy routes. One per app, so a test can
* drive it with a fake clock the same way `now` and `guid` are injected.
*/
readonly hostGuard?: HostConnectivityGuard;
readonly httpClient?: WebBackendHttpClient;
readonly now?: () => Date;
readonly resolveHostname?: (hostname: string) => Promise<readonly string[]>;
readonly runtimeBackendUrl?: string;
}
type ProviderTargetRegistry = Map<string, URL>;
export function createWebBackendApp(
options: WebBackendAppOptions = {}
): Express {
const app = express();
const guid = options.guid ?? createGuid;
const now = options.now ?? (() => new Date());
const hostGuard =
options.hostGuard ??
new HostConnectivityGuard({
// Host and port only — the provider URL's query string routinely
// carries Xtream credentials and must never reach a log.
onOpen: (host) =>
console.warn(
`[web-backend] ${host} is not answering; skipping requests to it for a short while`
),
});
const clientOrigins = options.clientOrigins ?? getClientOrigins();
const runtimeBackendUrl =
options.runtimeBackendUrl ?? process.env['BACKEND_URL'] ?? '/api';
const providerUrlPolicy: ProviderUrlPolicy = {
allowPrivateNetworkTargets:
options.allowPrivateNetworkTargets ??
isPrivateNetworkProxyAllowed(),
resolveHostname: options.resolveHostname ?? resolveHostname,
};
const httpClient = new ValidatedHttpClient(
providerUrlPolicy,
options.httpClient
);
const providerTargets: ProviderTargetRegistry = new Map();
const corsMiddleware = cors({
origin(origin, callback) {
if (
!origin ||
clientOrigins.includes('*') ||
clientOrigins.includes(origin)
) {
callback(null, true);
return;
}
callback(null, false);
},
optionsSuccessStatus: 200,
});
app.get('/', (_req, res) => res.send('IPTVnator web backend'));
app.get('/health', (_req, res) =>
res.json({ status: 'ok', service: 'iptvnator-web-backend' })
);
app.get('/config.js', corsMiddleware, (_req, res) => {
const config = JSON.stringify({ BACKEND_URL: runtimeBackendUrl });
res.type('application/javascript').send(
`window.__IPTVNATOR_CONFIG__ = Object.assign({}, window.__IPTVNATOR_CONFIG__, ${config});\n`
);
});
app.options('/provider-targets', corsMiddleware);
app.post(
'/provider-targets',
corsMiddleware,
express.json({ limit: '16kb' }),
async (req, res) => {
const rawUrl =
req.body &&
typeof req.body === 'object' &&
'url' in req.body &&
typeof req.body.url === 'string'
? req.body.url
: undefined;
if (!rawUrl) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
const result = await validateProviderUrl(rawUrl, providerUrlPolicy);
if ('message' in result) {
res.status(result.status).json(providerUrlErrorBody(result));
return;
}
const targetId = createProviderTargetId(result.url);
providerTargets.set(targetId, result.url);
res.json({ targetId });
}
);
app.options('/connectivity-guard/reset', corsMiddleware);
app.post(
'/connectivity-guard/reset',
corsMiddleware,
express.json({ limit: '4kb' }),
(req, res) => {
// Takes the raw provider URL rather than a registered targetId:
// callers reset a host precisely when its address may have changed,
// which is before any target exists for it. Nothing is fetched
// here — only the host is read, so there is no SSRF surface — and
// the URL is never logged, since its query string carries the
// Xtream credentials.
const rawUrl =
req.body &&
typeof req.body === 'object' &&
'url' in req.body &&
typeof req.body.url === 'string'
? req.body.url
: undefined;
if (!rawUrl) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
res.json({ reset: resetProviderHost(hostGuard, rawUrl) });
}
);
app.get('/parse', corsMiddleware, async (req, res) => {
const url = getRegisteredProviderUrl(req, res, providerTargets);
if (!url) {
return;
}
// Deliberately unguarded, matching Electron, where the breaker is
// wired into the two portal IPC handlers and not into the playlist or
// EPG download path. See "Scope" in the contract doc: a download is one
// request rather than a catalog fan-out, it is usually the direct
// result of the user asking for it, and it can legitimately run far
// longer than any portal call.
const result = await handlePlaylistParse({
guid,
httpClient,
now,
url: url.href,
userAgent:
typeof req.query.userAgent === 'string'
? req.query.userAgent.trim() || undefined
: undefined,
});
if (isPlaylistParseError(result)) {
res.status(result.status).json(result);
return;
}
res.json(result);
});
app.get('/parse-xml', corsMiddleware, async (req, res) => {
const url = getRegisteredProviderUrl(req, res, providerTargets);
if (!url) {
return;
}
// Unguarded for the same reasons as /parse above.
try {
const result = await fetchEpgDataFromUrl(httpClient, url);
if (!result) {
res.status(500).json({
message: 'Error, something went wrong',
status: 500,
});
return;
}
res.json(result);
} catch (error) {
logProviderRequestFailure({ error, route: '/parse-xml', url });
const providerError = normalizeProviderError(error);
res.status(providerError.status).json(providerError);
}
});
app.get('/xtream', corsMiddleware, async (req, res) => {
const registeredUrl = getRegisteredProviderUrl(
req,
res,
providerTargets
);
if (!registeredUrl) {
return;
}
const url = new URL(registeredUrl.href);
let guardToken: HostRequestToken | null = null;
// The URL actually requested, so a failure on a redirect hop can be
// told apart from a failure of the endpoint we guarded.
let requestUrl: string | undefined;
// Whether the provider accepted the TCP connection: a timeout after
// that is a slow provider, not a dead one.
let connected = false;
try {
// Before URL validation, not after: that step resolves the hostname
// over DNS, and a dead host is exactly where DNS is slow or failing
// too. Checking first means an open breaker answers immediately
// with the fast-fail the caller expects, instead of paying for a
// lookup and then reporting an unrelated "host could not be
// resolved". Safe to key on the registered URL because
// `normalizeXtreamServerUrl` rebuilds from `url.origin`, so
// normalization can change the path but never the host.
const admission = admitProviderRequest(hostGuard, url.href);
if (!admission.allowed) {
// This route answers provider failures with HTTP 200 and an
// error body; a fast-fail is one of them.
res.json(admission.error);
return;
}
guardToken = admission.token;
url.href = normalizeXtreamServerUrl(url.href);
requestUrl = appendPathSegment(url, 'player_api.php');
const response = await httpClient.get(requestUrl, {
params: getProxyParams(req, ['targetId', 'connectionTest']),
timeout: PROVIDER_REQUEST_TIMEOUT_MS.xtream,
onConnect: () => {
connected = true;
reportProviderRequestConnected(hostGuard, guardToken);
},
});
reportProviderRequestSuccess(hostGuard, guardToken);
guardToken = null;
res.json({
action: getQueryString(req, 'action'),
payload: response.data,
});
} catch (error) {
reportProviderRequestFailure(hostGuard, guardToken, error, {
requestUrl,
connected,
});
logProviderRequestFailure({ error, route: '/xtream', url });
if (getQueryString(req, 'connectionTest') === 'true') {
const wrapped =
error instanceof ProviderRequestError ? error : null;
res.json({
connectionFailure: wrapped?.policyError
? { kind: 'connection', canTryHttp: false }
: describeXtreamConnectionFailure(
wrapped?.cause ?? error,
wrapped?.initialResponded ?? true
),
});
} else {
res.json(normalizeProviderError(error));
}
} finally {
releaseProviderRequest(hostGuard, guardToken);
}
});
app.get('/stalker', corsMiddleware, async (req, res) => {
const url = getRegisteredProviderUrl(req, res, providerTargets);
const macAddress = getQueryString(req, 'macAddress');
const token = getQueryString(req, 'token');
const serialNumber = getQueryString(req, 'serialNumber');
if (!url) {
return;
}
// Endpoint-discovery probes expect most candidates to fail; counting
// them would let discovery declare a slow-but-alive portal unreachable.
const countsTowardsGuard =
getQueryString(req, 'skipConnectionGuard') !== 'true';
let guardToken: HostRequestToken | null = null;
let requestUrl: string | undefined;
// Whether the portal accepted the TCP connection: a timeout after
// that is a slow portal, not a dead one.
let connected = false;
try {
// `macAddress`, `token` and `serialNumber` are portal credentials,
// not protocol content: they reach the portal only as the same
// Cookie / Authorization / SN headers the Electron transport
// sends, never in the portal's query string (which lands in
// portal and intermediary access logs). The one protocol
// exception is `handshake`, which presents its candidate token as
// a query param and is answered without authentication.
const params: Record<string, string | number> = getProxyParams(
req,
[
'targetId',
'macAddress',
'token',
'serialNumber',
// Guard control flag, not protocol content — it must never
// reach the portal's query string.
'skipConnectionGuard',
]
);
if (params['action'] === 'handshake' && token) {
params['token'] = token;
}
// Shared with the Electron transport: full STB cookie, MAG
// User-Agent pair, `sn` only on get_profile, `JsHttpRequest`
// defaulting, and the reference `cmd` encoding (raw slashes,
// pre-encoded sequences untouched, `&`/`#` still escaped).
const identity = buildStalkerIdentityRequestContext({
macAddress: macAddress ?? '',
params,
...(token ? { token } : {}),
...(serialNumber ? { serialNumber } : {}),
});
const headers = { ...identity.headers };
if (!macAddress) {
// Tolerate credential-less calls the way the route always
// has: no MAC means no session cookie, not an empty `mac=`.
delete headers['Cookie'];
}
requestUrl = buildStalkerRequestUrl(
url.href,
identity.requestParams
);
if (countsTowardsGuard) {
const admission = admitProviderRequest(hostGuard, requestUrl);
if (!admission.allowed) {
// This route answers provider failures with HTTP 200 and
// an error body; a fast-fail is one of them.
res.json(admission.error);
return;
}
guardToken = admission.token;
} else {
// Endpoint discovery: never policed, never counted, but a
// candidate that answers still clears the record.
guardToken = observeProviderRequest(hostGuard, requestUrl);
}
const response = await httpClient.get(requestUrl, {
headers,
// `create_link` gets the longer budget: the portal mints a
// stream URL before it answers.
timeout:
params['action'] === 'create_link'
? PROVIDER_REQUEST_TIMEOUT_MS.stalkerCreateLink
: PROVIDER_REQUEST_TIMEOUT_MS.stalker,
onConnect: () => {
connected = true;
reportProviderRequestConnected(hostGuard, guardToken);
},
});
reportProviderRequestSuccess(hostGuard, guardToken);
guardToken = null;
res.json({
action: getQueryString(req, 'action'),
payload: response.data,
});
} catch (error) {
// Always reported, even for exempt discovery probes: a failure
// carrying an HTTP response proves the endpoint answered, and
// dropping that is what lets the breaker open mid-discovery.
reportProviderRequestFailure(hostGuard, guardToken, error, {
countFailures: countsTowardsGuard,
requestUrl,
connected,
});
logProviderRequestFailure({ error, route: '/stalker', url });
res.json(normalizeProviderError(error));
} finally {
releaseProviderRequest(hostGuard, guardToken);
}
});
return app;
}
function getRegisteredProviderUrl(
req: Request,
res: Response,
providerTargets: ProviderTargetRegistry
): URL | null {
const targetId = getQueryString(req, 'targetId');
if (!targetId) {
res.status(400).json({ message: 'Missing targetId', status: 400 });
return null;
}
const targetUrl = providerTargets.get(targetId);
if (!targetUrl) {
res.status(404).json({
message: 'Provider target not found',
status: 404,
});
return null;
}
return targetUrl;
}
function createProviderTargetId(url: URL): string {
return createHash('sha256').update(url.href).digest('hex');
}
function isPrivateNetworkProxyAllowed(): boolean {
const value = process.env['IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS'];
return value === '1' || value === 'true';
}
function getClientOrigins(): string[] {
const configured = process.env['CLIENT_URL']
?.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
if (configured?.length) {
return configured;
}
// Production default matches the documented self-hosted setup
// (docker/docker-compose.yml maps the PWA to port 4333). The Docker image
// sets CLIENT_URL explicitly; this fallback only covers manual runs.
return process.env['NODE_ENV'] === 'development' ||
process.env['NODE_ENV'] === 'dev'
? ['http://localhost:4200']
: ['http://localhost:4333'];
}
function getQueryString(req: Request, key: string): string | undefined {
const value = req.query[key];
if (Array.isArray(value)) {
return normalizeQueryValue(value[0]);
}
return normalizeQueryValue(value);
}
function normalizeQueryValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function getProxyParams(
req: Request,
excludedKeys: string[]
): Record<string, string> {
const excluded = new Set(excludedKeys);
const params: Record<string, string> = {};
for (const [key, value] of Object.entries(req.query)) {
if (excluded.has(key)) {
continue;
}
const normalized = Array.isArray(value)
? normalizeQueryValue(value[0])
: normalizeQueryValue(value);
if (normalized) {
params[key] = normalized;
}
}
return params;
}
function appendPathSegment(url: URL, segment: string): string {
const nextUrl = new URL(url.href);
nextUrl.pathname = `${nextUrl.pathname.replace(/\/+$/, '')}/${segment}`;
nextUrl.search = '';
nextUrl.hash = '';
return nextUrl.href;
}
async function handlePlaylistParse(options: {
readonly guid: () => string;
readonly httpClient: WebBackendHttpClient;
readonly now: () => Date;
readonly url: string;
readonly userAgent?: string;
}): Promise<Record<string, unknown> | PlaylistParseError> {
try {
const response = await options.httpClient.get<string>(options.url, {
timeout: PROVIDER_REQUEST_TIMEOUT_MS.playlist,
...(options.userAgent
? { headers: { 'User-Agent': options.userAgent } }
: {}),
});
const parsedPlaylist = parsePlaylist(response.data);
const title = getLastUrlSegment(options.url);
return {
...createPlaylistObject({
guid: options.guid,
now: options.now,
playlist: parsedPlaylist,
title,
url: options.url,
}),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
};
} catch (error) {
logProviderRequestFailure({ error, route: '/parse', url: options.url });
if (error instanceof ProviderRequestError && error.policyError) {
return providerUrlErrorBody(error.policyError);
}
const providerError = (
error instanceof ProviderRequestError ? error.cause : error
) as ProviderError;
if (providerError?.response?.statusText !== undefined) {
return {
status: providerError.response.status ?? 500,
message: providerError.response.statusText,
};
}
const code = collectProviderErrorCodes(providerError)[0];
return {
status: providerError?.response?.status ?? 500,
message: code
? `Error, something went wrong (${code})`
: 'Error, something went wrong',
...(code ? { code } : {}),
};
}
}
async function fetchEpgDataFromUrl(
httpClient: WebBackendHttpClient,
url: URL
): Promise<unknown> {
const href = url.href;
const response = await httpClient.get<ArrayBuffer | string>(href, {
timeout: PROVIDER_REQUEST_TIMEOUT_MS.epg,
...(url.pathname.endsWith('.gz')
? { responseType: 'arraybuffer' }
: {}),
});
const xml = url.pathname.endsWith('.gz')
? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString()
: response.data.toString();
return epgParser.parse(xml);
}
function isPlaylistParseError(
result: Record<string, unknown> | PlaylistParseError
): result is PlaylistParseError {
return (
typeof (result as PlaylistParseError).status === 'number' &&
typeof (result as PlaylistParseError).message === 'string'
);
}
function parsePlaylist(playlist: string): {
items: Array<Record<string, unknown>>;
} {
return parser.parse(playlist) as unknown as {
items: Array<Record<string, unknown>>;
};
}
function createPlaylistObject(options: {
readonly guid: () => string;
readonly now: () => Date;
readonly playlist: { items: Array<Record<string, unknown>> };
readonly title: string;
readonly url: string;
}): Record<string, unknown> {
const timestamp = options.now().toISOString();
const id = options.guid();
return {
id,
_id: id,
filename: options.title,
title: options.title,
count: options.playlist.items.length,
playlist: {
...options.playlist,
items: options.playlist.items.map((item) => {
// Keep this builder aligned with the shared
// createPlaylistObject() in @iptvnator/shared/m3u-utils:
// KODIPROP ClearKey DRM must survive the /parse URL-import
// path too.
const drm = extractDrmFromRaw(
typeof item['raw'] === 'string' ? item['raw'] : undefined
);
return {
id: options.guid(),
...item,
...(drm ? { drm } : {}),
};
}),
},
importDate: timestamp,
lastUsage: timestamp,
favorites: [],
autoRefresh: false,
url: options.url,
};
}
function getLastUrlSegment(value: string): string {
const segment = value.slice(value.lastIndexOf('/') + 1).trim();
return segment.length > 0 ? segment : 'Playlist without title';
}
function createGuid(): string {
return Math.random().toString(36).slice(2);
}