const express = require('express');
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const dns = require('dns');
const jwt = require('jsonwebtoken');
const cheerio = require('cheerio');
const db = require('../db');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router();
// ── SSRF 防护(保留既有逻辑)────────────────────────────────────────
function isBlockedHost(hostname) {
if (!hostname) return true;
let host = String(hostname).toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) host = mapped[1];
if (host === 'localhost' || host === '127.0.0.1' || host === '::1'
|| host === '0' || host === '0.0.0.0' || host === '::') return true;
if (host.startsWith('10.')) return true;
if (host.startsWith('192.168.')) return true;
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
if (host.startsWith('169.254.')) return true;
if (host.startsWith('fc00:') || host.startsWith('fd00:')) return true;
if (host.startsWith('fe80:')) return true;
return false;
}
function lookupIpv4(hostname) {
return new Promise((resolve) => {
dns.lookup(hostname, { family: 4, all: true }, (err, addrs) => {
if (err) return resolve([]);
resolve((addrs || []).map(a => a.address));
});
});
}
function ipv4ToInt(ip) {
const parts = String(ip).split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const p of parts) {
if (!/^\d{1,3}$/.test(p)) return null;
const v = parseInt(p, 10);
if (v > 255) return null;
n = (n << 8) + v;
}
return n >>> 0;
}
function ipv4CidrContains(cidr, host) {
const m = String(cidr).match(/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/);
if (!m) return null;
const net = ipv4ToInt(m[1]);
const hostInt = ipv4ToInt(host);
const prefix = parseInt(m[2], 10);
if (net === null || hostInt === null || prefix < 0 || prefix > 32) return null;
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (net & mask) === (hostInt & mask);
}
function proxyAllowed(hostname) {
if (!hostname) return false;
let host = String(hostname).toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) host = mapped[1];
const raw = (db.getSetting('proxy_allowed_hosts') || '').trim();
if (!raw) return false;
for (const entry of raw.split(/[,;\s]+/)) {
let e = entry.trim().toLowerCase();
if (!e) continue;
const lastColon = e.lastIndexOf(':');
if (lastColon > 0 && e.indexOf('.') !== -1 && !e.includes('/')) e = e.slice(0, lastColon);
if (e === host) return true;
if (e.includes('/') && ipv4CidrContains(e, host) === true) return true;
}
return false;
}
// SSRF 校验:返回 null=放行,否则为错误信息
async function ssrfCheck(hostname) {
if (isBlockedHost(hostname) && !proxyAllowed(hostname)) return '禁止访问内网地址';
const resolvedIps = await lookupIpv4(hostname);
if (resolvedIps.length > 0) {
const hitInternal = resolvedIps.some(ip => isBlockedHost(ip) && !proxyAllowed(ip));
if (hitInternal) return '禁止访问内网地址';
}
return null;
}
// ── 面板解析 ─────────────────────────────────────────────────────────
const RESERVED_SLUGS = new Set(['token', 'fetch']);
function getPanelBySlug(slug) {
return db.get('SELECT * FROM admin_links WHERE slug = ?', [String(slug || '').toLowerCase()]);
}
// 短 TTL 代理 token 认证 cookie 名(同源子资源鉴权用,Path=/proxy/{slug}/)
function authCookieName(slug) {
return 'rwp_' + String(slug).replace(/[^a-z0-9-]/g, '');
}
// 手动解析 Cookie 头(项目未用 cookie-parser,req.cookies 不存在)
function getCookie(req, name) {
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
}
return undefined;
}
// 校验请求者是否为管理员:Authorization header → ?token= → rwp cookie 任一有效即可
function isAuthorized(req, slug) {
const token = req.headers.authorization && req.headers.authorization.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: (req.query.token || getCookie(req, authCookieName(slug)));
if (!token) return false;
try {
const p = jwt.verify(token, SECRET);
if (!p || p.role !== 'admin') return false;
// 短代理 token(query/cookie):带 slug 时须匹配当前面板;旧版无 slug 的 5 分钟 token 任意面板放行
if (p.proxy && p.slug && p.slug !== slug) return false;
return true;
} catch { return false; }
}
// 签发面板访问 cookie(同源子资源自动携带)
function setPanelCookie(res, slug) {
const token = jwt.sign({ id: 0, username: 'panel', role: 'admin', proxy: true, slug }, SECRET, { expiresIn: '12h' });
const cookie = `${authCookieName(slug)}=${token}; Path=/proxy/${slug}/; HttpOnly; SameSite=Lax; Max-Age=43200`;
const setCookieHeader = res.getHeader('Set-Cookie');
if (setCookieHeader) {
const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [String(setCookieHeader)];
arr.push(cookie);
res.setHeader('Set-Cookie', arr);
} else {
res.setHeader('Set-Cookie', cookie);
}
}
// ── URL 改写 ─────────────────────────────────────────────────────────
// 根相对路径 /xxx → basePath + xxx;同 host 绝对 URL → basePath + path;其余原样
function rewriteUrl(value, basePath, originHost) {
const v = String(value || '').trim();
if (!v) return v;
if (/^(data:|javascript:|blob:|mailto:|tel:|about:)/i.test(v)) return v;
if (v.startsWith('//')) return v; // 协议相对外部资源:保持原样(base 下仍解析到外部 host)
if (v.startsWith('/proxy/')) return v; // 已是代理路径
if (v.startsWith('/')) return basePath + v.slice(1);
if (/^https?:\/\//i.test(v)) {
try {
const u = new URL(v);
if (u.hostname === originHost) return basePath + u.pathname + u.search + u.hash;
return v;
} catch { return v; }
}
return v; // 相对路径: 已处理
}
// srcset 逗号分隔项改写
function rewriteSrcset(value, basePath, originHost) {
return String(value || '').split(',').map((part) => {
const trimmed = part.trim();
if (!trimmed) return part;
const m = trimmed.match(/^(\S+)(\s+.*)?$/);
if (!m) return part;
return rewriteUrl(m[1], basePath, originHost) + (m[2] || '');
}).join(', ');
}
// ── 运行时 shim(Layer 2 客户端注入)────────────────────────────────
// 在目标 JS 前执行:patch fetch/XHR/WS/EventSource/元素属性 setter/history/存储隔离
function shimScript(slug, basePath) {
return ``;
}
// ── HTML 改写(Layer 1,cheerio 静态)──────────────────────────────
function rewriteHtml(html, basePath, originHost, slug) {
try {
const $ = cheerio.load(html);
// 指向代理前缀(带尾斜杠)
if ($('base').length) { $('base').attr('href', basePath); }
else { $('head').prepend(''); }
// 改写根相对/同 host 属性
$('a,link,script,img,iframe,form,video,audio,source,embed,object').each((i, el) => {
const $el = $(el);
const tag = el.tagName;
if (tag === 'a' || tag === 'link') { if ($el.attr('href')) $el.attr('href', rewriteUrl($el.attr('href'), basePath, originHost)); }
if (['script', 'img', 'iframe', 'video', 'audio', 'source', 'embed'].includes(tag)) { if ($el.attr('src')) $el.attr('src', rewriteUrl($el.attr('src'), basePath, originHost)); }
if (tag === 'form') { if ($el.attr('action')) $el.attr('action', rewriteUrl($el.attr('action'), basePath, originHost)); }
if (tag === 'object') { if ($el.attr('data')) $el.attr('data', rewriteUrl($el.attr('data'), basePath, originHost)); }
if ($el.attr('poster')) $el.attr('poster', rewriteUrl($el.attr('poster'), basePath, originHost));
if ($el.attr('srcset')) $el.attr('srcset', rewriteSrcset($el.attr('srcset'), basePath, originHost));
if ($el.attr('data-src')) $el.attr('data-src', rewriteUrl($el.attr('data-src'), basePath, originHost));
if ($el.attr('data-href')) $el.attr('data-href', rewriteUrl($el.attr('data-href'), basePath, originHost));
});
// meta[content](og 等 URL 型)
$('meta[content]').each((i, el) => {
const $el = $(el);
const prop = String($el.attr('property') || $el.attr('name') || '').toLowerCase();
if (prop.includes('image') || prop.includes('url') || prop.includes('og:')) {
$el.attr('content', rewriteUrl($el.attr('content'), basePath, originHost));
}
});
// 剥 SRI integrity / crossorigin(同源代理下无意义且可能失败)
$('[integrity]').removeAttr('integrity');
$('[crossorigin]').removeAttr('crossorigin');
// 剥 CSP/XFO meta
$('meta[http-equiv]').each((i, el) => {
const he = String($(el).attr('http-equiv') || '').toLowerCase();
if (he === 'content-security-policy' || he === 'x-frame-options' || he === 'content-script-type') $(el).remove();
});
// 注入 shim(head 最前,早于目标脚本执行)
$('head').prepend(shimScript(slug, basePath));
return $.html();
} catch (e) {
console.error('Proxy HTML rewrite error:', e.message);
// 改写失败:至少注入 base + shim(降级)
return html.replace('
', `` + shimScript(slug, basePath)) || html;
}
}
// ── 响应头处理(Layer 3)────────────────────────────────────────────
const HOP_BY_HOP = ['connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization'];
function rewriteCookie(cookie, basePath, isHttps) {
return String(cookie).split(';').map((part, i) => {
const p = part.trim();
if (i === 0) return p; // name=value
const l = p.toLowerCase();
if (l.startsWith('path=')) return 'Path=' + basePath;
if (l.startsWith('domain=')) return ''; // 去 Domain
if (l === 'secure' && !isHttps) return ''; // http 下剥 Secure
if (l.startsWith('samesite')) return 'SameSite=Lax';
return p;
}).filter(Boolean).join('; ');
}
function rewriteLocationHeader(value, basePath, originHost) {
const v = String(value || '');
if (v.startsWith('/proxy/')) return v;
if (v.startsWith('/')) return basePath + v.slice(1);
if (/^https?:\/\//i.test(v)) {
try {
const u = new URL(v);
if (u.hostname === originHost) return basePath + u.pathname + u.search;
} catch {}
}
return v;
}
// ── 解压响应流(支持 gzip/deflate/br)──────────────────────────────
function decodeStream(stream, encoding) {
const enc = String(encoding || '').toLowerCase();
if (enc === 'gzip' || enc === 'x-gzip') return stream.pipe(zlib.createGunzip());
if (enc === 'deflate') return stream.pipe(zlib.createInflate());
if (enc === 'br') return stream.pipe(zlib.createBrotliDecompress());
return stream;
}
// ── HTTP 代理(全方法 + body 透传)──────────────────────────────────
function proxyHttp(slug, reqPath, query, req, res, panel) {
let base;
try { base = new URL(panel.url); } catch { return sendError(res, '面板 URL 无效'); }
const basePath = '/proxy/' + slug + '/';
const client = base.protocol === 'https:' ? https : http;
const targetPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : '');
// 转发头:剥 hop-by-hop,host 重写为目标,合并 proxy_headers
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
const lk = k.toLowerCase();
if (HOP_BY_HOP.includes(lk) || lk === 'host' || lk.startsWith('rwp_') || lk === 'content-length') continue;
if (lk === 'cookie') {
// 只透传非面板鉴权 cookie(剥 rwp_*)
const kept = String(v).split(';').map(c => c.trim()).filter(c => !/^rwp_/i.test(c)).join('; ');
if (kept) headers[k] = kept;
continue;
}
headers[k] = v;
}
headers.host = base.host;
// 自定义转发头(proxy_headers JSON)
try {
const ph = JSON.parse(panel.proxy_headers || '{}');
if (ph && typeof ph === 'object') {
for (const [k, v] of Object.entries(ph)) {
if (!/^content-length$/i.test(k) && !HOP_BY_HOP.includes(k.toLowerCase())) headers[k] = String(v);
}
}
} catch {}
const upReq = client.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: targetPath,
method: req.method,
headers,
timeout: 20000,
family: 4,
// proxy_skip_tls_verify=1 时跳过 TLS 校验(内网自签面板);默认校验(公网面板)
rejectUnauthorized: Number(panel.proxy_skip_tls_verify) ? false : true,
}, (upRes) => {
// 响应头:剥 XFO/CSP/Permissions-Policy,改写 Cookie/Location
const out = {};
const isHttps = req.secure || req.protocol === 'https';
for (const [k, v] of Object.entries(upRes.headers)) {
const lk = k.toLowerCase();
if (['x-frame-options', 'content-security-policy', 'permissions-policy'].includes(lk)) continue;
if (lk === 'set-cookie') { out[k] = (Array.isArray(v) ? v : [v]).map(c => rewriteCookie(c, basePath, isHttps)); continue; }
if (lk === 'location' || lk === 'refresh') { out[k] = rewriteLocationHeader(v, basePath, base.hostname); continue; }
if (HOP_BY_HOP.includes(lk)) continue;
if (lk === 'content-length' || lk === 'content-encoding') continue; // 统一转码后重算
out[k] = v;
}
out['Referrer-Policy'] = 'no-referrer';
out['X-Content-Type-Options'] = 'nosniff';
const ctype = (upRes.headers['content-type'] || '').toLowerCase();
const status = upRes.statusCode || 200;
// HTML:解压 → 改写 → 按 identity 发送
if (ctype.includes('text/html') && (status >= 200 && status < 300)) {
const chunks = [];
const dec = decodeStream(upRes, upRes.headers['content-encoding']);
dec.on('data', c => chunks.push(c));
dec.on('end', () => {
try {
let html = Buffer.concat(chunks).toString('utf8');
html = rewriteHtml(html, basePath, base.hostname, slug);
setPanelCookie(res, slug); // 首次加载即下发面板 cookie,子资源同源自动携带
const buf = Buffer.from(html, 'utf8');
out['Content-Type'] = 'text/html; charset=utf-8';
out['Content-Length'] = buf.length;
res.writeHead(status, out);
res.end(buf);
} catch (e) {
console.error('Proxy HTML process error:', e.message);
sendError(res, '代理响应处理失败: ' + e.message);
}
});
dec.on('error', (e) => { console.error('Proxy decode error:', e.message); sendError(res, '代理响应解码失败'); });
return;
}
// 非 HTML:原样透传(保留 content-encoding/content-length)
if (upRes.headers['content-length']) out['Content-Length'] = upRes.headers['content-length'];
if (upRes.headers['content-encoding']) out['Content-Encoding'] = upRes.headers['content-encoding'];
res.writeHead(status, out);
upRes.pipe(res);
});
upReq.on('timeout', () => { upReq.destroy(); sendError(res, '代理请求超时(20秒)'); });
upReq.on('error', (e) => { sendError(res, '代理请求失败: ' + e.message); });
// body 透传(POST/PUT/PATCH):application/json 已被 express.json 消费 → 重新序列化发送;
// 其余 content-type(form/multipart/raw)请求流完整 → 直接 pipe
const isJsonBody = /^application\/json/i.test(String(req.headers['content-type'] || ''));
if (isJsonBody && req.body !== undefined) {
const body = JSON.stringify(req.body);
upReq.setHeader('Content-Length', Buffer.byteLength(body));
upReq.end(body);
} else {
req.pipe(upReq);
}
}
// ── WebSocket 代理(upgrade 事件用)────────────────────────────────
// 返回 true=已接管;false=不处理(调用方销毁 socket)
function proxyWsUpgrade(slug, reqPath, query, request, socket, head) {
const panel = getPanelBySlug(slug);
if (!panel || !panel.url) return false;
let base;
try { base = new URL(panel.url); } catch { return false; }
// SSRF 校验
if (isBlockedHost(base.hostname) && !proxyAllowed(base.hostname)) return false;
const client = base.protocol === 'https:' ? https : http;
const wsPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : '');
const headers = {};
for (const h of ['upgrade', 'connection', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions']) {
if (request.headers[h]) headers[h] = request.headers[h];
}
headers.host = base.host;
headers.origin = base.protocol + '//' + base.host;
const upReq = client.request({
hostname: base.hostname,
port: base.port || (base.protocol === 'https:' ? 443 : 80),
path: wsPath,
method: 'GET',
headers,
family: 4,
rejectUnauthorized: false,
});
upReq.on('upgrade', (upRes, upSocket, upHead) => {
// 转发 101 头:Connection/Upgrade 必须保留(Node 客户端据此识别 upgrade),
// 仅剥 transfer-encoding/keep-alive 等无关 hop-by-hop
let respHead = 'HTTP/1.1 101 Switching Protocols\r\n';
const skip101 = ['transfer-encoding', 'keep-alive', 'te', 'trailer', 'proxy-authenticate', 'proxy-authorization'];
for (const [k, v] of Object.entries(upRes.headers)) {
if (skip101.includes(k.toLowerCase())) continue;
const val = Array.isArray(v) ? v.join(', ') : v;
respHead += k + ': ' + val + '\r\n';
}
respHead += '\r\n';
try {
socket.write(respHead);
if (upHead && upHead.length) socket.write(upHead);
upSocket.pipe(socket);
socket.pipe(upSocket);
socket.on('error', () => { try { upSocket.destroy(); } catch {} });
upSocket.on('error', () => { try { socket.destroy(); } catch {} });
} catch (e) {
console.error('WS proxy pipe error:', e.message);
try { upSocket.destroy(); } catch {}
try { socket.destroy(); } catch {}
}
});
upReq.on('error', (e) => { console.error('WS proxy error:', e.message); socket.destroy(); });
upReq.on('timeout', () => { upReq.destroy(); socket.destroy(); });
upReq.end();
return true;
}
// 供 server.js upgrade 事件调用:解析 /proxy/:slug/ 前缀的 WS 请求
function handleProxyUpgrade(request, socket, head) {
try {
const u = new URL(request.url, 'http://x');
const m = u.pathname.match(/^\/proxy\/([^/]+)\/?(.*)$/);
if (!m) return false;
const slug = m[1];
const reqPath = m[2] ? '/' + m[2] : '/';
return proxyWsUpgrade(slug, reqPath, u.search ? u.search.slice(1) : '', request, socket, head);
} catch { return false; }
}
function sendError(res, msg) {
console.error('Proxy error:', msg);
const html = ``;
if (res && !res.headersSent) res.status(502).send(html);
}
// ── 路由 ─────────────────────────────────────────────────────────────
// 兼容 iframe/直链的 query token 认证(旧 /fetch 用)
function queryTokenAuth(req, res, next) {
if (req.query.token) req.headers.authorization = 'Bearer ' + req.query.token;
next();
}
// 短 TTL 代理 token(保留)
router.get('/token', authMiddleware, adminOnly, (req, res) => {
const token = jwt.sign(
{ id: req.user.id, username: req.user.username, role: req.user.role, proxy: true },
SECRET,
{ expiresIn: '5m' }
);
res.json({ token });
});
// 旧版单 URL 透传(保留兼容)
router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, async (req, res) => {
if (!req.query.url) return res.status(400).json({ error: '缺少 url 参数' });
let url;
try { url = new URL(req.query.url); } catch { return res.status(400).json({ error: '无效的 URL' }); }
const err = await ssrfCheck(url.hostname);
if (err) return res.status(403).json({ error: err });
proxyLegacyFetch(url, res, '/proxy/legacy/');
});
// 旧 /fetch 的底层实现(保留原行为:HTML 注入 + shim + 剥头)
function proxyLegacyFetch(url, res, basePath) {
const client = url.protocol === 'https:' ? https : http;
const req = client.get({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
timeout: 15000,
family: 4,
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*' },
rejectUnauthorized: false,
}, (upRes) => {
const headers = { ...upRes.headers };
delete headers['x-frame-options'];
delete headers['content-security-policy'];
headers['Referrer-Policy'] = 'no-referrer';
headers['X-Content-Type-Options'] = 'nosniff';
const ctype = (headers['content-type'] || '').toLowerCase();
if (ctype.includes('text/html')) {
const chunks = [];
upRes.on('data', c => chunks.push(c));
upRes.on('end', () => {
const origin = url.hostname;
let html = Buffer.concat(chunks).toString('utf8');
html = rewriteHtml(html, basePath, origin, 'legacy');
const buf = Buffer.from(html, 'utf8');
headers['Content-Length'] = buf.length;
headers['Content-Type'] = 'text/html; charset=utf-8';
res.writeHead(upRes.statusCode || 200, headers);
res.end(buf);
});
} else {
res.writeHead(upRes.statusCode || 200, headers);
upRes.pipe(res);
}
});
req.on('error', e => sendError(res, '代理请求失败: ' + e.message));
req.on('timeout', () => { req.destroy(); sendError(res, '代理请求超时'); });
}
// ── /proxy/:slug 前缀代理(核心)──────────────────────────────────
// 挂载于 server.js 的 app.use('/proxy', proxyRoutes)(在 express.json 之前,保证 body 流完整)
// 解析 slug 与鉴权的公共处理器
function prefixAuth(req, res, next) {
const slug = req.params.slug;
if (!slug || RESERVED_SLUGS.has(slug)) return sendError(res, '面板不存在');
const panel = getPanelBySlug(slug);
if (!panel) return sendError(res, '面板不存在');
if (!isAuthorized(req, slug)) return res.status(403).json({ error: '未授权' });
req.panel = panel;
req.proxySlug = slug;
next();
}
// 面板内网/SSRF 校验(异步)
async function prefixCheck(req, res, next) {
try {
const base = new URL(req.panel.url);
const err = await ssrfCheck(base.hostname);
if (err) return res.status(403).json({ error: err });
next();
} catch { return sendError(res, '面板 URL 无效'); }
}
function prefixProxy(req, res) {
const slug = req.proxySlug;
const basePath = '/proxy/' + slug + '/';
// 去掉 /proxy/{slug} 前缀后的目标路径
const m = String(req.path).match(/^\/[^/]+(?:\/(.*))?$/);
const reqPath = m && m[1] ? '/' + m[1] : '/';
const query = req.url.indexOf('?') !== -1 ? req.url.slice(req.url.indexOf('?') + 1) : '';
proxyHttp(slug, reqPath, query, req, res, req.panel);
}
router.all('/:slug', prefixAuth, prefixCheck, prefixProxy);
router.all('/:slug/*', prefixAuth, prefixCheck, prefixProxy);
module.exports = router;
module.exports.handleProxyUpgrade = handleProxyUpgrade;