Checkpoint/plugins/proxy.js

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');
}