Files
rainblogweb/routes/proxy.js
T

658 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-parserreq.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;
// 短代理 tokenquery/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; // 相对路径:<base> 已处理
}
// 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 `<script>/* RainWeb 面板代理 shim */
(function(){
var P=${JSON.stringify(basePath)};
var SLUG=${JSON.stringify(slug)};
// 恢复前缀:根相对/同 host 绝对 → 加 /proxy/{slug}/ 前缀
function rP(u){
if(typeof u!=='string'||!u) return u;
if(u.charAt(0)==='/'&&u.charAt(1)!=='/') return P+u.slice(1);
if(/^https?:\/\//i.test(u)){ try{var a=new URL(u);if(a.host===location.host)return P+a.pathname+a.search+a.hash;}catch(e){} }
return u;
}
function rWs(u){
if(typeof u!=='string'||!u) return u;
var proto=(location.protocol==='https:')?'wss://':'ws://';
if(u.charAt(0)==='/') return proto+location.host+P+u.slice(1);
if(/^wss?:/i.test(u)){ try{var a=new URL(u.replace(/^ws/i,'http'));if(a.host===location.host)return proto+location.host+P+a.pathname+a.search;}catch(e){} }
if(/^https?:\/\//i.test(u)){ try{var b=new URL(u);if(b.host===location.host)return proto+location.host+P+b.pathname+b.search;}catch(e){} }
return u;
}
// document.baseURI 指向代理前缀
try{Object.defineProperty(document,'baseURI',{configurable:true,get:function(){return location.origin+P;}});}catch(e){}
// fetch
if(window.fetch){
var of=window.fetch;
window.fetch=function(input,init){
if(typeof input==='string'){input=rP(input);}
else if(input&&input.url){input=new Request(rP(input.url),input);}
return of(input,init);
};
}
// XHR
var _ox=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(method,url){return _ox.apply(this,arguments.length>1?[method,rP(url)]:arguments);};
// sendBeacon
if(navigator.sendBeacon){var _sb=navigator.sendBeacon.bind(navigator);navigator.sendBeacon=function(url,data){return _sb(rP(url),data);};}
// WebSocket
var _WS=window.WebSocket;
window.WebSocket=function(url,protocols){return protocols?new _WS(rWs(url),protocols):new _WS(rWs(url));};
window.WebSocket.prototype=_WS.prototype;
window.WebSocket.CONNECTING=0;window.WebSocket.OPEN=1;window.WebSocket.CLOSING=2;window.WebSocket.CLOSED=3;
// EventSource
if(window.EventSource){var _ES=window.EventSource;window.EventSource=function(url,c){return c?new _ES(rWs(url),c):new _ES(rWs(url));};window.EventSource.prototype=_ES.prototype;}
// 元素属性 setter 同步改写
function patch(proto,prop){
var d=Object.getOwnPropertyDescriptor(proto,prop);
if(!d||!d.set)return;
Object.defineProperty(proto,prop,{configurable:true,get:function(){return d.get.call(this);},set:function(v){if(typeof v==='string')v=rP(v);d.set.call(this,v);}});
}
if(HTMLImageElement)patch(HTMLImageElement.prototype,'src');
if(HTMLScriptElement)patch(HTMLScriptElement.prototype,'src');
if(HTMLLinkElement)patch(HTMLLinkElement.prototype,'href');
if(HTMLAnchorElement)patch(HTMLAnchorElement.prototype,'href');
if(HTMLFormElement)patch(HTMLFormElement.prototype,'action');
if(HTMLIFrameElement)patch(HTMLIFrameElement.prototype,'src');
if(HTMLVideoElement)patch(HTMLVideoElement.prototype,'src');
if(HTMLAudioElement)patch(HTMLAudioElement.prototype,'src');
if(HTMLSourceElement)patch(HTMLSourceElement.prototype,'src');
if(HTMLEmbedElement)patch(HTMLEmbedElement.prototype,'src');
if(HTMLObjectElement)patch(HTMLObjectElement.prototype,'data');
// history:写入干净路径时自动加回前缀;location.pathname 读取时去前缀
var _hs=history.pushState,_rs=history.replaceState;
function cleanPath(p){p=p||'';if(p.indexOf(P)===0)return p.slice(P.length-1);return p;}
history.pushState=function(s,t,u){return _hs.call(this,s,t,u?P+String(u).replace(/^\\/,'').replace(/^\//,''):u);};
history.replaceState=function(s,t,u){return _rs.call(this,s,t,u?P+String(u).replace(/^\\/,'').replace(/^\//,''):u);};
try{var _pl=Object.getOwnPropertyDescriptor(Location.prototype,'pathname');
Object.defineProperty(Location.prototype,'pathname',{configurable:true,get:function(){var v=_pl.get.call(this);return cleanPath(v);},set:function(v){_pl.set.call(this,v.indexOf(P)===0?v:P+String(v).replace(/^\\/,'').replace(/^\//,''));}});
}catch(e){}
// 存储命名空间隔离(按 slug)
function nsStore(st){
var prefix=SLUG+':';
var g=st.getItem.bind(st),s=st.setItem.bind(st),r=st.removeItem.bind(st),c=st.clear.bind(st),k=st.key.bind(st),gl=st.length;
st.getItem=function(n){return g(prefix+n);};
st.setItem=function(n,v){return s(prefix+n,v);};
st.removeItem=function(n){return r(prefix+n);};
st.clear=function(){var keys=[];for(var i=0;i<gl;i++){var kk=k(i);if(kk&&kk.indexOf(prefix)===0)keys.push(kk);}keys.forEach(function(x){r(x);});};
st.key=function(i){return k(i);};
}
try{if(window.localStorage)nsStore(window.localStorage);}catch(e){}
try{if(window.sessionStorage)nsStore(window.sessionStorage);}catch(e){}
// serviceWorker 禁用
if(navigator.serviceWorker&&navigator.serviceWorker.register){navigator.serviceWorker.register=function(){return Promise.resolve({});};}
// parent/top 隔离到自身
try{Object.defineProperty(window,'parent',{configurable:true,get:function(){return window;}});}catch(e){}
try{Object.defineProperty(window,'top',{configurable:true,get:function(){return window;}});}catch(e){}
// MutationObserver 兜底:动态插入元素的属性改写
try{
function fixNode(n){
if(!n||n.nodeType!==1)return;
var attrs=['src','href','action','poster','data-src','data-href'];
for(var i=0;i<attrs.length;i++){var a=attrs[i];if(n.hasAttribute&&n.hasAttribute(a)){n.setAttribute(a,rP(n.getAttribute(a)));}}
if(n.tagName==='IMG'&&n.src)try{n.src=rP(n.src);}catch(e){}
if(n.tagName==='SCRIPT'&&n.src)try{n.src=rP(n.src);}catch(e){}
}
var _mo=window.MutationObserver;
if(_mo){var mo=new _mo(function(muts){muts.forEach(function(mu){if(mu.type==='childList'){mu.addedNodes.forEach(fixNode);}});});mo.observe(document.documentElement,{childList:true,subtree:true});}
}catch(e){}
})();
</script>`;
}
// ── HTML 改写(Layer 1cheerio 静态)──────────────────────────────
function rewriteHtml(html, basePath, originHost, slug) {
try {
const $ = cheerio.load(html);
// <base> 指向代理前缀(带尾斜杠)
if ($('base').length) { $('base').attr('href', basePath); }
else { $('head').prepend('<base href="' + basePath + '">'); }
// 改写根相对/同 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('<head>', `<head><base href="${basePath}">` + 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-hophost 重写为目标,合并 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-typeform/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 = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5;color:#333}
.box{max-width:480px;padding:32px;text-align:center;background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08)}
h2{color:#d32f2f;margin:0 0 8px;font-size:20px}
p{color:#666;font-size:14px;line-height:1.6;margin:0}
code{display:block;font-size:13px;background:#f5f5f5;padding:8px 12px;border-radius:8px;margin-top:12px;word-break:break-all}
</style></head><body><div class="box"><h2>⚠️ 代理加载失败</h2><p>${msg}</p></div></body></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 注入 <base> + 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;