Files
p2ns/includes/networking/internal_domains_proxy.js
T
2025-12-17 20:05:50 -05:00

602 lines
22 KiB
JavaScript

const https = require('https');
const http = require('http');
const tls = require('tls');
const WebSocket = require('ws');
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
const { getHashForDomain } = require('../core/core');
const { createInterfaceForDomain } = require('./virtual_interfaces');
const { restartHolesailClient } = require('./holesail');
const state = require('../infrastructure/state');
const ca = require('../security/certificate_authority');
// Lazy load admin module to avoid circular dependency
// const { handleAdminRequest, adminWss } = require('../admin');
const { handlePluginRequest, serveStaticFiles, hasPlugin } = require('../plugins/plugin-handler');
const certCache = new Map();
const MAX_CERT_CACHE_SIZE = parseInt(process.env.MAX_CERT_CACHE_SIZE || '1000', 10); // Default 1000 entries
// Store internal domains in module-level variable so it can be updated dynamically
let internalDomainsList = ['p2ns.admin']; // Default to p2ns.admin
// Helper function to set certificate in cache with size limit enforcement
function setCertCache(domain, cert) {
// If cache is at limit, remove oldest entries (simple FIFO - remove first key)
if (certCache.size >= MAX_CERT_CACHE_SIZE) {
const firstKey = certCache.keys().next().value;
if (firstKey) {
certCache.delete(firstKey);
logDebug('Proxy', `Removed oldest certificate from cache (${firstKey}) to maintain size limit`);
}
}
certCache.set(domain, cert);
}
/**
* Update proxy server certificates for current internal domains
* This should be called when plugins are enabled/disabled to update certificates
*/
async function updateProxyServerCertificates() {
if (process.env.DISABLE_PROXY_SERVER === 'true') {
return;
}
// Get internal domains from plugin system
let internalDomains;
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
internalDomains = await getInternalDomains();
} catch (err) {
// Fallback if plugin system not initialized yet
logWarn('Proxy', 'Plugin system not initialized, using default internal domains');
internalDomains = ['p2ns.admin'];
}
// Update module-level variable
internalDomainsList = internalDomains;
// Create comprehensive SAN list for localhost certificate
const localhostSANs = [
{ type: 2, value: 'localhost' },
{ type: 2, value: '127.0.0.1' },
{ type: 7, value: '127.0.0.1' },
{ type: 7, value: '::1' },
...internalDomains.map(domain => ({ type: 2, value: domain }))
];
// Update localhost certificate with all internal domains in SAN
let cert = ca.getOrCreateDomainCert('localhost', localhostSANs);
setCertCache('localhost', cert);
setCertCache('127.0.0.1', cert);
// Create/update individual certificates for each internal domain
for (const domain of internalDomains) {
const domainSANs = [
{ type: 2, value: domain },
{ type: 2, value: 'localhost' },
{ type: 2, value: '127.0.0.1' },
{ type: 7, value: '127.0.0.1' },
{ type: 7, value: '::1' }
];
cert = ca.getOrCreateDomainCert(domain, domainSANs);
setCertCache(domain, cert);
}
logInfo('Proxy', `Updated proxy server certificates for internal domains: ${internalDomains.join(', ')}`);
}
async function setupProxyServer() {
if (process.env.DISABLE_PROXY_SERVER === 'true') {
logInfo('Internal Proxy', 'Proxy server disabled via DISABLE_PROXY_SERVER=true');
return;
}
// Get internal domains from plugin system
let internalDomains;
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
internalDomains = await getInternalDomains();
} catch (err) {
// Fallback if plugin system not initialized yet
logWarn('Proxy', 'Plugin system not initialized, using default internal domains');
internalDomains = ['p2ns.admin'];
}
// Store in module-level variable
internalDomainsList = internalDomains;
// Create comprehensive SAN list for localhost certificate
const localhostSANs = [
{ type: 2, value: 'localhost' },
{ type: 2, value: '127.0.0.1' },
{ type: 7, value: '127.0.0.1' },
{ type: 7, value: '::1' },
...internalDomains.map(domain => ({ type: 2, value: domain }))
];
// Create localhost certificate with all internal domains in SAN
let cert = ca.getOrCreateDomainCert('localhost', localhostSANs);
setCertCache('localhost', cert);
setCertCache('127.0.0.1', cert);
// Create individual certificates for each internal domain
for (const domain of internalDomains) {
const domainSANs = [
{ type: 2, value: domain },
{ type: 2, value: 'localhost' },
{ type: 2, value: '127.0.0.1' },
{ type: 7, value: '127.0.0.1' },
{ type: 7, value: '::1' }
];
cert = ca.getOrCreateDomainCert(domain, domainSANs);
setCertCache(domain, cert);
}
const defaultDomain = 'localhost';
// Create HTTP server for redirecting to HTTPS
const httpServer = http.createServer((req, res) => {
try {
const host = req.headers.host;
let domain = host ? host.split(':')[0].toLowerCase() : defaultDomain;
if (domain === '127.0.0.1') {
domain = 'localhost';
}
logInfo('HTTP Proxy', `HTTP request for domain: ${domain}, path: ${req.url}`);
// Only redirect for internal domains or localhost
if (internalDomainsList.includes(domain) || domain === 'localhost') {
const redirectUrl = `https://${host}${req.url}`;
res.writeHead(301, {
Location: redirectUrl,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin-when-cross-origin'
});
res.end(`Redirecting to ${redirectUrl}`);
logDebug('HTTP Proxy', `Redirected HTTP request for ${domain} to ${redirectUrl}`);
} else {
res.writeHead(403);
res.end('HTTP access not allowed for non-internal domains');
logWarn('HTTP Proxy', `Blocked HTTP request for non-internal domain: ${domain}`);
}
} catch (err) {
logError('HTTP Proxy', `Error handling HTTP request: ${err.message}`);
res.writeHead(500);
res.end('Internal Server Error');
}
});
httpServer.on('error', (err) => {
logError('HTTP Proxy', `HTTP server error: ${err.message}`);
});
httpServer.on('clientError', (err, socket) => {
// Filter out common benign errors like "socket hang up"
if (err.message && !err.message.toLowerCase().includes('socket hang up')) {
logError('HTTP Proxy', `Client error: ${err.message}`);
}
if (socket.writable) {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
}
});
// Start HTTP server on port 80
httpServer.listen(process.env.HTTP_PORT || 80, '127.0.0.1', () => {
logInfo('HTTP Proxy', `HTTP server running on port ${process.env.HTTP_PORT || 80} at 127.0.0.1 for redirects to HTTPS`);
});
const httpsServer = https.createServer({
SNICallback: async (servername, cb) => {
logDebug('Proxy', `SNI request for ${servername || 'undefined'}`);
const normalizedServername = servername?.toLowerCase();
// Handle localhost or 127.0.0.1
if (!servername || normalizedServername === 'localhost' || normalizedServername === '127.0.0.1') {
const cert = certCache.get('localhost');
logDebug('Proxy', 'Using localhost certificate');
try {
const secureContext = tls.createSecureContext({
key: cert.key,
cert: cert.cert
});
cb(null, secureContext);
} catch (err) {
logError('Proxy', `Error creating secure context for localhost: ${err.message}`);
cb(err);
}
return;
}
// Check for internal domains
if (internalDomainsList.includes(normalizedServername)) {
const cert = certCache.get(normalizedServername) || certCache.get('localhost');
logDebug('Proxy', `Using internal domain certificate for ${normalizedServername}`);
try {
const secureContext = tls.createSecureContext({
key: cert.key,
cert: cert.cert
});
cb(null, secureContext);
} catch (err) {
logError('Proxy', `Error creating secure context for ${normalizedServername}: ${err.message}`);
cb(err);
}
return;
}
// Check if DNS service is initialized
if (!state.dnsPass) {
logWarn('Proxy', `DNS service not initialized, rejecting SNI request for ${normalizedServername}`);
cb(new Error(`DNS service not initialized for ${normalizedServername}`));
return;
}
// Check if it's a P2P domain
try {
const hash = await getHashForDomain(normalizedServername);
if (hash) {
let cert = certCache.get(normalizedServername);
if (!cert) {
const domainSANs = [
{ type: 2, value: normalizedServername },
{ type: 2, value: 'localhost' },
{ type: 7, value: '127.0.0.1' }
];
cert = ca.getOrCreateDomainCert(normalizedServername, domainSANs);
setCertCache(normalizedServername, cert);
}
logDebug('Proxy', `Using/created certificate for P2P domain ${normalizedServername}`);
try {
const secureContext = tls.createSecureContext({
key: cert.key,
cert: cert.cert
});
cb(null, secureContext);
} catch (err) {
logError('Proxy', `Error creating secure context for ${normalizedServername}: ${err.message}`);
cb(err);
}
return;
}
} catch (err) {
logError('Proxy', `Error checking P2P domain ${normalizedServername}: ${err.message}`);
}
},
key: certCache.get(defaultDomain).key,
cert: certCache.get(defaultDomain).cert
});
httpsServer.on('request', async (req, res) => {
try {
const host = req.headers.host;
let domain = host ? host.split(':')[0].toLowerCase() : defaultDomain;
if (domain === '127.0.0.1') {
domain = 'localhost';
}
const path = req.url;
logInfo('Internal Proxy', `HTTPS request for domain: ${domain}, path: ${path}`);
// Enhanced HSTS header removal
const originalSetHeader = res.setHeader.bind(res);
res.setHeader = function(name, value) {
const lowerName = name.toLowerCase();
if (lowerName === 'strict-transport-security' || lowerName === 'hsts') {
logDebug('Proxy', `Blocked ${name} header`);
return this;
}
return originalSetHeader(name, value);
};
// Set security headers for all responses
const setSecurityHeaders = (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
};
// Handle internal domains
if (internalDomainsList.includes(domain) || domain === 'localhost') {
setSecurityHeaders(res);
// Check if domain has a plugin
if (hasPlugin(domain)) {
// First, try serving static files from www/ directory
const staticHandled = await serveStaticFiles(domain, req, res);
if (staticHandled) {
return;
}
// If no static file found, try plugin handler
const handled = await handlePluginRequest(domain, req, res);
if (handled) {
return;
}
// If neither static files nor handler handled it, return 404
res.writeHead(404);
res.end('Not Found');
return;
}
// Try serving static files if no plugin exists
const staticHandled = await serveStaticFiles(domain, req, res);
if (staticHandled) {
return;
}
// Fallback to default handlers for backward compatibility
if (domain === 'p2ns.admin') {
// Lazy load to avoid circular dependency
const { handleAdminRequest } = require('../admin');
await handleAdminRequest(req, res);
return;
} else {
// All internal domains should be plugins or p2ns.admin
// If we reach here, it's an unexpected state
res.writeHead(404);
res.end('Not Found');
return;
}
}
// Check if it's a P2P domain
const hash = await getHashForDomain(domain);
if (!hash) {
logWarn('Proxy', `Ignoring request for non-P2P domain: ${domain}`);
setSecurityHeaders(res);
res.writeHead(404);
return res.end('Domain not found: Not an internal or P2P domain');
}
// Proceed with P2P domain handling
if (!state.dnsPass) {
logError('Proxy', 'DNS service not initialized');
setSecurityHeaders(res);
res.writeHead(503);
return res.end('Service Unavailable: DNS service not initialized');
}
const localIP = (state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain]) || await createInterfaceForDomain(domain);
if (!localIP) {
logError('Proxy', `No DNS records for ${domain}`);
setSecurityHeaders(res);
res.writeHead(404);
return res.end('Domain not found');
}
// Ensure holesail client is running and responsive
await restartHolesailClient(domain, hash, localIP, state.internalPort);
// Verify connection is actually responsive before proxying
const { checkPortResponsive } = require('../infrastructure/utils');
const key = `${domain}:${state.internalPort}`;
const isResponsive = await checkPortResponsive(localIP, state.internalPort);
if (!isResponsive) {
logWarn('Proxy', `Connection for ${key} is not responsive, attempting restart`);
// Force restart by cleaning up first
const existing = state.holesails.get(key);
if (existing) {
try {
await existing.close();
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
} catch (err) {
logError('Proxy', `Error closing unresponsive connection: ${err.message}`);
}
}
// Restart the connection
await restartHolesailClient(domain, hash, localIP, state.internalPort);
// Check again after restart
const stillUnresponsive = !(await checkPortResponsive(localIP, state.internalPort));
if (stillUnresponsive) {
logError('Proxy', `Connection for ${key} still unresponsive after restart`);
setSecurityHeaders(res);
res.writeHead(503);
return res.end('Service Unavailable: Connection not ready');
}
}
const options = {
hostname: localIP,
port: state.internalPort,
path: path,
method: req.method,
headers: req.headers,
};
const proxyRequest = http.request(options, (proxyRes) => {
const headers = {};
for (const [key, value] of Object.entries(proxyRes.headers)) {
const lowerKey = key.toLowerCase();
if (lowerKey !== 'strict-transport-security' && lowerKey !== 'hsts') {
headers[key] = value;
}
}
setSecurityHeaders(res);
res.writeHead(proxyRes.statusCode, headers);
proxyRes.pipe(res, { end: true });
});
proxyRequest.on('error', (err) => {
logError('Proxy', `Error proxying request for ${domain}: ${err.message}`);
setSecurityHeaders(res);
res.writeHead(500);
res.end('Internal Server Error');
});
req.pipe(proxyRequest, { end: true });
} catch (err) {
logError('Proxy', `Failed to handle request: ${err.message}`);
res.writeHead(500);
res.end('Internal Server Error');
}
});
httpsServer.on('upgrade', async (req, socket, head) => {
try {
const host = req.headers.host;
let domain = host ? host.split(':')[0].toLowerCase() : defaultDomain;
if (domain === '127.0.0.1') {
domain = 'localhost';
}
const path = req.url;
logInfo('Internal Proxy', `WebSocket upgrade for domain: ${domain}, path: ${path}`);
if (domain === 'p2ns.admin' && path === '/ws') {
// Lazy load to avoid circular dependency
const { adminWss } = require('../admin');
adminWss.handleUpgrade(req, socket, head, (ws) => {
adminWss.emit('connection', ws, req);
});
return;
}
if (internalDomainsList.includes(domain) || domain === 'localhost') {
// Check if domain has a plugin with WebSocket support
if (hasPlugin(domain)) {
const { getPluginWebSocket } = require('../plugins/plugin-handler');
const pluginWss = getPluginWebSocket(domain);
if (pluginWss && path === '/ws') {
logInfo('Internal Proxy', `Handling WebSocket upgrade for plugin ${domain}`);
try {
pluginWss.handleUpgrade(req, socket, head, (ws) => {
pluginWss.emit('connection', ws, req);
});
return;
} catch (err) {
logError('Internal Proxy', `Error handling WebSocket upgrade for ${domain}: ${err.message}`);
if (err.stack) {
logError('Internal Proxy', `Stack trace: ${err.stack}`);
}
socket.destroy();
return;
}
} else {
logWarn('Internal Proxy', `Plugin ${domain} has no WebSocket server registered (wss: ${pluginWss ? 'exists' : 'null'}, path: ${path})`);
}
} else {
logDebug('Internal Proxy', `Domain ${domain} has no plugin`);
}
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
return;
}
const hash = await getHashForDomain(domain);
if (!hash) {
logWarn('Proxy', `Ignoring WebSocket upgrade for non-P2P domain: ${domain}`);
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
return;
}
if (!state.dnsPass) {
logError('Proxy', 'DNS service not initialized');
socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
return;
}
const localIP = (state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain]) || await createInterfaceForDomain(domain);
if (!localIP) {
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
return;
}
// Ensure holesail client is running and responsive
await restartHolesailClient(domain, hash, localIP, state.internalPort);
// Verify connection is actually responsive before proxying
const { checkPortResponsive } = require('../infrastructure/utils');
const key = `${domain}:${state.internalPort}`;
const isResponsive = await checkPortResponsive(localIP, state.internalPort);
if (!isResponsive) {
logWarn('Proxy', `WebSocket connection for ${key} is not responsive, attempting restart`);
// Force restart by cleaning up first
const existing = state.holesails.get(key);
if (existing) {
try {
await existing.close();
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
} catch (err) {
logError('Proxy', `Error closing unresponsive WebSocket connection: ${err.message}`);
}
}
// Restart the connection
await restartHolesailClient(domain, hash, localIP, state.internalPort);
// Check again after restart
const stillUnresponsive = !(await checkPortResponsive(localIP, state.internalPort));
if (stillUnresponsive) {
logError('Proxy', `WebSocket connection for ${key} still unresponsive after restart`);
socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
return;
}
}
const clientReq = http.request({
hostname: localIP,
port: state.internalPort,
path: path,
method: req.method,
headers: req.headers
});
clientReq.end();
clientReq.on('upgrade', (clientRes, clientSocket, clientHead) => {
let headers = '';
for (const [key, value] of Object.entries(clientRes.headers)) {
const lowerKey = key.toLowerCase();
if (lowerKey !== 'strict-transport-security' && lowerKey !== 'hsts') {
headers += `${key}: ${value}\r\n`;
}
}
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headers}\r\n`);
if (clientHead.length > 0) {
socket.write(clientHead);
}
clientSocket.pipe(socket);
socket.pipe(clientSocket);
});
clientReq.on('response', (res) => {
socket.end(`HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n\r\n`);
});
clientReq.on('error', (err) => {
logError('Proxy', `WebSocket proxy error for ${domain}: ${err.message}`);
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
});
} catch (err) {
logError('Proxy', `Failed to handle WebSocket upgrade: ${err.message}`);
socket.end('HTTP/1.1 500 Internal Server Error\r\n\r\n');
}
});
httpsServer.on('clientError', (err, socket) => {
// Filter out common benign errors like "socket hang up"
if (err.message && !err.message.toLowerCase().includes('socket hang up')) {
logError('Proxy', `Client error: ${err.message}`);
}
if (socket.writable) {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
}
});
httpsServer.listen(process.env.HTTPS_PORT || 443, '127.0.0.1', () => {
logInfo('Internal Proxy', `HTTPS server running on port ${process.env.HTTPS_PORT || 443} at 127.0.0.1`);
logInfo('Internal Proxy', `Internal domains configured: ${internalDomainsList.join(', ')}`);
});
httpsServer.on('error', (err) => {
logError('Proxy', `HTTPS server error: ${err.message}`);
});
}
module.exports = { setupProxyServer, updateProxyServerCertificates };