mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
* 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>
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import { classifyHostRequestFailure } from '@iptvnator/shared/host-health';
|
|
import { ProviderRequestError } from './provider-request-error';
|
|
import { ProviderAxiosTransport } from './provider-axios-transport';
|
|
import express from 'express';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createServer, Agent as HttpsAgent } from 'node:https';
|
|
import { Server } from 'node:http';
|
|
import { AddressInfo } from 'node:net';
|
|
import { TLSSocket } from 'node:tls';
|
|
import { gzipSync } from 'node:zlib';
|
|
import {
|
|
ValidatedHttpClient,
|
|
WebBackendHttpClient,
|
|
} from './validated-http-client';
|
|
import { withServer } from './web-backend-app.spec-helpers';
|
|
|
|
const lan = {
|
|
allowPrivateNetworkTargets: true,
|
|
resolveHostname: async () => ['127.0.0.1'],
|
|
};
|
|
|
|
async function withListeningServer<T>(
|
|
server: Server,
|
|
hostname: string,
|
|
run: (port: number) => Promise<T>
|
|
): Promise<T> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, hostname, resolve);
|
|
});
|
|
try {
|
|
return await run((server.address() as AddressInfo).port);
|
|
} finally {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('real axios validated transport', () => {
|
|
it('uses exactly the resolved address at connect time, preserves Host, and bypasses ambient proxy settings', async () => {
|
|
const proxy = express().use((_req, res) =>
|
|
res.status(500).send('unexpected proxy')
|
|
);
|
|
await withServer(proxy, async (proxyUrl) => {
|
|
const provider = express().use((req, res) =>
|
|
res.json({ host: req.headers.host })
|
|
);
|
|
await withServer(provider, async (providerUrl) => {
|
|
const { port } = new URL(providerUrl);
|
|
const resolveHostname = jest
|
|
.fn()
|
|
.mockResolvedValueOnce(['127.0.0.1'])
|
|
.mockResolvedValue(['127.0.0.2']);
|
|
const previous = process.env['http_proxy'];
|
|
process.env['http_proxy'] = proxyUrl;
|
|
try {
|
|
const response = await new ValidatedHttpClient({
|
|
...lan,
|
|
resolveHostname,
|
|
}).get(`http://provider.example:${port}/`);
|
|
expect(response.data).toEqual({
|
|
host: `provider.example:${port}`,
|
|
});
|
|
expect(resolveHostname).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
if (previous === undefined)
|
|
delete process.env['http_proxy'];
|
|
else process.env['http_proxy'] = previous;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
it('uses the new validated DNS answer on each same-host redirect instead of a pooled socket', async () => {
|
|
const provider = express().use((_req, res) => res.redirect('/next'));
|
|
await withServer(provider, async (providerUrl) => {
|
|
const { port } = new URL(providerUrl);
|
|
const resolveHostname = jest
|
|
.fn()
|
|
.mockResolvedValueOnce(['127.0.0.1'])
|
|
.mockResolvedValueOnce(['127.0.0.2']);
|
|
await expect(
|
|
new ValidatedHttpClient({ ...lan, resolveHostname }).get(
|
|
`http://provider.example:${port}/`,
|
|
{ timeout: 500 }
|
|
)
|
|
).rejects.toMatchObject({
|
|
initialResponded: true,
|
|
cause: {
|
|
code: expect.stringMatching(
|
|
/^(ECONNREFUSED|ECONNABORTED)$/
|
|
),
|
|
},
|
|
});
|
|
expect(resolveHostname).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
it.each(['invalid-gzip', 'unfinished'])(
|
|
'discards the %s redirect body immediately',
|
|
async (kind) => {
|
|
const provider = express();
|
|
provider.get('/start', (_req, res) => {
|
|
res.status(302).set('Location', '/final');
|
|
if (kind === 'invalid-gzip')
|
|
res.set('Content-Encoding', 'gzip').end('not gzip');
|
|
else res.write('a body that never ends');
|
|
});
|
|
provider.get('/final', (_req, res) => res.json({ ok: true }));
|
|
await withServer(provider, async (url) => {
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(`${url}/start`, {
|
|
timeout: 500,
|
|
})
|
|
).resolves.toMatchObject({ data: { ok: true } });
|
|
});
|
|
}
|
|
);
|
|
it('preserves double-slash paths and escaped segments without changing connection authority', async () => {
|
|
const provider = express().use((req, res) =>
|
|
res.json({ path: req.url, host: req.headers.host })
|
|
);
|
|
await withServer(provider, async (url) => {
|
|
const target = new URL(url);
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(
|
|
`${url}//other.example/a%2Fb?token=a%2Bb`
|
|
)
|
|
).resolves.toMatchObject({
|
|
data: {
|
|
path: '//other.example/a%2Fb?token=a%2Bb',
|
|
host: target.host,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
it('keeps query serialization and sends only Location query on a real redirect', async () => {
|
|
const requests: string[] = [];
|
|
const provider = express().use((req, res) => {
|
|
requests.push(req.url);
|
|
if (requests.length === 1) res.redirect('/final?ticket=issued');
|
|
else res.send('done');
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
await new ValidatedHttpClient(lan).get(`${url}/start?fixed=yes`, {
|
|
params: { password: 'a b+c' },
|
|
});
|
|
expect(requests).toEqual([
|
|
'/start?fixed=yes&password=a+b%2Bc',
|
|
'/final?ticket=issued',
|
|
]);
|
|
});
|
|
});
|
|
it('retains final arraybuffer, decompression, BOM text and JSON behavior', async () => {
|
|
const provider = express();
|
|
provider.get('/binary', (_req, res) =>
|
|
res.end(Buffer.from([0, 255, 128]))
|
|
);
|
|
provider.get('/compressed', (_req, res) =>
|
|
res.set('Content-Encoding', 'gzip').end(gzipSync('{"ok":true}'))
|
|
);
|
|
provider.get('/text', (_req, res) => res.end('\uFEFFhello'));
|
|
await withServer(provider, async (url) => {
|
|
const client = new ValidatedHttpClient(lan);
|
|
expect(
|
|
(
|
|
await client.get(`${url}/binary`, {
|
|
responseType: 'arraybuffer',
|
|
})
|
|
).data
|
|
).toEqual(Buffer.from([0, 255, 128]));
|
|
expect((await client.get(`${url}/compressed`)).data).toEqual({
|
|
ok: true,
|
|
});
|
|
expect((await client.get(`${url}/text`)).data).toEqual('hello');
|
|
});
|
|
});
|
|
it('cancels an unfinished final body and closes its connection', async () => {
|
|
const controller = new AbortController();
|
|
let started!: () => void;
|
|
const bodyStarted = new Promise<void>((resolve) => (started = resolve));
|
|
const provider = express().use((_req, res) => {
|
|
res.write('unfinished');
|
|
started();
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
const pending = new ValidatedHttpClient(lan).get(url, {
|
|
signal: controller.signal,
|
|
});
|
|
await bodyStarted;
|
|
controller.abort();
|
|
await expect(pending).rejects.toMatchObject({
|
|
cause: { code: 'ERR_CANCELED' },
|
|
});
|
|
});
|
|
});
|
|
it.each(['truncated', 'invalid-gzip', 'timeout'])(
|
|
'retains response evidence for a %s final body',
|
|
async (kind) => {
|
|
const provider = express().use((_req, res) => {
|
|
if (kind === 'invalid-gzip') {
|
|
res.set('Content-Encoding', 'gzip').end('not gzip');
|
|
return;
|
|
}
|
|
res.set('Content-Length', '100').write('short');
|
|
if (kind === 'truncated') setTimeout(() => res.destroy(), 10);
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
const error = await new ValidatedHttpClient(lan)
|
|
.get(url, { timeout: 100 })
|
|
.catch((error: unknown) => error);
|
|
expect(error).toBeInstanceOf(ProviderRequestError);
|
|
const failure = error as ProviderRequestError;
|
|
expect(failure.cause).toMatchObject({
|
|
response: { status: 200, statusText: 'OK' },
|
|
});
|
|
expect(classifyHostRequestFailure(failure.cause)).toBe(
|
|
'responded'
|
|
);
|
|
});
|
|
}
|
|
);
|
|
it('times out before headers on the custom native transport', async () => {
|
|
const provider = express().use(() => {
|
|
/* Deliberately silent synthetic provider. */
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(url, { timeout: 100 })
|
|
).rejects.toMatchObject({
|
|
initialResponded: false,
|
|
cause: { code: 'ECONNABORTED' },
|
|
});
|
|
});
|
|
});
|
|
it('reports the accepted connection of a provider that then never answers', async () => {
|
|
const provider = express().use(() => {
|
|
/* Deliberately silent synthetic provider. */
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
const onConnect = jest.fn();
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(url, {
|
|
timeout: 100,
|
|
onConnect,
|
|
})
|
|
).rejects.toMatchObject({ cause: { code: 'ECONNABORTED' } });
|
|
expect(onConnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('reports nothing for a provider that refuses the connection', async () => {
|
|
// A listener that has just closed: nothing accepts on that port.
|
|
const url = await withServer(express(), async (url) => url);
|
|
const onConnect = jest.fn();
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(url, { timeout: 1_000, onConnect })
|
|
).rejects.toMatchObject({ cause: { code: 'ECONNREFUSED' } });
|
|
expect(onConnect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not cap a healthy trickling body at the inactivity timeout', async () => {
|
|
const provider = express().use((_req, res) => {
|
|
let count = 0;
|
|
res.write('start');
|
|
const interval = setInterval(() => {
|
|
res.write('x');
|
|
if (++count === 8) {
|
|
clearInterval(interval);
|
|
res.end();
|
|
}
|
|
}, 25);
|
|
res.on('close', () => clearInterval(interval));
|
|
});
|
|
await withServer(provider, async (url) => {
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(url, { timeout: 100 })
|
|
).resolves.toMatchObject({ data: 'startxxxxxxxx' });
|
|
});
|
|
});
|
|
it('preserves TLS SNI and hostname verification with a pinned address', async () => {
|
|
// This key/certificate is exclusively a synthetic test fixture.
|
|
const cert = readFileSync(`${__dirname}/testing/provider-test.pem`);
|
|
const key = readFileSync(`${__dirname}/testing/provider-test.key`);
|
|
let requests = 0;
|
|
const server = createServer({ key, cert }, (req, res) => {
|
|
requests++;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(
|
|
JSON.stringify({
|
|
host: req.headers.host,
|
|
sni: (req.socket as TLSSocket & { servername: string })
|
|
.servername,
|
|
})
|
|
);
|
|
});
|
|
const trustedTransport: WebBackendHttpClient = {
|
|
get: (url, options) => {
|
|
const agent = options?.httpsAgent as HttpsAgent & {
|
|
options: { ca?: Buffer };
|
|
};
|
|
// Supply the local test CA, retaining the production lookup,
|
|
// SNI and rejectUnauthorized defaults on the actual agent.
|
|
agent.options.ca = cert;
|
|
return new ProviderAxiosTransport().get(url, options);
|
|
},
|
|
};
|
|
await withListeningServer(server, '127.0.0.1', async (port) => {
|
|
await expect(
|
|
new ValidatedHttpClient(lan).get(
|
|
`https://provider.example:${port}`
|
|
)
|
|
).rejects.toMatchObject({
|
|
cause: { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' },
|
|
});
|
|
expect(requests).toBe(0);
|
|
const client = new ValidatedHttpClient(lan, trustedTransport);
|
|
await expect(
|
|
client.get(`https://provider.example:${port}`)
|
|
).resolves.toMatchObject({
|
|
data: {
|
|
host: `provider.example:${port}`,
|
|
sni: 'provider.example',
|
|
},
|
|
});
|
|
await expect(
|
|
client.get(`https://wrong.example:${port}`)
|
|
).rejects.toMatchObject({
|
|
cause: { code: 'ERR_TLS_CERT_ALTNAME_INVALID' },
|
|
});
|
|
expect(requests).toBe(1);
|
|
});
|
|
});
|
|
it('connects to a pinned IPv6 address and an IPv6 literal in trusted LAN mode', async () => {
|
|
const server = new Server((_req, res) => res.end('ipv6'));
|
|
await withListeningServer(server, '::1', async (port) => {
|
|
const client = new ValidatedHttpClient({
|
|
...lan,
|
|
resolveHostname: async () => ['::1'],
|
|
});
|
|
expect(
|
|
(await client.get(`http://provider.example:${port}/`)).data
|
|
).toBe('ipv6');
|
|
expect((await client.get(`http://[::1]:${port}/`)).data).toBe(
|
|
'ipv6'
|
|
);
|
|
});
|
|
});
|
|
});
|