Migrate From Bun to Express
This commit is contained in:
parent
b525cc0dd0
commit
d2c014e744
8 changed files with 3054 additions and 668 deletions
|
|
@ -381,83 +381,82 @@ function isBlockedIPExtended(ip) {
|
|||
}
|
||||
|
||||
function IPBlockMiddleware() {
|
||||
return async (request, server) => {
|
||||
const clientIP = getRealIP(request, server);
|
||||
logs.plugin('ipfilter', `Incoming request from IP: ${clientIP}`);
|
||||
const [blocked, blockType, blockValue, customPage, asnOrgName] = isBlockedIPExtended(clientIP);
|
||||
return {
|
||||
middleware: async (req, res, next) => {
|
||||
// Convert Express request to the format expected by ipfilter logic
|
||||
const request = {
|
||||
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
|
||||
headers: {
|
||||
get: (name) => req.get(name),
|
||||
entries: () => Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v])
|
||||
}
|
||||
};
|
||||
|
||||
const clientIP = getRealIP(request);
|
||||
logs.plugin('ipfilter', `Incoming request from IP: ${clientIP}`);
|
||||
const [blocked, blockType, blockValue, customPage, asnOrgName] = isBlockedIPExtended(clientIP);
|
||||
|
||||
if (blocked) {
|
||||
recordEvent('ipfilter.block', {
|
||||
type: blockType,
|
||||
value: blockValue,
|
||||
asn_org: asnOrgName,
|
||||
ip: clientIP, // Include the IP address for stats
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
if (blocked) {
|
||||
recordEvent('ipfilter.block', {
|
||||
type: blockType,
|
||||
value: blockValue,
|
||||
asn_org: asnOrgName,
|
||||
ip: clientIP, // Include the IP address for stats
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied from your location or network.',
|
||||
reason: 'geoip',
|
||||
type: blockType,
|
||||
value: blockValue,
|
||||
asn_org: asnOrgName,
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize page paths by stripping leading slash
|
||||
const cleanCustomPage = customPage.replace(/^\/+/, '');
|
||||
const cleanDefaultPage = defaultBlockPage.replace(/^\/+/, '');
|
||||
|
||||
let html = '';
|
||||
logs.plugin(
|
||||
'ipfilter',
|
||||
`Block pages: custom="${cleanCustomPage}", default="${cleanDefaultPage}"`,
|
||||
);
|
||||
}
|
||||
logs.plugin('ipfilter', 'Searching for block page in the following locations:');
|
||||
const paths = [
|
||||
// allow absolute paths relative to project root first
|
||||
join(rootDir, cleanCustomPage),
|
||||
];
|
||||
// Fallback to default block page if custom page isn't found
|
||||
if (customPage !== defaultBlockPage) {
|
||||
paths.push(
|
||||
// check default page at root directory
|
||||
join(rootDir, cleanDefaultPage),
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize page paths by stripping leading slash
|
||||
const cleanCustomPage = customPage.replace(/^\/+/, '');
|
||||
const cleanDefaultPage = defaultBlockPage.replace(/^\/+/, '');
|
||||
for (const p of paths) {
|
||||
logs.plugin('ipfilter', `Trying block page at: ${p}`);
|
||||
const content = await loadBlockPage(p);
|
||||
logs.plugin('ipfilter', `Load result for ${p}: ${content ? 'FOUND' : 'NOT FOUND'}`);
|
||||
if (content) {
|
||||
html = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let html = '';
|
||||
logs.plugin(
|
||||
'ipfilter',
|
||||
`Block pages: custom="${cleanCustomPage}", default="${cleanDefaultPage}"`,
|
||||
);
|
||||
logs.plugin('ipfilter', 'Searching for block page in the following locations:');
|
||||
const paths = [
|
||||
// allow absolute paths relative to project root first
|
||||
join(rootDir, cleanCustomPage),
|
||||
];
|
||||
// Fallback to default block page if custom page isn't found
|
||||
if (customPage !== defaultBlockPage) {
|
||||
paths.push(
|
||||
// check default page at root directory
|
||||
join(rootDir, cleanDefaultPage),
|
||||
);
|
||||
}
|
||||
|
||||
for (const p of paths) {
|
||||
logs.plugin('ipfilter', `Trying block page at: ${p}`);
|
||||
const content = await loadBlockPage(p);
|
||||
logs.plugin('ipfilter', `Load result for ${p}: ${content ? 'FOUND' : 'NOT FOUND'}`);
|
||||
if (content) {
|
||||
html = content;
|
||||
break;
|
||||
if (html) {
|
||||
const output = html.replace('{{.ASNName}}', asnOrgName || 'Blocked Network');
|
||||
return res.status(403).type('html').send(output);
|
||||
} else {
|
||||
return res.status(403).type('text').send('Access denied from your location or network.');
|
||||
}
|
||||
}
|
||||
|
||||
if (html) {
|
||||
const output = html.replace('{{.ASNName}}', asnOrgName || 'Blocked Network');
|
||||
return new Response(output, {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
} else {
|
||||
return new Response('Access denied from your location or network.', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
309
plugins/proxy.js
309
plugins/proxy.js
|
|
@ -1,5 +1,7 @@
|
|||
import { registerPlugin, loadConfig } from '../index.js';
|
||||
import * as logs from '../utils/logs.js';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import express from 'express';
|
||||
|
||||
const proxyConfig = {};
|
||||
await loadConfig('proxy', proxyConfig);
|
||||
|
|
@ -17,222 +19,113 @@ proxyConfig.Mapping.forEach(mapping => {
|
|||
|
||||
logs.plugin('proxy', `Proxy mappings loaded: ${JSON.stringify(proxyMappings)}`);
|
||||
|
||||
const HOP_BY_HOP_HEADERS = [
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
];
|
||||
|
||||
// Connect to upstream WebSocket with handshake timeout
|
||||
async function connectUpstreamWebSocket(url, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, { headers });
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('timeout'));
|
||||
}, wsTimeout);
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(ws);
|
||||
};
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('closed'));
|
||||
};
|
||||
function createProxyForHost(target) {
|
||||
return createProxyMiddleware({
|
||||
target,
|
||||
changeOrigin: true,
|
||||
ws: true, // Enable WebSocket support
|
||||
timeout: upstreamTimeout,
|
||||
proxyTimeout: upstreamTimeout,
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
// Remove undefined headers
|
||||
const headersToRemove = ['x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-for'];
|
||||
headersToRemove.forEach(header => {
|
||||
proxyReq.removeHeader(header);
|
||||
});
|
||||
|
||||
// Set proper forwarded headers
|
||||
const forwarded = {
|
||||
for: req.ip || req.connection.remoteAddress,
|
||||
host: req.get('host'),
|
||||
proto: req.protocol
|
||||
};
|
||||
|
||||
proxyReq.setHeader('X-Forwarded-For', forwarded.for);
|
||||
proxyReq.setHeader('X-Forwarded-Host', forwarded.host);
|
||||
proxyReq.setHeader('X-Forwarded-Proto', forwarded.proto);
|
||||
|
||||
// Log the proxied request
|
||||
const startTime = Date.now();
|
||||
res.on('finish', () => {
|
||||
const latency = Date.now() - startTime;
|
||||
logs.plugin('proxy', `Proxied request to: ${target}${req.url} (${res.statusCode}) (${latency}ms)`);
|
||||
});
|
||||
},
|
||||
onProxyReqWs: (proxyReq, req, socket, options, head) => {
|
||||
// Set WebSocket timeout
|
||||
socket.setTimeout(wsTimeout);
|
||||
logs.plugin('proxy', `WebSocket proxied to: ${target}${req.url}`);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
logs.error('proxy', `Proxy error: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(502).send('Bad Gateway');
|
||||
}
|
||||
},
|
||||
// Handle SSE and streaming responses properly
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// For SSE responses, ensure proper headers
|
||||
const contentType = proxyRes.headers['content-type'];
|
||||
if (contentType && contentType.includes('text/event-stream')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
// Remove compression for SSE
|
||||
delete proxyRes.headers['content-encoding'];
|
||||
// Force connection keep-alive
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
}
|
||||
},
|
||||
// Advanced options for better compatibility
|
||||
followRedirects: false,
|
||||
preserveHeaderKeyCase: true,
|
||||
autoRewrite: true,
|
||||
protocolRewrite: 'http',
|
||||
cookieDomainRewrite: {
|
||||
"*": "" // Remove domain restrictions from cookies
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createProxyResponse(targetURL, request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const targetPathAndQuery = url.pathname + url.search;
|
||||
const fullTargetURL = new URL(targetPathAndQuery, targetURL).toString();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const outgoingHeaders = new Headers(request.headers);
|
||||
outgoingHeaders.delete('host');
|
||||
|
||||
// Set proper host header for the target
|
||||
const targetHost = new URL(targetURL).host;
|
||||
outgoingHeaders.set('host', targetHost);
|
||||
|
||||
// Forward the original host as X-Forwarded-Host for applications that need it
|
||||
outgoingHeaders.set('x-forwarded-host', request.headers.get('host'));
|
||||
outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
||||
|
||||
const options = {
|
||||
method: request.method,
|
||||
headers: outgoingHeaders,
|
||||
// Always use manual redirect to let client handle it
|
||||
redirect: 'manual',
|
||||
// Don't decode compressed responses - let the client handle it
|
||||
decompress: false,
|
||||
};
|
||||
|
||||
// Handle request body
|
||||
if (request.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
|
||||
options.body = request.body;
|
||||
}
|
||||
|
||||
// Add timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
logs.warn('proxy', `Upstream request to ${fullTargetURL} timed out after ${upstreamTimeout}ms`);
|
||||
controller.abort();
|
||||
}, upstreamTimeout);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(fullTargetURL, { ...options, signal: controller.signal });
|
||||
} catch (fetchErr) {
|
||||
clearTimeout(timeoutId);
|
||||
if (fetchErr.name === 'AbortError') {
|
||||
logs.error('proxy', `Upstream fetch aborted for ${fullTargetURL} (likely due to timeout)`);
|
||||
return new Response('Gateway Timeout', { status: 504 });
|
||||
}
|
||||
logs.error('proxy', `Fetch error: ${fetchErr.message}`);
|
||||
return new Response('Bad Gateway', { status: 502 });
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
logs.plugin('proxy', `Proxied request to: ${fullTargetURL} (${response.status} ${response.statusText}) (${latency}ms)`);
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
|
||||
// Remove hop-by-hop headers
|
||||
HOP_BY_HOP_HEADERS.forEach((h) => responseHeaders.delete(h));
|
||||
|
||||
// IMPORTANT: Don't remove content-encoding or modify the body
|
||||
// Let the response stream through as-is for SSE compatibility
|
||||
|
||||
// Add proxy information
|
||||
responseHeaders.set('X-Proxy-Latency', `${latency}ms`);
|
||||
|
||||
// Handle Set-Cookie headers - rewrite domain if needed
|
||||
const setCookieHeaders = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
|
||||
if (setCookieHeaders.length > 0) {
|
||||
responseHeaders.delete('set-cookie');
|
||||
|
||||
setCookieHeaders.forEach(cookieStr => {
|
||||
let modifiedCookie = cookieStr;
|
||||
|
||||
// Remove domain restrictions
|
||||
modifiedCookie = modifiedCookie.replace(/;\s*domain=[^;]*/gi, '');
|
||||
|
||||
// Handle SameSite for local development
|
||||
if (url.protocol === 'http:' && modifiedCookie.match(/samesite\s*=\s*none/i)) {
|
||||
modifiedCookie = modifiedCookie.replace(/;\s*samesite=[^;]*/gi, '; SameSite=Lax');
|
||||
modifiedCookie = modifiedCookie.replace(/;\s*secure/gi, '');
|
||||
}
|
||||
|
||||
responseHeaders.append('set-cookie', modifiedCookie);
|
||||
});
|
||||
}
|
||||
|
||||
// Return response with original body stream
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (err) {
|
||||
logs.error('proxy', `Proxy error processing ${request.method} ${request.url}: ${err.message}`);
|
||||
logs.error('proxy', `Full error details: ${err.stack}`);
|
||||
return new Response('Bad Gateway', { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
function proxyMiddleware() {
|
||||
return async (request, server) => {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Skip checkpoint endpoints
|
||||
if (path.startsWith('/api/challenge') || path.startsWith('/api/verify')) return undefined;
|
||||
|
||||
// Skip static assets
|
||||
if (path.startsWith('/webfont/') || path.startsWith('/js/')) return undefined;
|
||||
|
||||
// Get the hostname from the request
|
||||
const hostname = request.headers.get('host')?.split(':')[0];
|
||||
const target = proxyMappings[hostname];
|
||||
if (!target) return undefined;
|
||||
|
||||
// Handle WebSocket upgrade requests
|
||||
const upgradeHeader = request.headers.get('upgrade')?.toLowerCase();
|
||||
if (upgradeHeader === 'websocket') {
|
||||
const targetUrl = new URL(url.pathname + url.search, target);
|
||||
targetUrl.protocol = targetUrl.protocol.replace(/^http/, 'ws');
|
||||
|
||||
// Forward important headers for WebSocket
|
||||
const wsHeaders = {};
|
||||
['cookie', 'authorization', 'origin', 'sec-websocket-protocol', 'sec-websocket-extensions']
|
||||
.forEach(header => {
|
||||
const value = request.headers.get(header);
|
||||
if (value) wsHeaders[header] = value;
|
||||
});
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstreamWebSocket(targetUrl.toString(), wsHeaders);
|
||||
} catch (err) {
|
||||
logs.error('proxy', `Upstream WebSocket connection failed: ${err}`);
|
||||
return new Response('Bad Gateway', { status: 502 });
|
||||
}
|
||||
|
||||
// Upgrade incoming client connection and attach upstream socket
|
||||
const ok = server.upgrade(request, { data: { upstream } });
|
||||
if (!ok) {
|
||||
logs.error('proxy', 'WebSocket upgrade failed');
|
||||
upstream.close();
|
||||
return new Response('Bad Gateway', { status: 502 });
|
||||
}
|
||||
logs.plugin('proxy', `WebSocket proxied to: ${targetUrl.toString()}`);
|
||||
return;
|
||||
const router = express.Router();
|
||||
|
||||
// Skip checkpoint endpoints
|
||||
router.use('/api/challenge', (req, res, next) => next('route'));
|
||||
router.use('/api/verify', (req, res, next) => next('route'));
|
||||
|
||||
// Skip static assets (already handled by static middleware)
|
||||
router.use('/webfont/', (req, res, next) => next('route'));
|
||||
router.use('/js/', (req, res, next) => next('route'));
|
||||
|
||||
// Create a proxy instance for each host
|
||||
const proxyInstances = {};
|
||||
Object.entries(proxyMappings).forEach(([host, target]) => {
|
||||
proxyInstances[host] = createProxyForHost(target);
|
||||
});
|
||||
|
||||
// Main proxy handler
|
||||
router.use((req, res, next) => {
|
||||
const hostname = req.hostname || req.headers.host?.split(':')[0];
|
||||
const proxyInstance = proxyInstances[hostname];
|
||||
|
||||
if (proxyInstance) {
|
||||
proxyInstance(req, res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
return createProxyResponse(target, request);
|
||||
};
|
||||
});
|
||||
|
||||
return { middleware: router };
|
||||
}
|
||||
|
||||
// WebSocket handlers for proxying messages between client and upstream
|
||||
// Export WebSocket handler for compatibility
|
||||
export const proxyWebSocketHandler = {
|
||||
open(ws) {
|
||||
const upstream = ws.data.upstream;
|
||||
upstream.onopen = () => logs.plugin('proxy', 'Upstream WebSocket connected');
|
||||
// Forward messages from target to client
|
||||
upstream.onmessage = (event) => ws.send(event.data);
|
||||
upstream.onerror = (err) => {
|
||||
logs.error('proxy', `Upstream WebSocket error: ${err}`);
|
||||
ws.close(1011, 'Upstream error');
|
||||
};
|
||||
upstream.onclose = ({ code, reason }) => ws.close(code, reason);
|
||||
},
|
||||
message(ws, message) {
|
||||
const upstream = ws.data.upstream;
|
||||
// Forward messages from client to target
|
||||
upstream.send(message);
|
||||
},
|
||||
close(ws, code, reason) {
|
||||
const upstream = ws.data.upstream;
|
||||
upstream.close(code, reason);
|
||||
},
|
||||
error(ws, err) {
|
||||
logs.error('proxy', `WebSocket proxy error: ${err}`);
|
||||
const upstream = ws.data.upstream;
|
||||
upstream.close();
|
||||
},
|
||||
// http-proxy-middleware handles WebSocket internally
|
||||
// These are kept for compatibility but won't be used
|
||||
open: () => {},
|
||||
message: () => {},
|
||||
close: () => {},
|
||||
error: () => {}
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
|
|
|
|||
|
|
@ -72,24 +72,23 @@ function recordEvent(metric, data = {}) {
|
|||
}
|
||||
|
||||
// Handler for serving the stats HTML UI
|
||||
async function handleStatsPage(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== statsUIPath) return undefined;
|
||||
async function handleStatsPage(req, res) {
|
||||
const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`);
|
||||
if (url.pathname !== statsUIPath) return false;
|
||||
try {
|
||||
const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8');
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
res.status(200).type('html').send(html);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return new Response('Stats UI not found', { status: 404 });
|
||||
res.status(404).send('Stats UI not found');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for stats API
|
||||
async function handleStatsAPI(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== statsAPIPath) return undefined;
|
||||
async function handleStatsAPI(req, res) {
|
||||
const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`);
|
||||
if (url.pathname !== statsAPIPath) return false;
|
||||
const metric = url.searchParams.get('metric');
|
||||
const start = parseInt(url.searchParams.get('start') || '0', 10);
|
||||
const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10);
|
||||
|
|
@ -101,23 +100,24 @@ async function handleStatsAPI(request) {
|
|||
})) {
|
||||
result.push(value);
|
||||
}
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
res.status(200).json(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Middleware for stats plugin
|
||||
function StatsMiddleware() {
|
||||
return async (request) => {
|
||||
// Always serve stats UI and API first, bypassing auth
|
||||
const pageResp = await handleStatsPage(request);
|
||||
if (pageResp) return pageResp;
|
||||
const apiResp = await handleStatsAPI(request);
|
||||
if (apiResp) return apiResp;
|
||||
return {
|
||||
middleware: async (req, res, next) => {
|
||||
// Always serve stats UI and API first, bypassing auth
|
||||
const pageHandled = await handleStatsPage(req, res);
|
||||
if (pageHandled) return;
|
||||
|
||||
const apiHandled = await handleStatsAPI(req, res);
|
||||
if (apiHandled) return;
|
||||
|
||||
// For any other routes, do not handle
|
||||
return undefined;
|
||||
// For any other routes, do not handle
|
||||
return next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue