Checkpoint/plugins/proxy.js

242 lines
8.1 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(':', ''));
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;
}
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');
}