try to fix stream
Build and Push Docker Images / build-and-push (push) Successful in 22s

This commit is contained in:
Bram committed 2026-11-04 11:39:54 +01:00
1 parent 2942f48ded
commit 1276dac371
7 files changed
+358 -142

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ ENV LISTEN_PORT=8080 \
PROBE_TIMEOUT_MS=8000 \ PROBE_TIMEOUT_MS=8000 \
UPSTREAM_TIMEOUT_MS=15000 \ UPSTREAM_TIMEOUT_MS=15000 \
ALLOW_BUSY_FALLBACK=1 \ ALLOW_BUSY_FALLBACK=1 \
PUBLIC_URL="" \
LOG_REQUESTS=0 \
PROXY_INSECURE_TLS=0 PROXY_INSECURE_TLS=0
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
@@ -11,6 +11,10 @@ services:
PROBE_TIMEOUT_MS: "8000" PROBE_TIMEOUT_MS: "8000"
UPSTREAM_TIMEOUT_MS: "15000" UPSTREAM_TIMEOUT_MS: "15000"
ALLOW_BUSY_FALLBACK: "1" ALLOW_BUSY_FALLBACK: "1"
# URL waarop clients de proxy bereiken, gebruikt in herschreven catalogus- en
# playlist-URLs. Leeg laten om de Host-header van het request te gebruiken.
# PUBLIC_URL: "http://192.168.0.123:8080"
# LOG_REQUESTS: "1" # log elke (stream)request en status
# PROXY_INSECURE_TLS: "1" # alleen nodig bij self-signed upstream-certificaten # PROXY_INSECURE_TLS: "1" # alleen nodig bij self-signed upstream-certificaten
volumes: volumes:
- ./upstreams.json:/config/upstreams.json:ro - ./upstreams.json:/config/upstreams.json:ro
+1
View File
@@ -17,6 +17,7 @@ function healthPayload() {
return { return {
ok: upstreams.some((upstream) => upstream.state.healthy), ok: upstreams.some((upstream) => upstream.state.healthy),
configFile: settings.configFile, configFile: settings.configFile,
publicUrl: settings.publicUrl || null,
probeIntervalSec: settings.probeIntervalSec, probeIntervalSec: settings.probeIntervalSec,
lastProbeAt: getLastProbeAt(), lastProbeAt: getLastProbeAt(),
maxConnections: totalConnectionCapacity(), maxConnections: totalConnectionCapacity(),
+4
View File
@@ -1,5 +1,7 @@
'use strict'; 'use strict';
const { settings } = require('./settings');
function sendText(res, statusCode, text, extraHeaders = {}) { function sendText(res, statusCode, text, extraHeaders = {}) {
res.writeHead(statusCode, { res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8', 'Content-Type': 'text/plain; charset=utf-8',
@@ -26,6 +28,8 @@ function firstHeader(value) {
} }
function getProxyOrigin(req) { function getProxyOrigin(req) {
if (settings.publicUrl) return settings.publicUrl;
const proto = const proto =
firstHeader(req.headers['x-forwarded-proto']) || firstHeader(req.headers['x-forwarded-proto']) ||
(req.socket.encrypted ? 'https' : 'http'); (req.socket.encrypted ? 'https' : 'http');
+283 -124
View File
@@ -25,6 +25,9 @@ const {
} = require('./rewrite'); } = require('./rewrite');
const { sendText, getProxyOrigin } = require('./http-utils'); const { sendText, getProxyOrigin } = require('./http-utils');
const MAX_REDIRECTS = 5;
const AUTH_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
} }
@@ -40,7 +43,72 @@ function markUpstreamFailure(upstream, error) {
requestProbeSoon(); requestProbeSoon();
} }
const AUTH_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; function isRedirect(status) {
return (
status === 301 ||
status === 302 ||
status === 303 ||
status === 307 ||
status === 308
);
}
function isRetryableStatus(status) {
return status === 429 || status >= 500;
}
function shouldRetryStreamStatus(status) {
return (
isRetryableStatus(status) ||
status === 401 ||
status === 403 ||
status === 404 ||
status === 406
);
}
function redact(value) {
if (typeof value !== 'string') return value;
let result = value;
for (const secret of [settings.proxyUsername, settings.proxyPassword]) {
if (secret) result = result.split(secret).join('***');
const encoded = encodeURIComponent(secret || '');
if (secret && encoded !== secret) {
result = result.split(encoded).join('***');
}
}
return result;
}
function requestDescription(req, reqUrl) {
return `${req.method} ${redact(reqUrl.pathname)}`;
}
function requestKind(streamRequest) {
return streamRequest ? '[stream]' : '[api]';
}
function buildRequestHeaders(req, target) {
const headers = filterHopByHop(req.headers);
delete headers.host;
delete headers['accept-encoding'];
headers.host = target.host;
headers['accept-encoding'] = 'identity';
const clientIp = req.socket.remoteAddress || '';
const existingForwardedFor = req.headers['x-forwarded-for'];
headers['x-forwarded-for'] = existingForwardedFor
? `${existingForwardedFor}, ${clientIp}`
: clientIp;
if (!headers['x-forwarded-proto']) {
headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : 'http';
}
if (!headers['x-forwarded-host'] && req.headers.host) {
headers['x-forwarded-host'] = req.headers.host;
}
return headers;
}
function isAuthRequest(req, reqUrl) { function isAuthRequest(req, reqUrl) {
if (req.method === 'HEAD') return false; if (req.method === 'HEAD') return false;
@@ -48,8 +116,11 @@ function isAuthRequest(req, reqUrl) {
return /\/player_api\.php\/?$/i.test(reqUrl.pathname); return /\/player_api\.php\/?$/i.test(reqUrl.pathname);
} }
function applyConnectionCapacity(body, upstream, proxyOrigin) { function applyAuthRewrites(body, upstream, proxyOrigin) {
let text = body.toString('utf8'); let text = body.toString('utf8');
const proxyUrl = new URL(proxyOrigin);
const proxyPort =
proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80');
try { try {
const json = JSON.parse(text); const json = JSON.parse(text);
@@ -59,9 +130,25 @@ function applyConnectionCapacity(body, upstream, proxyOrigin) {
if (max > 0) { if (max > 0) {
info.max_connections = info.max_connections =
typeof info.max_connections === 'number' ? max : String(max); typeof info.max_connections === 'number' ? max : String(max);
text = JSON.stringify(json); }
if (settings.proxyUsername && 'username' in info) {
info.username = settings.proxyUsername;
} }
} }
const server = json && json.server_info;
if (server && typeof server === 'object') {
if ('url' in server) server.url = proxyUrl.hostname;
if ('port' in server) server.port = proxyPort;
if ('https_port' in server && proxyUrl.protocol === 'https:') {
server.https_port = proxyPort;
}
const protocol = proxyUrl.protocol.replace(':', '');
if ('server_protocol' in server) server.server_protocol = protocol;
if ('protocol' in server) server.protocol = protocol;
}
text = JSON.stringify(json);
} catch {} } catch {}
return Buffer.from(rewriteUrlValue(text, upstream, proxyOrigin)); return Buffer.from(rewriteUrlValue(text, upstream, proxyOrigin));
@@ -97,7 +184,7 @@ function sendAuthResponse(proxyRes, res, upstream, proxyOrigin) {
} }
res.end( res.end(
applyConnectionCapacity(Buffer.concat(chunks), upstream, proxyOrigin) applyAuthRewrites(Buffer.concat(chunks), upstream, proxyOrigin)
); );
}); });
} }
@@ -169,127 +256,15 @@ function handleProxy(req, res, reqUrl) {
const pathname = rewritePath(creds, upstream); const pathname = rewritePath(creds, upstream);
const search = rewriteSearch(reqUrl, upstream); const search = rewriteSearch(reqUrl, upstream);
const target = buildUpstreamUrl(upstream, pathname, search); const initialTarget = buildUpstreamUrl(upstream, pathname, search);
const initialHeaders = buildRequestHeaders(req, initialTarget);
let bodySent = false;
const headers = filterHopByHop(req.headers); const fail = (error) => {
delete headers.host;
delete headers['accept-encoding'];
headers.host = target.host;
headers['accept-encoding'] = 'identity';
const clientIp = req.socket.remoteAddress || '';
const existingForwardedFor = req.headers['x-forwarded-for'];
headers['x-forwarded-for'] = existingForwardedFor
? `${existingForwardedFor}, ${clientIp}`
: clientIp;
if (!headers['x-forwarded-proto']) {
headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : 'http';
}
if (!headers['x-forwarded-host'] && req.headers.host) {
headers['x-forwarded-host'] = req.headers.host;
}
const lib = target.protocol === 'https:' ? https : http;
let proxyReq;
let headersTimeout = null;
try {
proxyReq = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: `${target.pathname}${target.search}`,
method: req.method,
headers,
rejectUnauthorized: settings.rejectUnauthorized,
},
(proxyRes) => {
clearTimeout(headersTimeout);
const proxyOrigin = getProxyOrigin(req);
const rewriting = shouldRewriteBody(req, proxyRes, reqUrl.pathname);
const responseHeaders = filterResponseHeaders(
proxyRes.headers,
rewriting
);
if (responseHeaders.location) {
responseHeaders.location = rewriteUrlValue(
responseHeaders.location,
upstream,
proxyOrigin
);
}
try {
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
} catch {
release();
try {
proxyRes.destroy();
} catch {}
try {
res.destroy();
} catch {}
return;
}
proxyRes.on('close', release);
proxyRes.on('error', () => {
release();
if (!res.headersSent) {
sendText(res, 502, 'Upstream response error');
} else {
res.destroy();
}
});
if (rewriting) {
if (isAuthRequest(req, reqUrl)) {
sendAuthResponse(proxyRes, res, upstream, proxyOrigin);
} else {
const replacer = new ReplaceStream(
buildReplacements(upstream, proxyOrigin)
);
replacer.on('error', () => {
try {
res.destroy();
} catch {}
release();
});
proxyRes.pipe(replacer).pipe(res);
}
} else {
proxyRes.pipe(res);
}
}
);
} catch (e) {
release();
markUpstreamFailure(upstream, e);
if (canRetry(req) && attempted.size < upstreamCount) {
attempt();
} else {
sendText(res, 502, 'Upstream request failed');
}
return;
}
headersTimeout = setTimeout(() => {
try {
proxyReq.destroy(new Error('upstream_timeout'));
} catch {}
}, settings.upstreamTimeoutMs);
currentProxyReq = proxyReq;
proxyReq.on('error', (err) => {
clearTimeout(headersTimeout);
if (currentProxyReq === proxyReq) currentProxyReq = null;
release(); release();
if (closed) return; if (closed) return;
markUpstreamFailure(upstream, err); markUpstreamFailure(upstream, error);
if (!res.headersSent && canRetry(req) && attempted.size < upstreamCount) { if (!res.headersSent && canRetry(req) && attempted.size < upstreamCount) {
attempt(); attempt();
@@ -301,12 +276,196 @@ function handleProxy(req, res, reqUrl) {
} else { } else {
res.destroy(); res.destroy();
} }
}); };
req.pipe(proxyReq); const send = (target, redirectsLeft) => {
if (closed || res.headersSent) {
release();
return;
}
const lib = target.protocol === 'https:' ? https : http;
const headers =
redirectsLeft === MAX_REDIRECTS
? initialHeaders
: buildRequestHeaders(req, target);
let proxyReq;
let headersTimeout = null;
let settled = false;
try {
proxyReq = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: `${target.pathname}${target.search}`,
method: req.method,
headers,
rejectUnauthorized: settings.rejectUnauthorized,
},
(proxyRes) => {
settled = true;
clearTimeout(headersTimeout);
if (currentProxyReq === proxyReq) currentProxyReq = null;
const status = proxyRes.statusCode || 502;
const location = proxyRes.headers.location;
if (
streamRequest &&
isRedirect(status) &&
location &&
redirectsLeft > 0
) {
let nextTarget = null;
try {
nextTarget = new URL(location, target);
} catch {}
if (
nextTarget &&
(nextTarget.protocol === 'http:' ||
nextTarget.protocol === 'https:')
) {
console.log(
`${requestKind(streamRequest)} redirect ${status} ${
upstream.label
} -> ${nextTarget.host} (${requestDescription(
req,
reqUrl
)})`
);
proxyRes.on('error', () => {});
proxyRes.resume();
send(nextTarget, redirectsLeft - 1);
return;
}
}
if (
streamRequest &&
canRetry(req) &&
shouldRetryStreamStatus(status) &&
attempted.size < upstreamCount
) {
console.warn(
`${requestKind(streamRequest)} upstream ${status} from ${
upstream.label
}, trying next (${requestDescription(req, reqUrl)})`
);
proxyRes.on('error', () => {});
proxyRes.resume();
release();
attempt();
return;
}
if (status >= 400) {
console.warn(
`${requestKind(streamRequest)} upstream ${status} from ${
upstream.label
} (${requestDescription(req, reqUrl)})`
);
} else if (settings.logRequests) {
console.log(
`${requestKind(streamRequest)} ${status} via ${
upstream.label
} (${requestDescription(req, reqUrl)})`
);
}
const proxyOrigin = getProxyOrigin(req);
const rewriting = shouldRewriteBody(req, proxyRes, reqUrl.pathname);
const responseHeaders = filterResponseHeaders(
proxyRes.headers,
rewriting
);
if (responseHeaders.location) {
responseHeaders.location = rewriteUrlValue(
responseHeaders.location,
upstream,
proxyOrigin
);
}
try {
res.writeHead(status, responseHeaders);
} catch {
release();
try {
proxyRes.destroy();
} catch {}
try {
res.destroy();
} catch {}
return;
}
proxyRes.on('close', release);
proxyRes.on('error', () => {
release();
if (!res.headersSent) {
sendText(res, 502, 'Upstream response error');
} else {
res.destroy();
}
});
if (rewriting) {
if (isAuthRequest(req, reqUrl)) {
sendAuthResponse(proxyRes, res, upstream, proxyOrigin);
} else {
const replacer = new ReplaceStream(
buildReplacements(upstream, proxyOrigin)
);
replacer.on('error', () => {
try {
res.destroy();
} catch {}
release();
});
proxyRes.pipe(replacer).pipe(res);
}
} else {
proxyRes.pipe(res);
}
}
);
} catch (e) {
fail(e);
return;
}
headersTimeout = setTimeout(() => {
try {
proxyReq.destroy(new Error('upstream_timeout'));
} catch {}
}, settings.upstreamTimeoutMs);
currentProxyReq = proxyReq;
proxyReq.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(headersTimeout);
if (currentProxyReq === proxyReq) currentProxyReq = null;
fail(err);
});
if (bodySent || canRetry(req)) {
proxyReq.end();
} else {
bodySent = true;
req.pipe(proxyReq);
}
};
send(initialTarget, MAX_REDIRECTS);
}; };
attempt(); attempt();
} }
module.exports = { handleProxy }; module.exports = { handleProxy };
+60 -18
View File
@@ -102,25 +102,67 @@ function buildReplacements(upstream, proxyOrigin) {
add(`"url":"${bareHost}"`, `"url":"${proxyHost}"`); add(`"url":"${bareHost}"`, `"url":"${proxyHost}"`);
add(`"url": "${bareHost}"`, `"url": "${proxyHost}"`); add(`"url": "${bareHost}"`, `"url": "${proxyHost}"`);
add(upstream.username, settings.proxyUsername); // Credentials only in credential positions, so that a numeric or short
add( // username/password can never rewrite unrelated stream IDs or names.
encodeURIComponent(upstream.username), const pathForms = (value) => [
encodeURIComponent(settings.proxyUsername) value,
); encodeURIComponent(value),
add( ];
encodeQueryComponent(upstream.username), const queryForms = (value) => [
encodeQueryComponent(settings.proxyUsername) value,
); encodeURIComponent(value),
encodeQueryComponent(value),
];
add(upstream.password, settings.proxyPassword); const userPath = pathForms(upstream.username);
add( const passPath = pathForms(upstream.password);
encodeURIComponent(upstream.password), const proxyUserPath = pathForms(settings.proxyUsername);
encodeURIComponent(settings.proxyPassword) const proxyPassPath = pathForms(settings.proxyPassword);
);
add( userPath.forEach((user, index) => {
encodeQueryComponent(upstream.password), passPath.forEach((pass, passIndex) => {
encodeQueryComponent(settings.proxyPassword) add(
); `/${user}/${pass}/`,
`/${proxyUserPath[index]}/${proxyPassPath[passIndex]}/`
);
});
});
const userQuery = queryForms(upstream.username);
const passQuery = queryForms(upstream.password);
const proxyUserQuery = queryForms(settings.proxyUsername);
const proxyPassQuery = queryForms(settings.proxyPassword);
userQuery.forEach((user, index) => {
add(`username=${user}`, `username=${proxyUserQuery[index]}`);
add(`"username":"${user}"`, `"username":"${proxyUserQuery[index]}"`);
add(`"username": "${user}"`, `"username": "${proxyUserQuery[index]}"`);
passQuery.forEach((pass, passIndex) => {
add(
`username=${user}&password=${pass}`,
`username=${proxyUserQuery[index]}&password=${proxyPassQuery[passIndex]}`
);
add(
`password=${pass}&username=${user}`,
`password=${proxyPassQuery[passIndex]}&username=${proxyUserQuery[index]}`
);
add(
`"username":"${user}","password":"${pass}"`,
`"username":"${proxyUserQuery[index]}","password":"${proxyPassQuery[passIndex]}"`
);
add(
`"username": "${user}", "password": "${pass}"`,
`"username": "${proxyUserQuery[index]}", "password": "${proxyPassQuery[passIndex]}"`
);
});
});
passQuery.forEach((pass, index) => {
add(`password=${pass}`, `password=${proxyPassQuery[index]}`);
add(`"password":"${pass}"`, `"password":"${proxyPassQuery[index]}"`);
add(`"password": "${pass}"`, `"password": "${proxyPassQuery[index]}"`);
});
return replacements; return replacements;
} }
+4
View File
@@ -21,6 +21,10 @@ const settings = {
), ),
rejectUnauthorized: process.env.PROXY_INSECURE_TLS !== '1', rejectUnauthorized: process.env.PROXY_INSECURE_TLS !== '1',
allowBusyFallback: process.env.ALLOW_BUSY_FALLBACK !== '0', allowBusyFallback: process.env.ALLOW_BUSY_FALLBACK !== '0',
publicUrl: (process.env.PUBLIC_URL || process.env.PROXY_PUBLIC_URL || '')
.trim()
.replace(/\/+$/, ''),
logRequests: process.env.LOG_REQUESTS === '1',
userAgent: process.env.USER_AGENT || 'xtream-proxy/3.0', userAgent: process.env.USER_AGENT || 'xtream-proxy/3.0',
}; };