This commit is contained in:
1 parent
2942f48ded
commit
1276dac371
7 files changed
+358
-142
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
+283
-124
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user