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

370 lines
14 KiB
JavaScript

const http = require("http");
const https = require("https");
const tls = require("tls");
const url = require("url");
const path = require("path");
const fs = require("fs");
const httpProxy = require("http-proxy");
const { logDebug, logError, logWarn, logInfo } = require("../infrastructure/logger");
const state = require("../infrastructure/state");
const { getOrCreateDomainCert, getCaChain } = require("../security/certificate_authority");
// Maps for managing proxy servers per bind IP
const httpServers = new Map(); // bindIp => httpServer
const tlsServers = new Map(); // bindIp => tlsServer
const configPerIp = new Map(); // bindIp => Map(lowercaseDomain => {key, cert, targetPort})
// Helper function to normalize IPs
function getIPs(ipInput) {
const ips = [];
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
function extract(input) {
if (
typeof input === "string" &&
(input.match(ipv4Regex) || input.match(ipv6Regex))
) {
ips.push(input);
} else if (Array.isArray(input)) {
input.forEach(extract);
} else if (typeof input === "object" && input !== null) {
["ip", "address", "value", "ipv4", "ipv6"].forEach((key) => {
if (input[key]) extract(input[key]);
});
}
}
extract(ipInput);
return [...new Set(ips)]; // unique IPs
}
// Start TLS proxy with HTTP redirect
function createTlsProxy(domain, ipInput, targetPort, logDebug) {
const { key, cert } = getOrCreateDomainCert(domain, ipInput);
const ips = getIPs(ipInput);
const bindIp = ips.length > 0 ? ips[0] : "0.0.0.0";
let ipConfig = configPerIp.get(bindIp);
if (!ipConfig) {
ipConfig = new Map();
configPerIp.set(bindIp, ipConfig);
}
ipConfig.set(domain.toLowerCase(), { key, cert, targetPort });
// HTTP server per bindIp
let httpServer = httpServers.get(bindIp);
if (!httpServer) {
httpServer = http.createServer((req, res) => {
const httpsPort = process.env.HTTPS_PORT || 443;
const host = req.headers.host
? req.headers.host.split(":")[0]
: "localhost";
const portPart = httpsPort === 443 ? "" : `:${httpsPort}`;
const redirectUrl = `https://${host}${portPart}${req.url}`;
res.writeHead(301, { Location: redirectUrl });
res.end();
logInfo(
"HTTP",
`Redirected ${req.headers.host || "unknown"} to ${redirectUrl}`
);
});
httpServer.listen(80, bindIp, () => {
logInfo("HTTP", `HTTP redirect server listening on ${bindIp}:80`);
});
httpServer.on("error", (err) => {
logError("HTTP", `HTTP server error on ${bindIp}: ${err.message}`);
});
httpServers.set(bindIp, httpServer);
}
// TLS server per bindIp with SNI
let tlsServer = tlsServers.get(bindIp);
if (!tlsServer) {
tlsServer = https.createServer(
{
SNICallback: (servername, cb) => {
const conf = ipConfig.get(servername.toLowerCase());
if (conf) {
const ctx = tls.createSecureContext({
key: conf.key,
cert: conf.cert,
});
cb(null, ctx);
} else {
logWarn("TLS", `No cert for servername: ${servername}`);
cb(new Error("No cert found for this domain"));
}
},
},
async (req, res) => {
const admin = require("../admin");
const servername = req.headers.host
? req.headers.host.split(":")[0]
: req.socket.servername;
if (!servername) {
logError("TLS", "No servername provided");
res.writeHead(400);
res.end("Bad Request");
return;
}
const conf = ipConfig.get(servername.toLowerCase());
if (!conf) {
logError("TLS", `No configuration found for ${servername}`);
res.writeHead(404);
res.end("Not Found");
return;
}
let cookies = {};
if (req.headers.cookie) {
req.headers.cookie.split(";").forEach((c) => {
const [key, val] = c.trim().split("=");
cookies[key] = val;
});
}
// Serve Tailwind CSS
if (req.url === '/tailwind.css' || req.url.startsWith('/tailwind.css?')) {
try {
const css = await fs.promises.readFile(path.join(__dirname, '..', 'css', 'tailwind.css'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(css);
} catch (err) {
logError('TLS', `Failed to serve tailwind.css: ${err.message}`);
res.writeHead(500);
res.end('Failed to load Tailwind CSS');
}
return;
}
let version = cookies.version;
let isChoose = req.url.startsWith("/_choose?");
let parsedUrl = new url.URL(req.url, `https://${servername}`);
if (isChoose) {
version = parsedUrl.searchParams.get("version");
if (version === "p2p" || version === "public") {
state.versionPreferences.set(servername.toLowerCase(), version);
await admin.saveSelectorCache(); // Save to selector_cache.json
admin.broadcast({ type: "update-local-dns" }); // Notify clients to update DNS Conflict Selector
const redirect = parsedUrl.searchParams.get("redirect") || "/";
res.writeHead(302, {
"Set-Cookie": `version=${version}; Path=/; Max-Age=31536000; HttpOnly`,
Location: redirect,
});
res.end();
logInfo(
"TLS",
`Set version preference for ${servername} to ${version} and saved to selector_cache.json`
);
return;
}
}
// Check if a version preference is set in state.versionPreferences
const stateVersion = state.versionPreferences.get(
servername.toLowerCase()
);
if (
state.domainsWithBoth.has(servername.toLowerCase()) &&
!version &&
!stateVersion
) {
// Show middleware page only if no cookie and no state preference
const redirect = encodeURIComponent(req.url);
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Choose Version for ${servername}</title>
<link rel="stylesheet" href="/tailwind.css">
<script>
function flushDNSCache() {
// Attempt to flush DNS cache by making a unique request
const timestamp = new Date().getTime();
fetch(\`https://\${window.location.hostname}/_dnsflush?t=\${timestamp}\`, { cache: 'no-store' })
.catch(() => console.log('DNS flush request attempted'));
}
function showLoadingMessage(button) {
// Hide the main content and show the loading message
document.getElementById('main-content').classList.add('hidden');
document.getElementById('loading-message').classList.remove('hidden');
// Flush DNS cache and redirect
flushDNSCache();
setTimeout(() => {
window.location.href = button.getAttribute('data-href');
}, 3000); // Small delay to ensure DNS flush attempt
}
</script>
</head>
<body class="bg-gray-900 text-white flex items-center justify-center min-h-screen">
<div class="container mx-auto p-4 max-w-md">
<div id="main-content">
<h1 class="text-2xl font-bold mb-4">Multiple Versions Available for ${servername}</h1>
<p class="mb-6">This domain has both a public internet version and a P2P version. Please choose which one you want to access:</p>
<div class="flex flex-col space-y-4">
<button
onclick="showLoadingMessage(this)"
data-href="/_choose?version=public&redirect=${redirect}"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-center">
Public Version
</button>
<button
onclick="showLoadingMessage(this)"
data-href="/_choose?version=p2p&redirect=${redirect}"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded text-center">
P2P Version
</button>
</div>
</div>
<div id="loading-message" class="hidden flex flex-col items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-t-4 border-b-4 border-blue-500 mb-4"></div>
<p class="text-lg font-semibold">Please wait while we redirect you.</p>
<p class="text-sm text-gray-400">You can enable or disable your choice within the admin area</p>
</div>
</div>
</body>
</html>`);
return;
}
// Use stateVersion if available, otherwise fall back to cookie or default to 'p2p'
const effectiveVersion = stateVersion || version || "p2p";
let target;
let proxy;
if (
state.domainsWithBoth.has(servername.toLowerCase()) &&
effectiveVersion === "public"
) {
const publicIP = state.publicIpForDomain[servername.toLowerCase()];
if (!publicIP) {
res.writeHead(500);
res.end("No public IP available");
return;
}
let ca = state.caForDomain.get(servername);
if (ca === undefined) {
ca = await getCaChain(servername, publicIP);
state.caForDomain.set(servername, ca);
}
let agentOptions = {
servername: servername,
checkServerIdentity: (host, cert) => {
return tls.checkServerIdentity(servername, cert);
},
createConnection: (options, cb) => {
options.host = publicIP;
return tls.connect(options, cb);
},
};
if (ca) {
agentOptions.ca = ca;
} else {
agentOptions.rejectUnauthorized = false;
logWarn(
"TLS",
`No CA chain for ${servername}, using rejectUnauthorized: false`
);
}
const agent = new https.Agent(agentOptions);
target = `https://${servername}`;
proxy = httpProxy.createProxyServer({});
proxy.web(req, res, {
target,
agent,
changeOrigin: false,
secure: !!ca,
});
} else {
// Check if domain uses SSL/TLS
const { getDomainSSLStatus } = require('../core/core');
const useSSL = await getDomainSSLStatus(servername);
const protocol = useSSL ? 'https' : 'http';
target = `${protocol}://${bindIp}:${conf.targetPort}`;
proxy = httpProxy.createProxyServer({});
proxy.web(req, res, { target });
}
}
);
tlsServer.on("upgrade", async (req, socket, head) => {
const servername = req.headers.host ? req.headers.host.split(":")[0] : "";
const conf = ipConfig.get(servername.toLowerCase());
if (!conf) {
socket.destroy();
return;
}
let cookies = {};
if (req.headers.cookie) {
req.headers.cookie.split(";").forEach((c) => {
const [key, val] = c.trim().split("=");
cookies[key] = val;
});
}
let version = cookies.version || "p2p";
const stateVersion = state.versionPreferences.get(
servername.toLowerCase()
);
const effectiveVersion = stateVersion || version;
let target;
if (
state.domainsWithBoth.has(servername.toLowerCase()) &&
effectiveVersion === "public"
) {
const publicIP = state.publicIpForDomain[servername.toLowerCase()];
if (!publicIP) {
socket.destroy();
return;
}
let ca = state.caForDomain.get(servername);
let agentOptions = {
servername: servername,
checkServerIdentity: (host, cert) => {
return tls.checkServerIdentity(servername, cert);
},
createConnection: (options, cb) => {
options.host = publicIP;
return tls.connect(options, cb);
},
};
if (ca) {
agentOptions.ca = ca;
} else {
agentOptions.rejectUnauthorized = false;
logWarn(
"TLS",
`No CA chain for ${servername} (WS), using rejectUnauthorized: false`
);
}
const agent = new https.Agent(agentOptions);
target = `wss://${servername}`;
const proxy = httpProxy.createProxyServer({});
proxy.ws(req, socket, head, {
target,
agent,
changeOrigin: false,
secure: !!ca,
});
} else {
// Check if domain uses SSL/TLS for WebSocket
const { getDomainSSLStatus } = require('../core/core');
const useSSL = await getDomainSSLStatus(servername);
const protocol = useSSL ? 'wss' : 'ws';
target = `${protocol}://${bindIp}:${conf.targetPort}`;
const proxy = httpProxy.createProxyServer({});
proxy.ws(req, socket, head, { target });
}
});
tlsServer.listen(process.env.HTTPS_PORT || 443, bindIp, () => {
logInfo(
"TLS",
`TLS proxy listening on ${bindIp}:${process.env.HTTPS_PORT || 443}`
);
});
tlsServer.on("error", (err) => {
logError("TLS", `TLS server error on ${bindIp}: ${err.message}`);
});
tlsServers.set(bindIp, tlsServer);
}
return { tlsServer, httpServer };
}
module.exports = {
createTlsProxy,
};