mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(web-backend): separate provider metadata from connection authority
This commit is contained in:
1 parent
2da2884ba2
commit
a57b516011
9 files changed
+200
-52
No files matched your search
@@ -191,6 +191,7 @@ export function reportProviderRequestFailure(
|
||||
return;
|
||||
}
|
||||
|
||||
const manualChain = error instanceof ProviderRequestError;
|
||||
if (error instanceof ProviderRequestError) {
|
||||
if (error.initialResponded) {
|
||||
guard.reportSuccess(token);
|
||||
@@ -207,7 +208,10 @@ export function reportProviderRequestFailure(
|
||||
// other response — otherwise an ordinary timeout, a probe that was
|
||||
// redirected to a dead destination, and another ordinary timeout
|
||||
// still read as two consecutive failures.
|
||||
if (failedAfterRedirect(error, token, options.requestUrl)) {
|
||||
if (
|
||||
!manualChain &&
|
||||
failedAfterRedirect(error, token, options.requestUrl)
|
||||
) {
|
||||
guard.reportSuccess(token);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
request as httpRequest,
|
||||
ClientRequest,
|
||||
IncomingMessage,
|
||||
RequestOptions,
|
||||
} from 'node:http';
|
||||
import {
|
||||
request as httpsRequest,
|
||||
Agent as HttpsAgent,
|
||||
AgentOptions,
|
||||
} from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import { checkServerIdentity } from 'node:tls';
|
||||
import type {
|
||||
ProviderTransportOptions,
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpResponse,
|
||||
} from './validated-http-client';
|
||||
|
||||
/**
|
||||
* Keep HTTP authority metadata separate from socket destination selection.
|
||||
* Only the hop's pinned agent can select an address; axios receives a fixed
|
||||
* logical hostname, never a user-selected connection authority. Host and TLS
|
||||
* identity still describe the validated provider, including IP certificates.
|
||||
*/
|
||||
export class ProviderAxiosTransport implements WebBackendHttpClient {
|
||||
async get<T>(
|
||||
url: string,
|
||||
options: ProviderTransportOptions = {}
|
||||
): Promise<WebBackendHttpResponse<T>> {
|
||||
const target = new URL(axios.getUri({ url, params: options.params }));
|
||||
const secure = target.protocol === 'https:';
|
||||
const hostname = target.hostname.replace(/^\[|\]$/g, '');
|
||||
const port = Number(target.port || (secure ? 443 : 80));
|
||||
const root = secure
|
||||
? 'https://provider.invalid/'
|
||||
: 'http://provider.invalid/';
|
||||
if (!options.httpAgent || !options.httpsAgent) {
|
||||
throw new Error('Provider transport requires pinned agents');
|
||||
}
|
||||
const agent = options.httpsAgent as HttpsAgent & {
|
||||
options: AgentOptions;
|
||||
};
|
||||
agent.options.servername = isIP(hostname) ? '' : hostname;
|
||||
agent.options.checkServerIdentity = (_name, certificate) =>
|
||||
checkServerIdentity(hostname, certificate);
|
||||
const response = await axios.get<T>(root, {
|
||||
...options,
|
||||
params: undefined,
|
||||
transport: {
|
||||
request: (
|
||||
requestOptions: RequestOptions,
|
||||
callback: (response: IncomingMessage) => void
|
||||
) => {
|
||||
// Path is HTTP metadata, never parsed as an authority.
|
||||
// Even //host/path remains a path on the pinned connection.
|
||||
const request = (secure ? httpsRequest : httpRequest)(
|
||||
{
|
||||
...requestOptions,
|
||||
hostname: 'provider.invalid',
|
||||
port,
|
||||
path: target.pathname + target.search,
|
||||
},
|
||||
callback
|
||||
);
|
||||
retainHeaderTimeout(request, options.timeout);
|
||||
return request;
|
||||
},
|
||||
},
|
||||
headers: { ...options.headers, Host: target.host },
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
headers: {
|
||||
location:
|
||||
typeof response.headers['location'] === 'string'
|
||||
? response.headers['location']
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Axios identifies native transports by identity; a custom one needs this timer. */
|
||||
function retainHeaderTimeout(request: ClientRequest, timeout?: number): void {
|
||||
if (!timeout) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
request.destroy(
|
||||
Object.assign(
|
||||
new Error('Provider response headers timed out'),
|
||||
{ code: 'ECONNABORTED' }
|
||||
)
|
||||
),
|
||||
timeout
|
||||
);
|
||||
timer.unref();
|
||||
const clear = () => {
|
||||
clearTimeout(timer);
|
||||
request.removeListener('response', clear);
|
||||
request.removeListener('error', clear);
|
||||
request.removeListener('close', clear);
|
||||
};
|
||||
request.once('response', clear);
|
||||
request.once('error', clear);
|
||||
request.once('close', clear);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { classifyHostRequestFailure } from '@iptvnator/shared/host-health';
|
||||
import { ProviderRequestError } from './provider-request-error';
|
||||
import axios from 'axios';
|
||||
import { ProviderAxiosTransport } from './provider-axios-transport';
|
||||
import express from 'express';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer, Agent as HttpsAgent } from 'node:https';
|
||||
@@ -116,6 +116,24 @@ describe('real axios validated transport', () => {
|
||||
});
|
||||
}
|
||||
);
|
||||
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) => {
|
||||
@@ -202,6 +220,19 @@ describe('real axios validated transport', () => {
|
||||
});
|
||||
}
|
||||
);
|
||||
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('does not cap a healthy trickling body at the inactivity timeout', async () => {
|
||||
const provider = express().use((_req, res) => {
|
||||
let count = 0;
|
||||
@@ -245,7 +276,7 @@ describe('real axios validated transport', () => {
|
||||
// Supply the local test CA, retaining the production lookup,
|
||||
// SNI and rejectUnauthorized defaults on the actual agent.
|
||||
agent.options.ca = cert;
|
||||
return axios.get(url, options);
|
||||
return new ProviderAxiosTransport().get(url, options);
|
||||
},
|
||||
};
|
||||
await withListeningServer(server, '127.0.0.1', async (port) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProviderAxiosTransport } from './provider-axios-transport';
|
||||
import {
|
||||
discardProviderBody,
|
||||
discardProviderErrorBody,
|
||||
@@ -60,7 +61,7 @@ const SENSITIVE_HEADERS = new Set([
|
||||
export class ValidatedHttpClient implements WebBackendHttpClient {
|
||||
constructor(
|
||||
private readonly policy: ProviderUrlPolicy,
|
||||
private readonly transport: WebBackendHttpClient = axios
|
||||
private readonly transport: WebBackendHttpClient = new ProviderAxiosTransport()
|
||||
) {}
|
||||
|
||||
async get<T>(
|
||||
|
||||
@@ -492,21 +492,11 @@ describe('web backend host connectivity guard', () => {
|
||||
});
|
||||
|
||||
it('does not fast-fail a provider whose redirect destination is dead', async () => {
|
||||
// The shape this route actually produces, verified against axios
|
||||
// 1.19.0: follow-redirects walks the chain inside one `get()`, so
|
||||
// `config` still holds the URL we asked for and only
|
||||
// `request._currentUrl` names the hop that failed. Reading `config.url`
|
||||
// alone would compare the original URL with itself, find no redirect,
|
||||
// and charge the dead destination to the provider that answered.
|
||||
const redirectedFailure = () =>
|
||||
Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED',
|
||||
config: { url: 'http://xtream.example/player_api.php' },
|
||||
request: { _currentUrl: 'http://cdn.dead.example/stream' },
|
||||
});
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueNetworkError(redirectedFailure());
|
||||
httpClient.queueNetworkError(redirectedFailure());
|
||||
for (let i = 0; i < 2; i++) {
|
||||
httpClient.queueRedirect('http://cdn.dead.example/stream');
|
||||
httpClient.queueNetworkError(hostLevelFailure());
|
||||
}
|
||||
httpClient.queueResponse({ user_info: { username: 'demo' } });
|
||||
const { guard } = createTestGuard();
|
||||
|
||||
@@ -534,7 +524,7 @@ describe('web backend host connectivity guard', () => {
|
||||
action: 'get_account_info',
|
||||
payload: { user_info: { username: 'demo' } },
|
||||
});
|
||||
expect(httpClient.requests).toHaveLength(3);
|
||||
expect(httpClient.requests).toHaveLength(5);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -689,35 +679,32 @@ describe('web backend host connectivity guard', () => {
|
||||
const started = new Promise<void>((resolve) => {
|
||||
arrived = resolve;
|
||||
});
|
||||
const transport = jest
|
||||
.spyOn(httpClient, 'get')
|
||||
.mockImplementationOnce(async () => {
|
||||
arrived();
|
||||
await pending;
|
||||
if (outcome !== 'success') {
|
||||
throw Object.assign(
|
||||
hostLevelFailure(
|
||||
outcome === 'cancel'
|
||||
? 'ERR_CANCELED'
|
||||
: 'ETIMEDOUT'
|
||||
),
|
||||
{
|
||||
request: {
|
||||
_currentUrl:
|
||||
outcome ===
|
||||
'redirect-failure'
|
||||
? 'http://cdn.example/slow'
|
||||
: `http://portal.example/${route === 'xtream' ? 'player_api.php' : ''}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
data: [] as never,
|
||||
status: 200,
|
||||
headers: {},
|
||||
};
|
||||
});
|
||||
const transport = jest.spyOn(httpClient, 'get');
|
||||
if (outcome === 'redirect-failure') {
|
||||
transport.mockImplementationOnce(async () => ({
|
||||
data: '' as never,
|
||||
status: 302,
|
||||
headers: {
|
||||
location: 'http://cdn.example/slow',
|
||||
},
|
||||
}));
|
||||
}
|
||||
transport.mockImplementationOnce(async () => {
|
||||
arrived();
|
||||
await pending;
|
||||
if (outcome !== 'success') {
|
||||
throw hostLevelFailure(
|
||||
outcome === 'cancel'
|
||||
? 'ERR_CANCELED'
|
||||
: 'ETIMEDOUT'
|
||||
);
|
||||
}
|
||||
return {
|
||||
data: [] as never,
|
||||
status: 200,
|
||||
headers: {},
|
||||
};
|
||||
});
|
||||
const trial = call();
|
||||
try {
|
||||
await started;
|
||||
@@ -733,7 +720,9 @@ describe('web backend host connectivity guard', () => {
|
||||
).message
|
||||
)
|
||||
).toBe(true);
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(transport).toHaveBeenCalledTimes(
|
||||
outcome === 'redirect-failure' ? 2 : 1
|
||||
);
|
||||
} finally {
|
||||
settle();
|
||||
await trial;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"sourceRoot": "apps/web-e2e/src",
|
||||
"implicitDependencies": [
|
||||
"web",
|
||||
"web-backend",
|
||||
"stalker-mock-server",
|
||||
"xtream-mock-server"
|
||||
],
|
||||
|
||||
@@ -146,7 +146,9 @@ policy refusals remain inconclusive. Error bodies never serialize that cause.
|
||||
path, not the query: axios `params` can append credentials absent from the
|
||||
caller's baseline. Unknown/unparseable URLs conservatively count as ordinary
|
||||
failures, and query-only redirects cannot be distinguished by this fallback.
|
||||
The shared helper and Electron behavior are unchanged by the web-backend fix.
|
||||
Wrapped web-backend requests use their explicit chain evidence and never infer
|
||||
redirects from the transport's fixed logical hostname. The shared helper and
|
||||
Electron behavior are unchanged by the web-backend fix.
|
||||
|
||||
Known gap: the failing hop is not guarded either (it has no token of its own),
|
||||
so a permanently broken redirect chain keeps costing a full timeout.
|
||||
|
||||
@@ -27,6 +27,12 @@ Do not invent a `test`, `build`, or `e2e` target because a similarly named
|
||||
project has one. Run affected lint/test/build targets that exist and the closest
|
||||
available E2E target for the changed behavior.
|
||||
|
||||
E2E applications must declare runtime dependencies even when they use HTTP
|
||||
instead of TypeScript imports. `web-e2e` includes `web-backend` in
|
||||
`implicitDependencies` so provider-proxy changes invalidate cached self-hosted
|
||||
PWA tests; starting the backend through a `serve` dependency alone does not
|
||||
make its source files inputs to the test hash.
|
||||
|
||||
## Nx Dependency Updates
|
||||
|
||||
Keep `nx` and every official `@nx/*` package on the same exact version. Run
|
||||
|
||||
@@ -166,8 +166,14 @@ chain; it still requires HTTP(S), no URL userinfo and concrete DNS addresses.
|
||||
|
||||
Fresh HTTP/HTTPS agents pin socket lookup to the exact validated IPv4/IPv6
|
||||
answer set for that hop. No second DNS query or pooled connection may substitute
|
||||
an unvalidated address. The original hostname remains in the URL for Host, TLS
|
||||
SNI and certificate hostname checks. Axios proxies and Node environment proxies
|
||||
an unvalidated address. The default axios transport uses a fixed logical hostname (`provider.invalid`)
|
||||
so its connection authority cannot come from user-controlled URL metadata;
|
||||
the pinned lookup alone selects an IP. Its native request-options adapter sets
|
||||
`path` separately, so even `//host/path` cannot replace the connection authority.
|
||||
It also owns the deadline from dispatch through response headers, since axios's
|
||||
built-in connection timer only covers its own native transport objects. The original provider host and port are
|
||||
explicitly preserved in Host, TLS SNI and certificate identity checks (literal
|
||||
IPs omit SNI but still verify the original IP). Axios proxies and Node environment proxies
|
||||
are disabled for these requests so a proxy cannot independently resolve the
|
||||
origin. Deployments that require an outbound HTTP proxy must use a different
|
||||
network arrangement; setting `HTTP_PROXY`/`HTTPS_PROXY` does not route provider
|
||||
|
||||
Reference in new issue
Block a user