mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-04 23:54:28 -07:00
fix(anime): harden stream proxy and session lifecycle
- Reject non-origin-form proxy targets - Release stale anime browser sessions when senders move
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
findTsSyncOffset,
|
||||
@@ -149,6 +150,25 @@ async function fetchBytes(url: string): Promise<{ status: number; body: Buffer }
|
||||
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
|
||||
}
|
||||
|
||||
async function requestRawTarget(proxyOrigin: string, target: string): Promise<number> {
|
||||
const proxy = new URL(proxyOrigin);
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const socket = net.createConnection(Number(proxy.port), proxy.hostname, () => {
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: ${proxy.host}\r\nConnection: close\r\n\r\n`);
|
||||
});
|
||||
let response = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk) => {
|
||||
response += chunk;
|
||||
const status = /^HTTP\/1\.1 (\d{3})/.exec(response)?.[1];
|
||||
if (!status) return;
|
||||
socket.destroy();
|
||||
resolve(Number(status));
|
||||
});
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('proxy strips the PNG disguise off a segment', async () => {
|
||||
const ts = makeTsPackets(8);
|
||||
const disguised = Buffer.concat([PNG_HEADER, ts]);
|
||||
@@ -184,6 +204,31 @@ test('proxy leaves non-TS bodies alone', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy accepts only origin-form targets for its configured HTTP upstream', async () => {
|
||||
let upstreamHits = 0;
|
||||
await withProxy(
|
||||
(_req, res) => {
|
||||
upstreamHits += 1;
|
||||
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
||||
},
|
||||
async (proxyOrigin, upstreamOrigin) => {
|
||||
const upstream = new URL(upstreamOrigin);
|
||||
const rejectedTargets = [
|
||||
`${upstreamOrigin}/absolute.ts`,
|
||||
`//${upstream.host}/scheme-relative.ts`,
|
||||
'http://127.0.0.1:9/alternate-host.ts',
|
||||
`https://${upstream.host}/https.ts`,
|
||||
];
|
||||
for (const target of rejectedTargets) {
|
||||
assert.equal(await requestRawTarget(proxyOrigin, target), 502, target);
|
||||
}
|
||||
assert.equal(upstreamHits, 0);
|
||||
assert.equal(await requestRawTarget(proxyOrigin, '/valid.ts?token=one'), 200);
|
||||
assert.equal(upstreamHits, 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
|
||||
await withProxy(
|
||||
(req, res) => {
|
||||
|
||||
@@ -1,148 +1,21 @@
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { handleUpstreamResponse, TS_SEGMENT_ALIAS_SUFFIX } from './stream-strip-response';
|
||||
import { forwardableRequestHeaders, requestUpstream } from './stream-strip-transport';
|
||||
|
||||
export {
|
||||
DEFAULT_SCAN_LIMIT_BYTES,
|
||||
findTsSyncOffset,
|
||||
rewritePlaylistOrigins,
|
||||
TS_PACKET_LENGTH,
|
||||
TS_SEGMENT_ALIAS_SUFFIX,
|
||||
} from './stream-strip-response';
|
||||
|
||||
/**
|
||||
* Loopback proxy between mpv and the anime bridge that undoes segment
|
||||
* disguises. Some hosts prepend a real image header (a 1x1 PNG in the wild) to
|
||||
* every HLS segment so scrapers see "an image"; ffmpeg then probes the segment
|
||||
* as a picture and playback dies with "no audio or video data played". Aniyomi
|
||||
* strips this in its player; mpv needs the bytes fixed before it sees them.
|
||||
*
|
||||
* Only bridge-origin `.m3u8` streams are routed through here (see
|
||||
* anime-browser-runtime). Playlist bodies get their absolute upstream origins
|
||||
* rewritten so segment requests come back through the proxy; segment bodies are
|
||||
* scanned for the first genuine MPEG-TS packet run and any junk before it is
|
||||
* dropped. Anything that is not TS (fMP4, VTT, keys) passes through untouched.
|
||||
* Loopback proxy between mpv and the anime bridge that removes disguised HLS
|
||||
* segment prefixes before ffmpeg sees them.
|
||||
*/
|
||||
|
||||
export const TS_PACKET_LENGTH = 188;
|
||||
const TS_SYNC_BYTE = 0x47;
|
||||
/**
|
||||
* Sync bytes that must repeat at exact packet spacing before an offset counts
|
||||
* as TS data. One or two matches happen by chance in binary data; five in a
|
||||
* row at 188-byte strides do not.
|
||||
*/
|
||||
const SYNC_RUN = 5;
|
||||
/**
|
||||
* FFmpeg 8.1 rejects HLS media whose URL suffix is not in its segment allowlist.
|
||||
* Hosts disguise MPEG-TS segments behind rotating fake extensions (`.image`,
|
||||
* `.jpg`, `.css`, ...), so the local playlist gives every proxied segment
|
||||
* without a recognized media extension this safe alias and removes it again
|
||||
* before forwarding.
|
||||
*/
|
||||
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
|
||||
/** ffmpeg 8.1 hls demuxer `allowed_segment_extensions` defaults (minus `html`,
|
||||
* which only newer builds accept and is a disguise whenever it shows up here). */
|
||||
const FFMPEG_SAFE_SEGMENT_EXTENSIONS = new Set([
|
||||
'3gp',
|
||||
'aac',
|
||||
'avi',
|
||||
'ac3',
|
||||
'eac3',
|
||||
'flac',
|
||||
'mkv',
|
||||
'm3u8',
|
||||
'm4a',
|
||||
'm4s',
|
||||
'm4v',
|
||||
'mpg',
|
||||
'mov',
|
||||
'mp2',
|
||||
'mp3',
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpegts',
|
||||
'ogg',
|
||||
'ogv',
|
||||
'oga',
|
||||
'ts',
|
||||
'vob',
|
||||
'vtt',
|
||||
'wav',
|
||||
'webvtt',
|
||||
'cmfv',
|
||||
'cmfa',
|
||||
'ec3',
|
||||
'fmp4',
|
||||
]);
|
||||
|
||||
/** True when ffmpeg's picky segment-extension check would reject this path. */
|
||||
function needsTsSegmentAlias(pathname: string): boolean {
|
||||
const name = pathname.slice(pathname.lastIndexOf('/') + 1).toLowerCase();
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot === -1) return true;
|
||||
return !FFMPEG_SAFE_SEGMENT_EXTENSIONS.has(name.slice(dot + 1));
|
||||
}
|
||||
/** A disguise prefix is small; give up scanning after this much. */
|
||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
/** Bytes needed to either find a run within the limit or rule one out. */
|
||||
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
|
||||
|
||||
/**
|
||||
* First offset at which a confirmed MPEG-TS packet run starts, or null when
|
||||
* the data does not look like TS at all (within the scan limit).
|
||||
*/
|
||||
export function findTsSyncOffset(
|
||||
data: Buffer,
|
||||
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
|
||||
): number | null {
|
||||
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
|
||||
const end = Math.min(lastConfirmable, scanLimit);
|
||||
for (let offset = 0; offset <= end; offset++) {
|
||||
if (data[offset] !== TS_SYNC_BYTE) continue;
|
||||
let confirmed = true;
|
||||
for (let packet = 1; packet < SYNC_RUN; packet++) {
|
||||
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
|
||||
confirmed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point absolute playlist entries at the proxy. Relative entries already
|
||||
* resolve against whatever origin served the playlist, so they need no help.
|
||||
*/
|
||||
export function rewritePlaylistOrigins(
|
||||
body: string,
|
||||
upstreamOrigin: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
const rebased = body.split(upstreamOrigin).join(proxyOrigin);
|
||||
return rebased
|
||||
.split(/(\r?\n)/)
|
||||
.map((line) => {
|
||||
const uri = line.trim();
|
||||
if (!uri || uri.startsWith('#')) return line;
|
||||
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(uri, proxyOrigin);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const queryIndex = uri.search(/[?#]/);
|
||||
const aliasIndex = queryIndex === -1 ? uri.length : queryIndex;
|
||||
const leadingWhitespace = line.slice(0, line.indexOf(uri));
|
||||
const trailingWhitespace = line.slice(leadingWhitespace.length + uri.length);
|
||||
return `${leadingWhitespace}${uri.slice(0, aliasIndex)}${TS_SEGMENT_ALIAS_SUFFIX}${uri.slice(aliasIndex)}${trailingWhitespace}`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function removeTsSegmentAlias(url: URL): void {
|
||||
if (url.pathname.endsWith(TS_SEGMENT_ALIAS_SUFFIX)) {
|
||||
url.pathname = url.pathname.slice(0, -TS_SEGMENT_ALIAS_SUFFIX.length);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamStripProxyOptions {
|
||||
/** Read per request so a bridge restart on a new port keeps working. */
|
||||
upstreamOrigin: () => string;
|
||||
@@ -152,12 +25,6 @@ export interface StreamStripProxyOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_DELAY_MS = 400;
|
||||
/**
|
||||
* Socket timeout on the upstream GET, cleared once its headers arrive. Node's
|
||||
* http client has no deadline of its own, so a host that accepts the
|
||||
* connection and then says nothing would hang mpv on that segment forever.
|
||||
*/
|
||||
const UPSTREAM_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface StreamStripProxyHandle {
|
||||
origin: string;
|
||||
@@ -165,26 +32,27 @@ export interface StreamStripProxyHandle {
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ClientRequestLifecycle {
|
||||
activeUpstreamRequest: http.ClientRequest | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Response headers that must not be forwarded verbatim. */
|
||||
const DROPPED_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
function forwardableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || DROPPED_HEADERS.has(name.toLowerCase())) continue;
|
||||
result[name] = value;
|
||||
function resolveUpstreamUrl(requestTarget: string, configuredOrigin: string): URL {
|
||||
if (
|
||||
!requestTarget.startsWith('/') ||
|
||||
requestTarget.startsWith('//') ||
|
||||
requestTarget.includes('#')
|
||||
) {
|
||||
throw new Error('Stream proxy requests must use origin-form targets.');
|
||||
}
|
||||
return result;
|
||||
|
||||
const upstream = new URL(configuredOrigin);
|
||||
if (upstream.protocol !== 'http:') {
|
||||
throw new Error('Stream proxy upstream must use HTTP.');
|
||||
}
|
||||
const resolved = new URL(requestTarget, upstream.origin);
|
||||
if (resolved.protocol !== 'http:' || resolved.origin !== upstream.origin) {
|
||||
throw new Error('Stream proxy request escaped the configured upstream origin.');
|
||||
}
|
||||
if (resolved.pathname.endsWith(TS_SEGMENT_ALIAS_SUFFIX)) {
|
||||
resolved.pathname = resolved.pathname.slice(0, -TS_SEGMENT_ALIAS_SUFFIX.length);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function startStreamStripProxy(
|
||||
@@ -192,6 +60,7 @@ export function startStreamStripProxy(
|
||||
): Promise<StreamStripProxyHandle> {
|
||||
const log = options.log ?? (() => {});
|
||||
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
let origin = '';
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
@@ -201,210 +70,36 @@ export function startStreamStripProxy(
|
||||
|
||||
let upstreamUrl: URL;
|
||||
try {
|
||||
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
|
||||
removeTsSegmentAlias(upstreamUrl);
|
||||
upstreamUrl = resolveUpstreamUrl(req.url ?? '/', options.upstreamOrigin());
|
||||
} catch {
|
||||
res.writeHead(502).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestHeaders = forwardableHeaders(req.headers);
|
||||
const requestHeaders = forwardableRequestHeaders(req.headers);
|
||||
delete requestHeaders.host;
|
||||
// Never forward Range: ffmpeg opens every segment with `bytes=0-`, the
|
||||
// bridge answers some of those 206, and a partial response cannot be
|
||||
// stripped (only full 200 bodies are). Byte ranges into a resource whose
|
||||
// bytes this proxy rewrites would be incoherent anyway.
|
||||
// Rewritten bodies cannot honor byte ranges into the original representation.
|
||||
delete requestHeaders.range;
|
||||
res.on('error', () => {});
|
||||
|
||||
const lifecycle: ClientRequestLifecycle = { activeUpstreamRequest: null, closed: false };
|
||||
const destroyUpstreamOnClientClose = (): void => {
|
||||
lifecycle.closed = true;
|
||||
lifecycle.activeUpstreamRequest?.destroy();
|
||||
};
|
||||
req.once('aborted', destroyUpstreamOnClientClose);
|
||||
res.once('close', destroyUpstreamOnClientClose);
|
||||
res.once('finish', () => {
|
||||
req.off('aborted', destroyUpstreamOnClientClose);
|
||||
res.off('close', destroyUpstreamOnClientClose);
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
});
|
||||
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, 0, lifecycle);
|
||||
});
|
||||
|
||||
/**
|
||||
* One delayed retry on a failed GET: right after an episode resolve, the
|
||||
* bridge (or the host behind it) can error on the very first segment
|
||||
* fetches and be fine a moment later — mpv treats a playlist full of failed
|
||||
* segments as a dead file and gives up for good.
|
||||
*/
|
||||
function requestUpstream(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstreamUrl: URL,
|
||||
requestHeaders: http.OutgoingHttpHeaders,
|
||||
attempt: number,
|
||||
lifecycle: ClientRequestLifecycle,
|
||||
): void {
|
||||
if (lifecycle.closed || res.destroyed) return;
|
||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||
// Once the response is handed off, its headers (and often part of its body)
|
||||
// are already on the wire: a later upstream error can only be reported by
|
||||
// killing the connection, never by retrying or writing a 502.
|
||||
let handedOff = false;
|
||||
const retry = (): void => {
|
||||
setTimeout(() => {
|
||||
requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1, lifecycle);
|
||||
}, retryDelayMs);
|
||||
};
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
requestUpstream({
|
||||
req,
|
||||
res,
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
|
||||
(upstream) => {
|
||||
const clearActiveRequest = (): void => {
|
||||
if (lifecycle.activeUpstreamRequest === upstreamRequest) {
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
}
|
||||
};
|
||||
upstream.once('end', clearActiveRequest);
|
||||
upstream.once('close', clearActiveRequest);
|
||||
// Body streaming has its own pace; only the wait for headers is capped.
|
||||
upstreamRequest.setTimeout(0);
|
||||
const status = upstream.statusCode ?? 502;
|
||||
if (status === 404 || status >= 500) {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
|
||||
upstream.resume();
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
||||
}
|
||||
handedOff = true;
|
||||
handleUpstreamResponse(req, res, upstream);
|
||||
},
|
||||
);
|
||||
lifecycle.activeUpstreamRequest = upstreamRequest;
|
||||
// Destroying with an error routes the stall through the retry/502 path.
|
||||
upstreamRequest.on('timeout', () => {
|
||||
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
||||
requestHeaders,
|
||||
retryDelayMs,
|
||||
log,
|
||||
handleResponse: (upstream) =>
|
||||
handleUpstreamResponse({
|
||||
req,
|
||||
res,
|
||||
upstream,
|
||||
upstreamOrigin: options.upstreamOrigin,
|
||||
proxyOrigin: () => origin,
|
||||
log,
|
||||
}),
|
||||
});
|
||||
upstreamRequest.on('error', (error) => {
|
||||
if (handedOff) {
|
||||
log(`[stream-proxy] upstream failed mid-response: ${String(error)}`);
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
upstreamRequest.end();
|
||||
}
|
||||
|
||||
function handleUpstreamResponse(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
upstream: http.IncomingMessage,
|
||||
): void {
|
||||
const status = upstream.statusCode ?? 502;
|
||||
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
|
||||
const contentType = String(upstream.headers['content-type'] ?? '');
|
||||
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
|
||||
|
||||
upstream.on('error', () => res.destroy());
|
||||
|
||||
// Only a full 200 body is safe to modify; everything else (errors, range
|
||||
// responses, HEAD) forwards untouched.
|
||||
if (status !== 200 || req.method === 'HEAD') {
|
||||
res.writeHead(status, forwardableHeaders(upstream.headers));
|
||||
upstream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaylist) {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
buffered += chunk.length;
|
||||
// A playlist is text and small; anything this large is not one, and it
|
||||
// has to be held whole in memory to be rewritten.
|
||||
if (buffered > DECISION_BYTES) {
|
||||
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
|
||||
upstream.destroy();
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
upstream.on('end', () => {
|
||||
const body = rewritePlaylistOrigins(
|
||||
Buffer.concat(chunks).toString('utf8'),
|
||||
options.upstreamOrigin(),
|
||||
origin,
|
||||
);
|
||||
res.writeHead(status, {
|
||||
...forwardableHeaders(upstream.headers),
|
||||
'content-length': Buffer.byteLength(body, 'utf8'),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stripSegment(res, upstream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer just enough of the body to find (or rule out) a TS packet run,
|
||||
* drop everything before it, then stream the rest through untouched.
|
||||
*/
|
||||
function stripSegment(res: http.ServerResponse, upstream: http.IncomingMessage): void {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
|
||||
const respond = (data: Buffer, remainderFollows: boolean): void => {
|
||||
const offset = findTsSyncOffset(data) ?? 0;
|
||||
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
|
||||
const body = offset > 0 ? data.subarray(offset) : data;
|
||||
|
||||
const headers = forwardableHeaders(upstream.headers);
|
||||
const upstreamLength = Number(upstream.headers['content-length']);
|
||||
if (remainderFollows) {
|
||||
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
|
||||
} else {
|
||||
headers['content-length'] = body.length;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.statusCode ?? 200, headers);
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk);
|
||||
buffered += chunk.length;
|
||||
if (buffered < DECISION_BYTES) return;
|
||||
upstream.off('data', onData);
|
||||
upstream.off('end', onEnd);
|
||||
respond(Buffer.concat(chunks), true);
|
||||
upstream.pipe(res);
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
respond(Buffer.concat(chunks), false);
|
||||
res.end();
|
||||
};
|
||||
upstream.on('data', onData);
|
||||
upstream.on('end', onEnd);
|
||||
}
|
||||
|
||||
let origin = '';
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type http from 'node:http';
|
||||
|
||||
export const TS_PACKET_LENGTH = 188;
|
||||
const TS_SYNC_BYTE = 0x47;
|
||||
/** Five sync bytes at exact packet spacing make an accidental match implausible. */
|
||||
const SYNC_RUN = 5;
|
||||
|
||||
export const TS_SEGMENT_ALIAS_SUFFIX = '.subminer.ts';
|
||||
const FFMPEG_SAFE_SEGMENT_EXTENSIONS = new Set([
|
||||
'3gp',
|
||||
'aac',
|
||||
'avi',
|
||||
'ac3',
|
||||
'eac3',
|
||||
'flac',
|
||||
'mkv',
|
||||
'm3u8',
|
||||
'm4a',
|
||||
'm4s',
|
||||
'm4v',
|
||||
'mpg',
|
||||
'mov',
|
||||
'mp2',
|
||||
'mp3',
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpegts',
|
||||
'ogg',
|
||||
'ogv',
|
||||
'oga',
|
||||
'ts',
|
||||
'vob',
|
||||
'vtt',
|
||||
'wav',
|
||||
'webvtt',
|
||||
'cmfv',
|
||||
'cmfa',
|
||||
'ec3',
|
||||
'fmp4',
|
||||
]);
|
||||
|
||||
function needsTsSegmentAlias(pathname: string): boolean {
|
||||
const name = pathname.slice(pathname.lastIndexOf('/') + 1).toLowerCase();
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot === -1) return true;
|
||||
return !FFMPEG_SAFE_SEGMENT_EXTENSIONS.has(name.slice(dot + 1));
|
||||
}
|
||||
|
||||
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
|
||||
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
|
||||
|
||||
export function findTsSyncOffset(
|
||||
data: Buffer,
|
||||
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
|
||||
): number | null {
|
||||
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
|
||||
const end = Math.min(lastConfirmable, scanLimit);
|
||||
for (let offset = 0; offset <= end; offset++) {
|
||||
if (data[offset] !== TS_SYNC_BYTE) continue;
|
||||
let confirmed = true;
|
||||
for (let packet = 1; packet < SYNC_RUN; packet++) {
|
||||
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
|
||||
confirmed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confirmed) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rewritePlaylistOrigins(
|
||||
body: string,
|
||||
upstreamOrigin: string,
|
||||
proxyOrigin: string,
|
||||
): string {
|
||||
const rebased = body.split(upstreamOrigin).join(proxyOrigin);
|
||||
return rebased
|
||||
.split(/(\r?\n)/)
|
||||
.map((line) => {
|
||||
const uri = line.trim();
|
||||
if (!uri || uri.startsWith('#')) return line;
|
||||
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(uri, proxyOrigin);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (resolved.origin !== proxyOrigin || !needsTsSegmentAlias(resolved.pathname)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const queryIndex = uri.search(/[?#]/);
|
||||
const aliasIndex = queryIndex === -1 ? uri.length : queryIndex;
|
||||
const leadingWhitespace = line.slice(0, line.indexOf(uri));
|
||||
const trailingWhitespace = line.slice(leadingWhitespace.length + uri.length);
|
||||
return `${leadingWhitespace}${uri.slice(0, aliasIndex)}${TS_SEGMENT_ALIAS_SUFFIX}${uri.slice(aliasIndex)}${trailingWhitespace}`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const DROPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
function forwardableResponseHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || DROPPED_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function handleUpstreamResponse(options: {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
upstream: http.IncomingMessage;
|
||||
upstreamOrigin: () => string;
|
||||
proxyOrigin: () => string;
|
||||
log: (message: string) => void;
|
||||
}): void {
|
||||
const { req, res, upstream, log } = options;
|
||||
const status = upstream.statusCode ?? 502;
|
||||
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
|
||||
const contentType = String(upstream.headers['content-type'] ?? '');
|
||||
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
|
||||
|
||||
upstream.on('error', () => res.destroy());
|
||||
|
||||
if (status !== 200 || req.method === 'HEAD') {
|
||||
res.writeHead(status, forwardableResponseHeaders(upstream.headers));
|
||||
upstream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaylist) {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
upstream.on('data', (chunk: Buffer) => {
|
||||
buffered += chunk.length;
|
||||
if (buffered > DECISION_BYTES) {
|
||||
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
|
||||
upstream.destroy();
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
upstream.on('end', () => {
|
||||
const body = rewritePlaylistOrigins(
|
||||
Buffer.concat(chunks).toString('utf8'),
|
||||
options.upstreamOrigin(),
|
||||
options.proxyOrigin(),
|
||||
);
|
||||
res.writeHead(status, {
|
||||
...forwardableResponseHeaders(upstream.headers),
|
||||
'content-length': Buffer.byteLength(body, 'utf8'),
|
||||
});
|
||||
res.end(body);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
stripSegment(res, upstream, log);
|
||||
}
|
||||
|
||||
function stripSegment(
|
||||
res: http.ServerResponse,
|
||||
upstream: http.IncomingMessage,
|
||||
log: (message: string) => void,
|
||||
): void {
|
||||
const chunks: Buffer[] = [];
|
||||
let buffered = 0;
|
||||
|
||||
const respond = (data: Buffer, remainderFollows: boolean): void => {
|
||||
const offset = findTsSyncOffset(data) ?? 0;
|
||||
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
|
||||
const body = offset > 0 ? data.subarray(offset) : data;
|
||||
|
||||
const headers = forwardableResponseHeaders(upstream.headers);
|
||||
const upstreamLength = Number(upstream.headers['content-length']);
|
||||
if (remainderFollows) {
|
||||
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
|
||||
} else {
|
||||
headers['content-length'] = body.length;
|
||||
}
|
||||
|
||||
res.writeHead(upstream.statusCode ?? 200, headers);
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer): void => {
|
||||
chunks.push(chunk);
|
||||
buffered += chunk.length;
|
||||
if (buffered < DECISION_BYTES) return;
|
||||
upstream.off('data', onData);
|
||||
upstream.off('end', onEnd);
|
||||
respond(Buffer.concat(chunks), true);
|
||||
upstream.pipe(res);
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
respond(Buffer.concat(chunks), false);
|
||||
res.end();
|
||||
};
|
||||
upstream.on('data', onData);
|
||||
upstream.on('end', onEnd);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import http from 'node:http';
|
||||
|
||||
const UPSTREAM_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface ClientRequestLifecycle {
|
||||
activeUpstreamRequest: http.ClientRequest | null;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export function forwardableRequestHeaders(
|
||||
headers: http.IncomingHttpHeaders,
|
||||
): http.OutgoingHttpHeaders {
|
||||
const result: http.OutgoingHttpHeaders = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (
|
||||
value === undefined ||
|
||||
name.toLowerCase() === 'connection' ||
|
||||
name.toLowerCase() === 'keep-alive' ||
|
||||
name.toLowerCase() === 'transfer-encoding' ||
|
||||
name.toLowerCase() === 'content-length'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function requestUpstream(options: {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
upstreamUrl: URL;
|
||||
requestHeaders: http.OutgoingHttpHeaders;
|
||||
retryDelayMs: number;
|
||||
log: (message: string) => void;
|
||||
handleResponse: (upstream: http.IncomingMessage) => void;
|
||||
}): void {
|
||||
const lifecycle: ClientRequestLifecycle = { activeUpstreamRequest: null, closed: false };
|
||||
const destroyUpstreamOnClientClose = (): void => {
|
||||
lifecycle.closed = true;
|
||||
lifecycle.activeUpstreamRequest?.destroy();
|
||||
};
|
||||
options.req.once('aborted', destroyUpstreamOnClientClose);
|
||||
options.res.once('close', destroyUpstreamOnClientClose);
|
||||
options.res.once('finish', () => {
|
||||
options.req.off('aborted', destroyUpstreamOnClientClose);
|
||||
options.res.off('close', destroyUpstreamOnClientClose);
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
});
|
||||
|
||||
requestAttempt(options, lifecycle, 0);
|
||||
}
|
||||
|
||||
function requestAttempt(
|
||||
options: Parameters<typeof requestUpstream>[0],
|
||||
lifecycle: ClientRequestLifecycle,
|
||||
attempt: number,
|
||||
): void {
|
||||
const { req, res, upstreamUrl, requestHeaders, retryDelayMs, log } = options;
|
||||
if (lifecycle.closed || res.destroyed) return;
|
||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||
let handedOff = false;
|
||||
const retry = (): void => {
|
||||
setTimeout(() => requestAttempt(options, lifecycle, attempt + 1), retryDelayMs);
|
||||
};
|
||||
|
||||
const upstreamRequest = http.request(
|
||||
upstreamUrl,
|
||||
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
|
||||
(upstream) => {
|
||||
const clearActiveRequest = (): void => {
|
||||
if (lifecycle.activeUpstreamRequest === upstreamRequest) {
|
||||
lifecycle.activeUpstreamRequest = null;
|
||||
}
|
||||
};
|
||||
upstream.once('end', clearActiveRequest);
|
||||
upstream.once('close', clearActiveRequest);
|
||||
upstreamRequest.setTimeout(0);
|
||||
const status = upstream.statusCode ?? 502;
|
||||
if (status === 404 || status >= 500) {
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
|
||||
upstream.resume();
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
||||
}
|
||||
handedOff = true;
|
||||
options.handleResponse(upstream);
|
||||
},
|
||||
);
|
||||
lifecycle.activeUpstreamRequest = upstreamRequest;
|
||||
upstreamRequest.on('timeout', () => {
|
||||
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
||||
});
|
||||
upstreamRequest.on('error', (error) => {
|
||||
if (handedOff) {
|
||||
log(`[stream-proxy] upstream failed mid-response: ${String(error)}`);
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (mayRetry) {
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
||||
retry();
|
||||
return;
|
||||
}
|
||||
log(`[stream-proxy] upstream request failed: ${String(error)}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
upstreamRequest.end();
|
||||
}
|
||||
Reference in New Issue
Block a user