Initial commit: Upload Checkpoint project
This commit is contained in:
commit
c0e3781244
32 changed files with 6121 additions and 0 deletions
303
plugins/proxy.js
Normal file
303
plugins/proxy.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue