658 lines
30 KiB
JavaScript
658 lines
30 KiB
JavaScript
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; // 相对路径:<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 1,cheerio 静态)──────────────────────────────
|
||
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-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 = `<!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;
|