diff --git a/Dockers/xtream-proxy/Dockerfile b/Dockers/xtream-proxy/Dockerfile index 56d4389..3fca880 100644 --- a/Dockers/xtream-proxy/Dockerfile +++ b/Dockers/xtream-proxy/Dockerfile @@ -22,6 +22,8 @@ ENV LISTEN_PORT=8080 \ PROBE_TIMEOUT_MS=8000 \ UPSTREAM_TIMEOUT_MS=15000 \ ALLOW_BUSY_FALLBACK=1 \ + PUBLIC_URL="" \ + LOG_REQUESTS=0 \ PROXY_INSECURE_TLS=0 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ diff --git a/Dockers/xtream-proxy/docker-compose.example.yml b/Dockers/xtream-proxy/docker-compose.example.yml index 076e8f0..0375db9 100644 --- a/Dockers/xtream-proxy/docker-compose.example.yml +++ b/Dockers/xtream-proxy/docker-compose.example.yml @@ -11,6 +11,10 @@ services: PROBE_TIMEOUT_MS: "8000" UPSTREAM_TIMEOUT_MS: "15000" 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 volumes: - ./upstreams.json:/config/upstreams.json:ro diff --git a/Dockers/xtream-proxy/src/health.js b/Dockers/xtream-proxy/src/health.js index 212ef36..9c69b7b 100644 --- a/Dockers/xtream-proxy/src/health.js +++ b/Dockers/xtream-proxy/src/health.js @@ -17,6 +17,7 @@ function healthPayload() { return { ok: upstreams.some((upstream) => upstream.state.healthy), configFile: settings.configFile, + publicUrl: settings.publicUrl || null, probeIntervalSec: settings.probeIntervalSec, lastProbeAt: getLastProbeAt(), maxConnections: totalConnectionCapacity(), diff --git a/Dockers/xtream-proxy/src/http-utils.js b/Dockers/xtream-proxy/src/http-utils.js index fc5fe7c..9929357 100644 --- a/Dockers/xtream-proxy/src/http-utils.js +++ b/Dockers/xtream-proxy/src/http-utils.js @@ -1,5 +1,7 @@ 'use strict'; +const { settings } = require('./settings'); + function sendText(res, statusCode, text, extraHeaders = {}) { res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8', @@ -26,6 +28,8 @@ function firstHeader(value) { } function getProxyOrigin(req) { + if (settings.publicUrl) return settings.publicUrl; + const proto = firstHeader(req.headers['x-forwarded-proto']) || (req.socket.encrypted ? 'https' : 'http'); diff --git a/Dockers/xtream-proxy/src/proxy.js b/Dockers/xtream-proxy/src/proxy.js index 8d6ee35..67e88d6 100644 --- a/Dockers/xtream-proxy/src/proxy.js +++ b/Dockers/xtream-proxy/src/proxy.js @@ -25,6 +25,9 @@ const { } = require('./rewrite'); const { sendText, getProxyOrigin } = require('./http-utils'); +const MAX_REDIRECTS = 5; +const AUTH_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; + function nowIso() { return new Date().toISOString(); } @@ -40,7 +43,72 @@ function markUpstreamFailure(upstream, error) { 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) { if (req.method === 'HEAD') return false; @@ -48,8 +116,11 @@ function isAuthRequest(req, reqUrl) { return /\/player_api\.php\/?$/i.test(reqUrl.pathname); } -function applyConnectionCapacity(body, upstream, proxyOrigin) { +function applyAuthRewrites(body, upstream, proxyOrigin) { let text = body.toString('utf8'); + const proxyUrl = new URL(proxyOrigin); + const proxyPort = + proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'); try { const json = JSON.parse(text); @@ -59,9 +130,25 @@ function applyConnectionCapacity(body, upstream, proxyOrigin) { if (max > 0) { info.max_connections = 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 {} return Buffer.from(rewriteUrlValue(text, upstream, proxyOrigin)); @@ -97,7 +184,7 @@ function sendAuthResponse(proxyRes, res, upstream, proxyOrigin) { } 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 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); - 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; + const fail = (error) => { release(); if (closed) return; - markUpstreamFailure(upstream, err); + markUpstreamFailure(upstream, error); if (!res.headersSent && canRetry(req) && attempted.size < upstreamCount) { attempt(); @@ -301,12 +276,196 @@ function handleProxy(req, res, reqUrl) { } else { 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(); } -module.exports = { handleProxy }; +module.exports = { handleProxy }; \ No newline at end of file diff --git a/Dockers/xtream-proxy/src/rewrite.js b/Dockers/xtream-proxy/src/rewrite.js index e801a58..6c629cc 100644 --- a/Dockers/xtream-proxy/src/rewrite.js +++ b/Dockers/xtream-proxy/src/rewrite.js @@ -102,25 +102,67 @@ function buildReplacements(upstream, proxyOrigin) { add(`"url":"${bareHost}"`, `"url":"${proxyHost}"`); add(`"url": "${bareHost}"`, `"url": "${proxyHost}"`); - add(upstream.username, settings.proxyUsername); - add( - encodeURIComponent(upstream.username), - encodeURIComponent(settings.proxyUsername) - ); - add( - encodeQueryComponent(upstream.username), - encodeQueryComponent(settings.proxyUsername) - ); + // Credentials only in credential positions, so that a numeric or short + // username/password can never rewrite unrelated stream IDs or names. + const pathForms = (value) => [ + value, + encodeURIComponent(value), + ]; + const queryForms = (value) => [ + value, + encodeURIComponent(value), + encodeQueryComponent(value), + ]; - add(upstream.password, settings.proxyPassword); - add( - encodeURIComponent(upstream.password), - encodeURIComponent(settings.proxyPassword) - ); - add( - encodeQueryComponent(upstream.password), - encodeQueryComponent(settings.proxyPassword) - ); + const userPath = pathForms(upstream.username); + const passPath = pathForms(upstream.password); + const proxyUserPath = pathForms(settings.proxyUsername); + const proxyPassPath = pathForms(settings.proxyPassword); + + userPath.forEach((user, index) => { + passPath.forEach((pass, passIndex) => { + 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; } diff --git a/Dockers/xtream-proxy/src/settings.js b/Dockers/xtream-proxy/src/settings.js index 88b8d6f..6de98ef 100644 --- a/Dockers/xtream-proxy/src/settings.js +++ b/Dockers/xtream-proxy/src/settings.js @@ -21,6 +21,10 @@ const settings = { ), rejectUnauthorized: process.env.PROXY_INSECURE_TLS !== '1', 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', };