303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
import { registerPlugin, loadConfig } from '../index.js';
|
|
import * as logs from '../utils/logs.js';
|
|
|
|
const proxyConfig = {};
|
|
await loadConfig('proxy', proxyConfig);
|
|
|
|
// Map configuration to internal structure
|
|
const enabled = proxyConfig.Core.Enabled;
|
|
const wsTimeout = proxyConfig.Timeouts.WebSocketTimeoutMs;
|
|
const upstreamTimeout = proxyConfig.Timeouts.UpstreamTimeoutMs;
|
|
|
|
// Build proxy mappings from array format
|
|
const proxyMappings = {};
|
|
proxyConfig.Mapping.forEach((mapping) => {
|
|
proxyMappings[mapping.Host] = mapping.Target;
|
|
});
|
|
|
|
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'));
|
|
};
|
|
});
|
|
}
|
|
|
|
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(':', ''));
|
|
|
|
// Preserve important headers for authentication
|
|
// Don't delete content-length or transfer-encoding here, handle them properly below
|
|
const options = {
|
|
method: request.method,
|
|
headers: outgoingHeaders,
|
|
// Follow redirects automatically for GET; forward redirects for non-GET
|
|
// Absolute requirement: DONT REMOVE
|
|
redirect: request.method === 'GET' ? 'follow' : 'manual',
|
|
credentials: 'include',
|
|
};
|
|
|
|
const isChunked = request.headers.get('transfer-encoding')?.toLowerCase() === 'chunked';
|
|
|
|
// Define methods that can legitimately have request bodies
|
|
const methodsWithBody = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
|
if (methodsWithBody.has(request.method) && request.body) {
|
|
if (isChunked) {
|
|
logs.plugin('proxy', `De-chunking request body for ${request.method} ${request.url}`);
|
|
try {
|
|
const bodyBuffer = await request.arrayBuffer();
|
|
options.body = bodyBuffer;
|
|
outgoingHeaders.set('content-length', String(bodyBuffer.byteLength));
|
|
outgoingHeaders.delete('transfer-encoding');
|
|
} catch (bufferError) {
|
|
logs.error('proxy', `Error buffering chunked request body: ${bufferError}`);
|
|
return new Response('Error processing chunked request body', { status: 500 });
|
|
}
|
|
} else {
|
|
// For non-chunked bodies, preserve the body stream
|
|
options.body = request.body;
|
|
// Keep the original content-length if it exists
|
|
if (request.headers.has('content-length')) {
|
|
outgoingHeaders.set('content-length', request.headers.get('content-length'));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add a timeout controller for the upstream fetch
|
|
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,
|
|
verbose: true,
|
|
});
|
|
} 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 });
|
|
}
|
|
throw fetchErr;
|
|
}
|
|
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));
|
|
|
|
// Remove content-encoding and content-length headers
|
|
// This is necessary because Bun/fetch automatically decompresses the response body
|
|
// but leaves the content-encoding header, causing the browser to try to decompress already decompressed content
|
|
responseHeaders.delete('content-encoding');
|
|
responseHeaders.delete('content-length');
|
|
|
|
// 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) => {
|
|
// Parse and potentially rewrite the cookie domain
|
|
let modifiedCookie = cookieStr;
|
|
|
|
// Remove domain restrictions that might prevent the cookie from working
|
|
modifiedCookie = modifiedCookie.replace(/;\s*domain=[^;]*/gi, '');
|
|
|
|
// If the cookie has SameSite=None, ensure it also has Secure
|
|
if (modifiedCookie.match(/samesite\s*=\s*none/i) && !modifiedCookie.match(/secure/i)) {
|
|
modifiedCookie += '; Secure';
|
|
}
|
|
|
|
// For local development, you might need to adjust SameSite
|
|
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 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}${
|
|
err.cause ? ' - Cause: ' + err.cause : ''
|
|
}`,
|
|
);
|
|
let causeDetails = '';
|
|
if (err.cause) {
|
|
causeDetails = typeof err.cause === 'object' ? JSON.stringify(err.cause) : String(err.cause);
|
|
}
|
|
logs.error(
|
|
'proxy',
|
|
`Full error details: ${err.stack}${err.cause ? '\nCause: ' + causeDetails : ''}`,
|
|
);
|
|
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 = new Headers();
|
|
if (request.headers.has('cookie')) wsHeaders.set('Cookie', request.headers.get('cookie'));
|
|
if (request.headers.has('authorization'))
|
|
wsHeaders.set('Authorization', request.headers.get('authorization'));
|
|
if (request.headers.has('origin')) wsHeaders.set('Origin', request.headers.get('origin'));
|
|
if (request.headers.has('sec-websocket-protocol'))
|
|
wsHeaders.set('Sec-WebSocket-Protocol', request.headers.get('sec-websocket-protocol'));
|
|
if (request.headers.has('sec-websocket-extensions'))
|
|
wsHeaders.set('Sec-WebSocket-Extensions', request.headers.get('sec-websocket-extensions'));
|
|
|
|
let upstream;
|
|
try {
|
|
// Convert Headers object to a plain object for the WebSocket constructor
|
|
const plainWsHeaders = {};
|
|
for (const [key, value] of wsHeaders) {
|
|
plainWsHeaders[key] = value;
|
|
}
|
|
upstream = await connectUpstreamWebSocket(targetUrl.toString(), plainWsHeaders);
|
|
} 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;
|
|
}
|
|
|
|
return createProxyResponse(target, request);
|
|
};
|
|
}
|
|
|
|
// WebSocket handlers for proxying messages between client and upstream
|
|
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();
|
|
},
|
|
};
|
|
|
|
if (enabled) {
|
|
registerPlugin('proxy', proxyMiddleware());
|
|
} else {
|
|
logs.plugin('proxy', 'Proxy plugin disabled via config');
|
|
}
|