199 lines
5.2 KiB
JavaScript
199 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const express = require('express');
|
|
const httpProxy = require('http-proxy');
|
|
|
|
const LISTEN_PORT = Number(process.env.LISTEN_PORT || 8080);
|
|
const HOSTS_FILE = process.env.HOSTS_FILE || '/config/hosts.txt';
|
|
const CHECK_INTERVAL_SEC = Math.max(5, Number(process.env.CHECK_INTERVAL_SEC || 30));
|
|
const PING_TIMEOUT_MS = Math.max(1000, Number(process.env.PING_TIMEOUT_MS || 8000));
|
|
const HEALTH_PATH = process.env.HEALTH_PATH || '/';
|
|
|
|
let currentTarget = null;
|
|
let lastStats = { checkedAt: null, results: [], selected: null };
|
|
|
|
const proxy = httpProxy.createProxyServer({
|
|
ws: true,
|
|
xfwd: true,
|
|
proxyTimeout: 0,
|
|
});
|
|
|
|
proxy.on('error', (err, req, res) => {
|
|
if (res && !res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
res.end(`Upstream error: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
function readHostsFile() {
|
|
if (!fs.existsSync(HOSTS_FILE)) {
|
|
console.error(`Hosts file not found: ${HOSTS_FILE}`);
|
|
return [];
|
|
}
|
|
const raw = fs.readFileSync(HOSTS_FILE, 'utf8');
|
|
const lines = raw.split(/\r?\n/);
|
|
const urls = [];
|
|
for (const line of lines) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith('#')) continue;
|
|
try {
|
|
const u = new URL(t);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
|
|
const base = `${u.protocol}//${u.host}`;
|
|
urls.push(base);
|
|
} catch {
|
|
console.warn(`Skipping invalid URL line: ${t}`);
|
|
}
|
|
}
|
|
return [...new Set(urls)];
|
|
}
|
|
|
|
function pingBase(base, path) {
|
|
return new Promise((resolve) => {
|
|
let url;
|
|
try {
|
|
const p = path.startsWith('/') ? path : `/${path}`;
|
|
url = new URL(p, base.endsWith('/') ? base : `${base}/`);
|
|
} catch {
|
|
resolve({ base, ok: false, ms: null, error: 'bad_url' });
|
|
return;
|
|
}
|
|
|
|
const lib = url.protocol === 'https:' ? https : http;
|
|
const start = Date.now();
|
|
const req = lib.request(
|
|
{
|
|
protocol: url.protocol,
|
|
hostname: url.hostname,
|
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
path: url.pathname + url.search,
|
|
method: 'GET',
|
|
timeout: PING_TIMEOUT_MS,
|
|
headers: {
|
|
'User-Agent': 'xtream-proxy-health/1.0',
|
|
Connection: 'close',
|
|
},
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
res.on('end', () => {
|
|
const ms = Date.now() - start;
|
|
const ok = res.statusCode >= 200 && res.statusCode < 500;
|
|
resolve({ base, ok, ms, status: res.statusCode });
|
|
});
|
|
res.on('error', () => {
|
|
resolve({ base, ok: false, ms: null, error: 'response_error' });
|
|
});
|
|
}
|
|
);
|
|
req.on('error', (e) => {
|
|
resolve({ base, ok: false, ms: null, error: e.code || e.message });
|
|
});
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
resolve({ base, ok: false, ms: null, error: 'timeout' });
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function pickFastestHost() {
|
|
const hosts = readHostsFile();
|
|
if (hosts.length === 0) {
|
|
console.warn('No valid hosts in file; keeping previous target if any.');
|
|
lastStats = {
|
|
checkedAt: new Date().toISOString(),
|
|
results: [],
|
|
selected: currentTarget,
|
|
};
|
|
return;
|
|
}
|
|
|
|
const pings = await Promise.all(hosts.map((h) => pingBase(h, HEALTH_PATH)));
|
|
const ok = pings.filter((p) => p.ok && typeof p.ms === 'number');
|
|
ok.sort((a, b) => a.ms - b.ms);
|
|
|
|
lastStats = {
|
|
checkedAt: new Date().toISOString(),
|
|
results: pings,
|
|
selected: ok[0]?.base ?? currentTarget,
|
|
};
|
|
|
|
if (ok[0]) {
|
|
if (currentTarget !== ok[0].base) {
|
|
console.log(
|
|
`Selected upstream ${ok[0].base} (${ok[0].ms} ms). Others: ${ok
|
|
.slice(1)
|
|
.map((x) => `${x.base}=${x.ms}ms`)
|
|
.join(', ') || 'none'}`
|
|
);
|
|
}
|
|
currentTarget = ok[0].base;
|
|
} else {
|
|
console.warn(
|
|
'No host responded successfully; keeping previous target:',
|
|
currentTarget || '(none)'
|
|
);
|
|
}
|
|
}
|
|
|
|
function scheduleChecks() {
|
|
setInterval(() => {
|
|
pickFastestHost().catch((e) => console.error('Health check error:', e));
|
|
}, CHECK_INTERVAL_SEC * 1000);
|
|
}
|
|
|
|
const app = express();
|
|
|
|
app.get('/health', (_req, res) => {
|
|
res.json({
|
|
ok: Boolean(currentTarget),
|
|
upstream: currentTarget,
|
|
intervalSec: CHECK_INTERVAL_SEC,
|
|
lastCheck: lastStats,
|
|
});
|
|
});
|
|
|
|
app.use((req, res) => {
|
|
if (!currentTarget) {
|
|
res.status(503).type('text/plain').send('No upstream available yet (hosts file empty or all pings failed).');
|
|
return;
|
|
}
|
|
proxy.web(req, res, {
|
|
target: currentTarget,
|
|
changeOrigin: true,
|
|
secure: process.env.PROXY_INSECURE_TLS !== '1',
|
|
});
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
if (!currentTarget) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
proxy.ws(req, socket, head, {
|
|
target: currentTarget,
|
|
changeOrigin: true,
|
|
secure: process.env.PROXY_INSECURE_TLS !== '1',
|
|
});
|
|
});
|
|
|
|
async function main() {
|
|
await pickFastestHost();
|
|
server.listen(LISTEN_PORT, () => {
|
|
console.log(`Xtream proxy listening on :${LISTEN_PORT}`);
|
|
console.log(`Hosts file: ${HOSTS_FILE}, interval: ${CHECK_INTERVAL_SEC}s, health path: ${HEALTH_PATH}`);
|
|
scheduleChecks();
|
|
});
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|