This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
BIN
View File
Binary file not shown.
+353
View File
@@ -0,0 +1,353 @@
const dgram = require('dgram');
const dnsPacket = require('dns-packet');
const { getHashForDomain } = require('../core/core');
const state = require('../infrastructure/state');
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
const { createInterfaceForDomain } = require('./virtual_interfaces');
const { startHolesailClient } = require('./holesail');
const { dnsPool } = require('./dns_pool');
const { trackDNSQuery, trackDNSQueryWithTiming } = require('../maintenance/metrics');
const { getCircuitBreaker } = require('../infrastructure/circuit_breaker');
const { secondsToMs } = require('../infrastructure/utils');
const dnsServer = dgram.createSocket('udp4');
// Initialize state for domains with both
if (!state.domainsWithBoth) state.domainsWithBoth = new Set();
if (!state.publicIpForDomain) state.publicIpForDomain = {};
if (!state.versionPreferences) state.versionPreferences = new Map();
// DNS resolution cache with TTL
const dnsCache = new Map(); // domain -> { p2pHash: string|null, publicIP: string|null, timestamp: number, ttl: number }
const DEFAULT_CACHE_TTL = secondsToMs(30); // 30 seconds default TTL
const MAX_DNS_CACHE_SIZE = parseInt(process.env.MAX_DNS_CACHE_SIZE || '5000', 10); // Default 5000 entries
function getCachedDNSResolution(domain) {
const cached = dnsCache.get(domain);
if (!cached) return null;
const now = Date.now();
if (now - cached.timestamp > cached.ttl) {
dnsCache.delete(domain);
return null;
}
return cached;
}
function setCachedDNSResolution(domain, p2pHash, publicIP, ttl = DEFAULT_CACHE_TTL) {
// If cache is at limit, remove oldest entries (simple FIFO - remove first key)
if (dnsCache.size >= MAX_DNS_CACHE_SIZE) {
const firstKey = dnsCache.keys().next().value;
if (firstKey) {
dnsCache.delete(firstKey);
logDebug('DNS', `Removed oldest DNS entry from cache (${firstKey}) to maintain size limit`);
}
}
dnsCache.set(domain, {
p2pHash,
publicIP,
timestamp: Date.now(),
ttl
});
}
// Cleanup old cache entries periodically
let dnsCacheCleanupInterval = null;
function startDNSCacheCleanup() {
if (dnsCacheCleanupInterval) {
clearInterval(dnsCacheCleanupInterval);
}
dnsCacheCleanupInterval = setInterval(() => {
const now = Date.now();
let cleanedCount = 0;
for (const [domain, cached] of dnsCache.entries()) {
if (now - cached.timestamp > cached.ttl) {
dnsCache.delete(domain);
cleanedCount++;
}
}
// If cache is still too large after TTL cleanup, remove oldest entries
if (dnsCache.size > MAX_DNS_CACHE_SIZE) {
const entriesToRemove = dnsCache.size - MAX_DNS_CACHE_SIZE;
const keysToRemove = Array.from(dnsCache.keys()).slice(0, entriesToRemove);
for (const key of keysToRemove) {
dnsCache.delete(key);
cleanedCount++;
}
logDebug('DNS', `Removed ${entriesToRemove} oldest DNS entries to maintain size limit`);
}
if (cleanedCount > 0) {
logDebug('DNS', `DNS cache cleanup: removed ${cleanedCount} entries, ${dnsCache.size} remaining`);
}
}, secondsToMs(60)); // Cleanup every minute
}
function stopDNSCacheCleanup() {
if (dnsCacheCleanupInterval) {
clearInterval(dnsCacheCleanupInterval);
dnsCacheCleanupInterval = null;
}
}
// Start cleanup on module load
startDNSCacheCleanup();
// Circuit breaker for public DNS queries
const publicDNSCircuitBreaker = getCircuitBreaker('public-dns', {
failureThreshold: 5,
resetTimeout: secondsToMs(30)
});
// Check public DNS for fallback resolution using connection pool
async function checkPublicDNS(query) {
try {
const response = await publicDNSCircuitBreaker.execute(async () => {
return await dnsPool.query(query);
}, 'public-dns');
if (response && response.answers && response.answers.length > 0) {
trackDNSQuery('public');
} else {
trackDNSQuery('failure');
}
return response;
} catch (err) {
// Circuit breaker is open or query failed
logWarn('DNS', `Public DNS query failed (circuit breaker may be open): ${err.message}`);
trackDNSQuery('failure');
// Return null to indicate failure, allow graceful degradation
return null;
}
}
// Handle incoming DNS messages
async function handleDnsMessage(msg, rinfo) {
const query = dnsPacket.decode(msg);
const domain = query.questions[0].name.toLowerCase();
const type = query.questions[0].type;
logInfo('DNS', `DNS query received: Domain = ${domain}, Type = ${type}`);
// Check local DNS records first
const localAnswers = (state.localDnsRecords || []).filter(r => r.name.toLowerCase() === domain && r.type === type);
if (localAnswers.length > 0) {
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: localAnswers
});
dnsServer.send(response, rinfo.port, rinfo.address);
return;
}
// Handle internal domains
let isInternal = false;
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
const internalDomains = await getInternalDomains();
isInternal = internalDomains.includes(domain);
} catch (err) {
// Fallback: only p2ns.admin is internal
isInternal = domain === 'p2ns.admin';
}
if (isInternal) {
const localIP = '127.0.0.1';
state.domainToIPMap[domain] = localIP;
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: [{
type: 'A',
name: domain,
ttl: 1,
data: localIP
}]
});
dnsServer.send(response, rinfo.port, rinfo.address);
return;
}
if (!state.dnsPass) {
logWarn('DNS', 'dnsPass not initialized, handling as public DNS');
const publicDNSResponse = await checkPublicDNS(query);
// Graceful degradation: if public DNS fails, return empty response instead of error
if (publicDNSResponse) {
const response = dnsPacket.encode({
type: 'response',
id: query.id,
flags: publicDNSResponse.flags,
questions: query.questions,
answers: publicDNSResponse.answers.map(answer => ({
...answer,
ttl: 1 // Force TTL to 1 second
})),
authorities: publicDNSResponse.authorities || [],
additionals: publicDNSResponse.additionals || []
});
dnsServer.send(response, rinfo.port, rinfo.address);
} else {
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: []
});
dnsServer.send(response, rinfo.port, rinfo.address);
}
return;
}
// Check cache first
const cached = getCachedDNSResolution(domain);
let hash = null;
let p2pRecord = null;
let publicDNSResponse = null;
if (cached) {
logDebug('DNS', `Using cached DNS resolution for ${domain}`);
hash = cached.p2pHash;
if (hash) {
p2pRecord = { hash };
}
// For public DNS, we still need to query if not cached or if we need full response
// But we can use cached publicIP if available
if (cached.publicIP) {
state.publicIpForDomain[domain] = cached.publicIP;
}
} else {
// Not cached, perform lookups
hash = await getHashForDomain(domain);
logDebug('DNS', `Resolved hash for ${domain}: ${hash || 'null'}`);
if (hash) {
p2pRecord = { hash };
}
publicDNSResponse = await checkPublicDNS(query);
// Cache the results
let publicIP = null;
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
}
setCachedDNSResolution(domain, hash, publicIP, DEFAULT_CACHE_TTL);
}
// If we used cache but need public DNS response, query it
if (cached && !publicDNSResponse) {
publicDNSResponse = await checkPublicDNS(query);
// Update cache with fresh public IP
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
const publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
if (publicIP) {
setCachedDNSResolution(domain, hash, publicIP, DEFAULT_CACHE_TTL);
}
}
}
// Check if domain has both P2P and public records
let publicIP = null;
if (publicDNSResponse && publicDNSResponse.answers.length > 0) {
publicIP = publicDNSResponse.answers.find(a => a.type === 'A')?.data;
if (publicIP) {
state.publicIpForDomain[domain] = publicIP;
if (p2pRecord) {
state.domainsWithBoth.add(domain);
logInfo('DNS', `Domain ${domain} has both P2P and public records.`);
}
}
}
// Check user preference for version
const version = state.versionPreferences.get(domain);
if (p2pRecord && (!state.domainsWithBoth.has(domain) || version !== 'public')) {
const startTime = Date.now();
const localIP = state.domainToIPMap[domain] || await createInterfaceForDomain(domain);
if (!localIP) {
logError('DNS', `Failed to assign IP for ${domain}`);
trackDNSQuery('failure');
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: []
});
dnsServer.send(response, rinfo.port, rinfo.address);
return;
}
await startHolesailClient(domain, p2pRecord.hash, localIP, state.internalPort);
const responseTime = Date.now() - startTime;
trackDNSQueryWithTiming('p2p', domain, responseTime);
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: [{
type: 'A',
name: domain,
ttl: 1,
data: localIP
}]
});
dnsServer.send(response, rinfo.port, rinfo.address);
} else if (publicDNSResponse && publicDNSResponse.answers.length > 0 && (!p2pRecord || version === 'public')) {
trackDNSQueryWithTiming('public', domain, null); // Response time tracked in checkPublicDNS
const response = dnsPacket.encode({
type: 'response',
id: query.id,
flags: publicDNSResponse.flags,
questions: query.questions,
answers: publicDNSResponse.answers.map(answer => ({
...answer,
ttl: 1 // Force TTL to 1 second
})),
authorities: publicDNSResponse.authorities || [],
additionals: publicDNSResponse.additionals || []
});
dnsServer.send(response, rinfo.port, rinfo.address);
} else {
trackDNSQueryWithTiming('failure', domain, null);
logWarn('DNS', `No P2P or public DNS records found for ${domain}`);
const response = dnsPacket.encode({
type: 'response',
id: query.id,
questions: query.questions,
answers: []
});
dnsServer.send(response, rinfo.port, rinfo.address);
}
}
// Bind DNS server to port
function bindDnsServer() {
if (process.env.DISABLE_DNS_SERVER === 'true') {
logInfo('DNS', 'DNS server disabled via DISABLE_DNS_SERVER=true');
return;
}
dnsServer.on('listening', () => {
logInfo('DNS', `DNS Server running on port ${process.env.DNS_PORT || 53}, bound to 0.0.0.0`);
});
dnsServer.on('error', (err) => {
logError('DNS', `Failed to bind DNS server to port ${process.env.DNS_PORT || 53}: ${err.message}`);
process.exit(1);
});
dnsServer.on('message', handleDnsMessage);
dnsServer.bind(process.env.DNS_PORT || 53, '0.0.0.0');
}
// Close DNS server and remove listeners
function closeDnsServer() {
if (process.env.DISABLE_DNS_SERVER === 'true') {
return;
}
try {
dnsServer.removeAllListeners();
dnsServer.close();
logInfo('DNS', 'DNS server closed and listeners removed');
} catch (err) {
logError('DNS', `Error closing DNS server: ${err.message}`);
}
}
module.exports = { dnsServer, handleDnsMessage, bindDnsServer, closeDnsServer, stopDNSCacheCleanup };
+216
View File
@@ -0,0 +1,216 @@
const dgram = require('dgram');
const dnsPacket = require('dns-packet');
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
// DNS resolver connection pool
class DNSPool {
constructor(maxConnections = 5) {
this.maxConnections = maxConnections;
this.resolvers = [];
this.activeQueries = new Map();
this.queryId = 0;
}
/**
* Get or create a DNS resolver socket
* @returns {dgram.Socket} - DNS resolver socket
*/
getResolver() {
if (this.resolvers.length < this.maxConnections) {
const resolver = dgram.createSocket('udp4');
resolver.on('error', (err) => {
logError('DNSPool', `Resolver error: ${err.message}`);
});
this.resolvers.push(resolver);
return resolver;
}
// Round-robin through existing resolvers
return this.resolvers[this.queryId % this.resolvers.length];
}
/**
* Get list of DNS servers from environment variable
* @returns {string[]} - Array of DNS server IP addresses
*/
getDnsServers() {
const publicDnsServerEnv = process.env.PUBLIC_DNS_SERVER || '1.1.1.1';
return publicDnsServerEnv.split(',').map(s => s.trim()).filter(s => s.length > 0);
}
/**
* Query public DNS with failover support
* @param {object} query - DNS query object
* @returns {Promise<object|null>} - DNS response or null
*/
async query(query) {
const dnsServers = this.getDnsServers();
// Try each DNS server in order (failover strategy)
for (let serverIndex = 0; serverIndex < dnsServers.length; serverIndex++) {
const publicDNSServer = dnsServers[serverIndex];
const isLastServer = serverIndex === dnsServers.length - 1;
const result = await new Promise((resolve) => {
const resolver = this.getResolver();
const queryId = ++this.queryId;
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
const queryData = this.activeQueries.get(queryId);
if (queryData) {
// Remove listeners explicitly
if (queryData.handler) {
resolver.removeListener('message', queryData.handler);
}
if (queryData.errorHandler) {
resolver.removeListener('error', queryData.errorHandler);
}
if (queryData.timeout) {
clearTimeout(queryData.timeout);
}
this.activeQueries.delete(queryId);
}
}
};
// Set timeout
const timeout = setTimeout(() => {
logWarn('DNSPool', `DNS query timeout for ${query.questions[0].name} on server ${publicDNSServer}`);
cleanup();
resolve(null);
}, 5000);
const handler = (msg) => {
if (resolved) return;
clearTimeout(timeout);
try {
const response = dnsPacket.decode(msg);
logDebug('DNSPool', `Public DNS response for ${query.questions[0].name} from ${publicDNSServer}: ${JSON.stringify(response.answers)}`);
cleanup();
resolve(response);
} catch (err) {
logError('DNSPool', `Error decoding DNS response from ${publicDNSServer}: ${err.message}`);
cleanup();
resolve(null);
}
};
const errorHandler = (err) => {
if (resolved) return;
clearTimeout(timeout);
logWarn('DNSPool', `Error receiving DNS response from ${publicDNSServer}: ${err.message}`);
cleanup();
resolve(null);
};
// Store query data with resolver reference for proper cleanup
this.activeQueries.set(queryId, { handler, errorHandler, timeout, resolver });
resolver.once('message', handler);
resolver.once('error', errorHandler);
const encodedQuery = dnsPacket.encode(query);
resolver.send(encodedQuery, 53, publicDNSServer, (err) => {
if (err) {
clearTimeout(timeout);
logWarn('DNSPool', `Error forwarding DNS query to ${publicDNSServer}: ${err.message}`);
resolver.removeListener('message', handler);
resolver.removeListener('error', errorHandler);
cleanup();
resolve(null);
}
});
});
// If we got a successful response, return it
if (result) {
return result;
}
// If this was the last server, return null
if (isLastServer) {
logWarn('DNSPool', `All DNS servers failed for ${query.questions[0].name}`);
return null;
}
// Otherwise, try next server
logDebug('DNSPool', `DNS server ${publicDNSServer} failed, trying next server...`);
}
return null;
}
/**
* Close all resolvers
*/
close() {
// Clean up all active queries and their listeners
for (const [queryId, query] of this.activeQueries.entries()) {
if (query.timeout) {
clearTimeout(query.timeout);
}
// Remove listeners from resolver
if (query.resolver && query.handler) {
try {
query.resolver.removeListener('message', query.handler);
} catch (err) {
logDebug('DNSPool', `Error removing message listener: ${err.message}`);
}
}
if (query.resolver && query.errorHandler) {
try {
query.resolver.removeListener('error', query.errorHandler);
} catch (err) {
logDebug('DNSPool', `Error removing error listener: ${err.message}`);
}
}
}
this.activeQueries.clear();
// Close all resolvers and remove all listeners
for (const resolver of this.resolvers) {
try {
// Remove all listeners to prevent leaks
resolver.removeAllListeners();
resolver.close();
} catch (err) {
logError('DNSPool', `Error closing resolver: ${err.message}`);
}
}
this.resolvers = [];
}
}
// Global DNS pool instance
const dnsPool = new DNSPool(parseInt(process.env.DNS_POOL_SIZE || '5', 10));
/**
* Update DNS pool configuration at runtime
* @param {number} maxConnections - New maximum number of connections
*/
function updateDnsPool(maxConnections) {
const oldMax = dnsPool.maxConnections;
dnsPool.maxConnections = maxConnections;
// If reducing pool size, close excess resolvers
if (maxConnections < oldMax && dnsPool.resolvers.length > maxConnections) {
const excessResolvers = dnsPool.resolvers.splice(maxConnections);
for (const resolver of excessResolvers) {
try {
resolver.removeAllListeners();
resolver.close();
} catch (err) {
logError('DNSPool', `Error closing excess resolver: ${err.message}`);
}
}
logInfo('DNSPool', `Reduced pool size from ${oldMax} to ${maxConnections}, closed ${excessResolvers.length} resolvers`);
} else if (maxConnections > oldMax) {
logInfo('DNSPool', `Increased pool size from ${oldMax} to ${maxConnections}, new resolvers will be created on demand`);
}
// PUBLIC_DNS_SERVER is already read from process.env at query time, so no action needed
}
module.exports = { dnsPool, DNSPool, updateDnsPool };
+380
View File
@@ -0,0 +1,380 @@
const http = require('http');
const net = require('net');
const Holesail = require('holesail');
const state = require('../infrastructure/state');
const { logDebug, logInfo, logError, logWarn } = require('../infrastructure/logger');
const ca = require('../security/certificate_authority');
const { createTlsProxy } = require('./p2p_domains_proxy');
const { checkPortResponsive, parseMinutesToMs, parseSecondsToMs, secondsToMs } = require('../infrastructure/utils');
const { checkPortAvailability, freePort } = require('../maintenance/cleanup');
const { trackHolesailEvent } = require('../maintenance/metrics');
const { retryWithBackoff } = require('../infrastructure/async_errors');
const { getCircuitBreaker } = require('../infrastructure/circuit_breaker');
// Cleanup connection resources
async function cleanupConnection(key, domain, hash, ip, port, shouldRestart = false) {
logInfo('Holesail', `Cleaning up connection for ${key}`);
// Check if this was a persistent connection before cleanup
const wasPersistent = state.persistentConnections && state.persistentConnections.has(key);
// Remove from holesails map
const holesail = state.holesails.get(key);
if (holesail) {
try {
// Remove all event listeners before closing to prevent leaks
holesail.removeAllListeners();
await holesail.close();
} catch (err) {
logError('Holesail', `Error closing holesail client during cleanup for ${key}: ${err.message}`);
}
state.holesails.delete(key);
// Clean up start time tracking
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
}
// Clear timeout if it exists
const timeout = state.holesailClientTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
state.holesailClientTimeouts.delete(key);
}
// Remove from persistent connections
if (state.persistentConnections) {
state.persistentConnections.delete(key);
}
// Remove TLS server reference (but don't close - servers are shared across domains on same IP)
// The servers are created by createTlsProxy and shared per bind IP, not per domain
state.tlsServers.delete(key);
// Remove HTTP server reference (but don't close - servers are shared across domains on same IP)
// Note: The HTTP redirect server created separately for port 80 is also per-domain,
// but we don't close it here to avoid breaking other domains using the same IP
state.httpServers.delete(key);
// If this was a persistent connection and we should restart, attempt to restart it
// Skip restart if FULL_PERSISTENCE is enabled
if (wasPersistent && shouldRestart && process.env.FULL_PERSISTENCE !== 'true') {
logInfo('Holesail', `Attempting to restart persistent connection for ${key}`);
// Use a small delay to avoid immediate restart loops
setTimeout(async () => {
try {
await restartHolesailClient(domain, hash, ip, port);
} catch (err) {
logError('Holesail', `Failed to restart persistent connection for ${key}: ${err.message}`);
}
}, 2000);
}
}
// Start Holesail client
async function startHolesailClient(domain, hash, ip, port, persistent = false) {
if (!ip) {
logDebug('Holesail', `Invalid IP for domain: ${domain}`);
return;
}
const key = `${domain}:${port}`;
if (state.holesails.has(key)) {
logDebug('Holesail', `Holesail client already exists for ${key}`);
return;
}
let startPromise = state.starting.get(key);
if (!startPromise) {
startPromise = (async () => {
logInfo('Holesail', `Starting Holesail client for domain: ${domain}, hash: ${hash}, IP: ${ip}, Port: ${port}`);
try {
// Check port availability
try {
await checkPortAvailability(ip, port);
} catch (err) {
logWarn('Holesail', `Port check failed initially for ${ip}:${port}: ${err.message}. Attempting to free the port.`);
const freed = await freePort(ip, port);
if (!freed) {
throw new Error(`Unable to free port ${port} on ${ip} after attempt`);
}
logInfo('Holesail', `Successfully freed port ${port} on ${ip}`);
}
// Use circuit breaker for Holesail connections
const circuitBreaker = getCircuitBreaker(`holesail-${domain}`, {
failureThreshold: 3,
resetTimeout: secondsToMs(30) // 30 seconds
});
const holesail = new Holesail({
client: true,
key: hash,
port: port,
host: ip,
log: false
});
try {
const startTime = Date.now();
// Use retry with backoff and circuit breaker
await circuitBreaker.execute(async () => {
return await retryWithBackoff(
async () => {
await holesail.ready();
return holesail;
},
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
shouldRetry: (err) => {
// Retry on connection errors, not on auth errors
return err.message && (
err.message.includes('ECONNREFUSED') ||
err.message.includes('ETIMEDOUT') ||
err.message.includes('ENOTFOUND')
);
}
}
);
}, `holesail-${domain}`);
state.holesails.set(key, holesail);
// Track start time for uptime calculation
if (!state.holesailStartTimes) {
state.holesailStartTimes = new Map();
}
state.holesailStartTimes.set(key, Date.now());
if (!state.persistentConnections) {
state.persistentConnections = new Set();
}
// If FULL_PERSISTENCE is enabled, make all connections persistent
if (persistent || process.env.FULL_PERSISTENCE === 'true') {
state.persistentConnections.add(key);
}
trackHolesailEvent('client', 'start', 'tcp', null);
logInfo('Holesail', `Holesail client for ${key} connected on ${ip}:${port}`);
// Add error and close event handlers to detect connection failures
holesail.on('error', async (err) => {
logError('Holesail', `Connection error for ${key}: ${err.message}`);
// Only restart if it was a persistent connection and FULL_PERSISTENCE is not enabled
const shouldRestart = state.persistentConnections && state.persistentConnections.has(key) && process.env.FULL_PERSISTENCE !== 'true';
await cleanupConnection(key, domain, hash, ip, port, shouldRestart);
});
holesail.on('close', async () => {
logWarn('Holesail', `Connection closed for ${key}`);
// Only restart if it was a persistent connection and FULL_PERSISTENCE is not enabled
const shouldRestart = state.persistentConnections && state.persistentConnections.has(key) && process.env.FULL_PERSISTENCE !== 'true';
await cleanupConnection(key, domain, hash, ip, port, shouldRestart);
});
if (process.env.DISABLE_PROXY_SERVER !== 'true') {
// Start TLS proxy
const { tlsServer, httpServer: tlsHttpServer } = createTlsProxy(domain, ip, port, logDebug);
state.tlsServers.set(key, tlsServer);
// Start HTTP redirect server (only for port 80)
if (port === parseInt(process.env.HTTP_PORT || 80)) {
const httpServer = http.createServer((req, res) => {
const host = req.headers.host || domain;
res.writeHead(301, { 'Location': `https://${host}${req.url}` });
res.end();
});
try {
await checkPortAvailability(ip, port); // Check again for HTTP server
httpServer.listen(port, ip, () => {
logInfo('Holesail', `HTTP redirect server for ${key} listening on ${ip}:${port}`);
});
state.httpServers.set(key, httpServer);
} catch (err) {
logError('Holesail', `Failed to start HTTP redirect server for ${key}: ${err.message}`);
}
}
} else {
logInfo('Holesail', `Skipping TLS proxy and HTTP redirect server for ${key} due to DISABLE_PROXY_SERVER=true`);
}
// Skip timeout if FULL_PERSISTENCE is enabled or connection is persistent
if (!persistent && process.env.FULL_PERSISTENCE !== 'true') {
const timeout = setTimeout(async () => {
// Check if connection still exists before cleaning up (may have been cleaned up already)
if (!state.holesails.has(key)) {
return;
}
const duration = Date.now() - startTime;
logInfo('Holesail', `Closing Holesail client for ${key}`);
try {
// Remove listeners before closing
if (holesail) {
holesail.removeAllListeners();
await holesail.close();
}
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
state.holesailClientTimeouts.delete(key);
trackHolesailEvent('client', 'stop', 'tcp', duration);
// Remove server references (but don't close - servers are shared across domains on same IP)
// The servers are created by createTlsProxy and shared per bind IP, not per domain
// Closing them would break all domains using that IP, including the internal proxy server
state.tlsServers.delete(key);
state.httpServers.delete(key);
} catch (err) {
logError('Holesail', `Error closing Holesail client for ${key}: ${err.message}`);
}
}, parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5'));
state.holesailClientTimeouts.set(key, timeout);
}
} catch (err) {
logError('Holesail', `Error connecting Holesail client for ${key}: ${err.message}`);
}
} catch (err) {
logError('Holesail', `Failed to start Holesail client for ${key}: ${err.message}`);
} finally {
state.starting.delete(key);
}
})();
state.starting.set(key, startPromise);
}
await startPromise;
}
// Restart Holesail client
async function restartHolesailClient(domain, hash, ip, port) {
if (!ip) {
logDebug('Holesail', `Invalid IP for domain: ${domain}`);
return;
}
const key = `${domain}:${port}`;
const isResponsive = await checkPortResponsive(ip, port);
if (isResponsive) {
logDebug('Holesail', `Port ${port} on ${ip} is responsive, using the existing connection.`);
// Ensure this connection is marked as persistent
if (!state.persistentConnections) {
state.persistentConnections = new Set();
}
state.persistentConnections.add(key);
// Clear any existing timeout to make it truly persistent
const timeout = state.holesailClientTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
state.holesailClientTimeouts.delete(key);
logDebug('Holesail', `Cleared timeout for ${key} to make it persistent`);
}
return;
}
logInfo('Holesail', `Port ${port} on ${ip} is unresponsive, closing and recreating the Holesail client`);
// Use the cleanup function to ensure all resources are properly closed
// Don't auto-restart here since we'll restart manually below
await cleanupConnection(key, domain, hash, ip, port, false);
if (state.starting.has(key)) {
await state.starting.get(key);
}
try {
// In restart, also attempt to free if needed, but since we closed, it should be free, but to be safe
try {
await checkPortAvailability(ip, port);
} catch (err) {
logWarn('Holesail', `Port still in use after close for ${ip}:${port}: ${err.message}. Attempting to free.`);
const freed = await freePort(ip, port);
if (!freed) {
throw new Error(`Unable to free port ${port} on ${ip} during restart`);
}
logInfo('Holesail', `Successfully freed port ${port} on ${ip} during restart`);
}
// Restart as persistent connection (no timeout)
await startHolesailClient(domain, hash, ip, port, true);
} catch (err) {
logError('Holesail', `Failed to restart Holesail client for ${key}: ${err.message}`);
}
}
// Periodic health check for all active connections
let healthCheckInterval = null;
function startHealthChecks() {
const intervalMs = parseSecondsToMs(process.env.HOLESAIL_HEALTH_CHECK_INTERVAL || '30'); // Default 30 seconds
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
healthCheckInterval = setInterval(async () => {
const keysToCheck = Array.from(state.holesails.keys());
if (keysToCheck.length === 0) {
return;
}
logDebug('Holesail', `Running health check on ${keysToCheck.length} connections`);
for (const key of keysToCheck) {
const [domain, portStr] = key.split(':');
const port = parseInt(portStr);
const ip = state.domainToIPMap.get(domain);
if (!ip) {
logWarn('Holesail', `No IP found for domain ${domain} during health check`);
continue;
}
// Get hash for domain
// Skip health check if DNS service is not initialized
if (!state.dnsPass) {
logDebug('Holesail', `DNS service not initialized, skipping health check for ${key}`);
continue;
}
const { getHashForDomain } = require('../core/core');
const hash = await getHashForDomain(domain);
if (!hash) {
logWarn('Holesail', `No hash found for domain ${domain} during health check`);
continue;
}
// Check if port is responsive
const isResponsive = await checkPortResponsive(ip, port);
if (!isResponsive) {
logWarn('Holesail', `Health check failed for ${key}, connection is unresponsive`);
// Skip health check restarts if FULL_PERSISTENCE is enabled
if (process.env.FULL_PERSISTENCE === 'true') {
logDebug('Holesail', `FULL_PERSISTENCE is enabled, skipping health check restart for ${key}`);
continue;
}
// If it's a persistent connection, restart it
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
if (isPersistent) {
logInfo('Holesail', `Restarting unresponsive persistent connection ${key}`);
try {
await restartHolesailClient(domain, hash, ip, port);
} catch (err) {
logError('Holesail', `Failed to restart connection ${key} during health check: ${err.message}`);
}
} else {
// For non-persistent connections, just clean up
logInfo('Holesail', `Cleaning up unresponsive non-persistent connection ${key}`);
await cleanupConnection(key, domain, hash, ip, port, false);
}
} else {
logDebug('Holesail', `Health check passed for ${key}`);
}
}
}, intervalMs);
logInfo('Holesail', `Started periodic health checks (interval: ${intervalMs}ms)`);
}
function stopHealthChecks() {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
healthCheckInterval = null;
logInfo('Holesail', 'Stopped periodic health checks');
}
}
module.exports = { startHolesailClient, restartHolesailClient, startHealthChecks, stopHealthChecks };
+58
View File
@@ -0,0 +1,58 @@
const Holesail = require('holesail');
const { checkPortAvailability, freePort } = require('../maintenance/cleanup');
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
debug: console.debug
};
process.on('message', async (msg) => {
if (msg.type === 'start') {
try {
// Override console methods to send logs via IPC
console.log = (...args) => process.send({ type: 'log', level: 'info', message: args.join(' ') });
console.error = (...args) => process.send({ type: 'log', level: 'error', message: args.join(' ') });
console.warn = (...args) => process.send({ type: 'log', level: 'warn', message: args.join(' ') });
console.debug = (...args) => process.send({ type: 'log', level: 'debug', message: args.join(' ') });
const opts = msg.opts;
// Only check and free port for clients, not servers
if (!opts.server) {
let portFree = true;
try {
await checkPortAvailability(opts.host, opts.port);
console.debug(`Port ${opts.port} on ${opts.host} is available`);
} catch (err) {
console.warn(`Initial port check failed for ${opts.host}:${opts.port}: ${err.message}`);
console.info(`Attempting to free the port`);
portFree = await freePort(opts.host, opts.port);
if (!portFree) {
throw new Error(`Unable to free port ${opts.port} on ${opts.host}`);
}
console.info(`Port ${opts.port} on ${opts.host} freed successfully`);
}
} else {
console.debug(`Skipping port check for server on ${opts.host}:${opts.port}`);
}
const holesail = new Holesail(opts);
await holesail.ready();
process.send({ type: 'ready', info: holesail.info });
// Keep running
} catch (err) {
process.send({ type: 'error', message: err.message });
process.exit(1);
}
}
});
// Handle uncaught errors
process.on('uncaughtException', (err) => {
process.send({ type: 'log', level: 'error', message: `Uncaught exception: ${err.message}` });
});
process.on('unhandledRejection', (reason) => {
process.send({ type: 'log', level: 'error', message: `Unhandled rejection: ${reason}` });
});
@@ -0,0 +1,601 @@
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 };
+369
View File
@@ -0,0 +1,369 @@
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,
};
+567
View File
@@ -0,0 +1,567 @@
const { exec } = require('child_process');
const os = require('os');
const util = require('util');
const state = require('../infrastructure/state');
const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logger');
const execAsync = util.promisify(exec);
// Helper function to calculate available IPs for a subnet
function getAvailableIPsForSubnet(subnet) {
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2; // Subtract network and broadcast
const startIndex = subnet.startIndex || 2;
const endIndex = Math.min(254, maxIPs); // Cap at 254 for /24 and smaller
return Math.max(0, endIndex - startIndex + 1);
}
// Helper function to check if an IP belongs to any configured subnet
function ipBelongsToSubnet(ip, subnet) {
const ipParts = ip.split('.').map(p => parseInt(p, 10));
const subnetParts = subnet.base.split('.').map(p => parseInt(p, 10));
// Calculate network mask
const maskBits = subnet.cidr;
const maskParts = [];
for (let i = 0; i < 4; i++) {
const bits = Math.min(8, Math.max(0, maskBits - i * 8));
maskParts.push(bits === 8 ? 255 : bits === 0 ? 0 : (255 << (8 - bits)) & 255);
}
// Check if IP is in subnet
for (let i = 0; i < 4; i++) {
if ((ipParts[i] & maskParts[i]) !== (subnetParts[i] & maskParts[i])) {
return false;
}
}
return true;
}
// Helper function to generate IP from subnet and index
function generateIPFromSubnet(subnet, index) {
const baseParts = subnet.base.split('.').map(p => parseInt(p, 10));
const lastOctet = index;
return `${baseParts[0]}.${baseParts[1]}.${baseParts[2]}.${lastOctet}`;
}
// Helper function to check if base interface is up
async function ensureBaseInterfaceUp() {
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return true;
}
if (!state.subnetName) {
return false;
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep -q 'status: active' || ifconfig ${state.subnetName} | grep -q 'UP'`;
} else if (os.platform() === 'linux') {
command = `ip link show ${state.subnetName} | grep -q 'state UP'`;
} else if (os.platform() === 'win32') {
// Windows loopback interfaces are typically always up
return true;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, cannot verify interface state`);
return false;
}
await execAsync(command);
return true;
} catch (err) {
// Interface is not up, try to bring it up
logDebug('VirtualInterface', `Base interface ${state.subnetName} is not up, attempting to bring it up`);
try {
let upCommand;
if (os.platform() === 'darwin') {
// macOS loopback interfaces are typically always up, but we can verify
upCommand = `ifconfig ${state.subnetName} up`;
} else if (os.platform() === 'linux') {
upCommand = `sudo ip link set ${state.subnetName} up`;
} else {
return false;
}
await execAsync(upCommand);
logInfo('VirtualInterface', `Brought base interface ${state.subnetName} up`);
return true;
} catch (upErr) {
logWarn('VirtualInterface', `Could not bring base interface ${state.subnetName} up: ${upErr.message}`);
return false;
}
}
}
// Helper function to verify interface state after IP assignment
async function verifyInterfaceState(ip) {
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return true;
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep 'inet ${ip}'`;
} else if (os.platform() === 'linux') {
command = `ip addr show ${state.subnetName} | grep 'inet ${ip}/'`;
} else if (os.platform() === 'win32') {
// Windows: Verify IP is configured
command = `netsh interface ip show addresses "${state.subnetName || 'Loopback Pseudo-Interface 1'}" | findstr "${ip}"`;
} else {
return false;
}
await execAsync(command);
return true;
} catch (err) {
logWarn('VirtualInterface', `Could not verify interface state for IP ${ip}: ${err.message}`);
return false;
}
}
// Helper function to get all configured IPs on the interface
async function getConfiguredIPs() {
// If virtual interfaces are disabled, return empty array
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return [];
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep 'inet ' | awk '{print $2}'`;
} else if (os.platform() === 'linux') {
command = `ip addr show ${state.subnetName} | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1`;
} else if (os.platform() === 'win32') {
// Windows: Get IP addresses using netsh
command = `netsh interface ip show addresses "${state.subnetName || 'Loopback Pseudo-Interface 1'}" | findstr "IP Address"`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, assuming no configured IPs`);
return [];
}
const { stdout } = await execAsync(command);
if (os.platform() === 'win32') {
// Parse Windows output: "IP Address: 192.168.3.2"
return stdout.split('\n')
.map(line => {
const match = line.match(/IP Address:\s*(\d+\.\d+\.\d+\.\d+)/);
return match ? match[1] : null;
})
.filter(ip => ip);
}
return stdout.split('\n').map(ip => ip.trim()).filter(ip => ip);
} catch (err) {
logError('VirtualInterface', `Error listing configured IPs: ${err.message}`);
return [];
}
}
// Clean up invalid or stale IPs from state.domainToIPMap at startup
async function cleanInvalidIPs() {
// Skip cleanup if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping IP cleanup');
return;
}
// Get list of plugin/internal domains (these use 127.0.0.1)
let pluginDomains = [];
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
pluginDomains = await getInternalDomains();
} catch (err) {
logDebug('VirtualInterface', `Could not get plugin domains during cleanup: ${err.message}`);
}
const pluginDomainsSet = new Set(pluginDomains);
const configuredIPs = await getConfiguredIPs();
const validSubnets = state.subnets || [];
// If no subnets configured, fall back to legacy SUBNET_BASE check
const legacySubnetBase = process.env.SUBNET_BASE || '192.168.3.';
for (const [domain, ip] of state.domainToIPMap) {
// Skip cleanup for plugin/internal domains (they use 127.0.0.1)
if (pluginDomainsSet.has(domain)) {
if (ip === '127.0.0.1') {
logDebug('VirtualInterface', `Preserving plugin domain ${domain} with IP 127.0.0.1`);
continue;
} else {
// Plugin domain had wrong IP, update it to 127.0.0.1
logWarn('VirtualInterface', `Plugin domain ${domain} had wrong IP ${ip}, updating to 127.0.0.1`);
if (configuredIPs.includes(ip)) {
await removeVirtualInterface(ip);
}
state.domainToIPMap.set(domain, '127.0.0.1');
continue;
}
}
// For non-plugin domains, validate IP belongs to configured subnets
const ipParts = ip.split('.');
let isValid = false;
// Basic IP format validation
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
isValid = false;
} else if (validSubnets.length > 0) {
// Check if IP belongs to any configured subnet
isValid = validSubnets.some(subnet => ipBelongsToSubnet(ip, subnet));
} else {
// Legacy: check if IP starts with SUBNET_BASE
isValid = ip.startsWith(legacySubnetBase);
}
if (!isValid) {
logWarn('VirtualInterface', `Removing invalid or stale IP ${ip} for domain ${domain} from state`);
if (configuredIPs.includes(ip)) {
await removeVirtualInterface(ip);
}
state.domainToIPMap.delete(domain);
} else if (configuredIPs.includes(ip)) {
logDebug('VirtualInterface', `IP ${ip} for domain ${domain} is valid and configured`);
} else {
logWarn('VirtualInterface', `IP ${ip} for domain ${domain} not configured, removing from state`);
state.domainToIPMap.delete(domain);
}
}
}
cleanInvalidIPs();
// Helper function to remove virtual interface
async function removeVirtualInterface(ip) {
return new Promise((resolve) => {
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping interface removal');
resolve();
return;
}
if (!state.subnetName) {
logWarn('VirtualInterface', 'SUBNET_NAME not defined, skipping interface removal');
resolve();
return;
}
// Validate IP format
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
logWarn('VirtualInterface', `Skipping removal of invalid IP ${ip}`);
resolve();
return;
}
// Check if IP is configured
getConfiguredIPs().then(configuredIPs => {
if (!configuredIPs.includes(ip)) {
logDebug('VirtualInterface', `IP ${ip} not configured on system, skipping removal`);
resolve();
return;
}
const cidrIp = `${ip}/24`;
let command;
if (os.platform() === 'darwin') {
command = `sudo ifconfig ${state.subnetName} -alias ${ip}`;
} else if (os.platform() === 'linux') {
command = `sudo ip addr del ${cidrIp} dev ${state.subnetName}`;
} else if (os.platform() === 'win32') {
// Windows: Remove IP address using netsh
const interfaceName = state.subnetName || 'Loopback Pseudo-Interface 1';
command = `netsh interface ip delete address "${interfaceName}" ${ip}`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, skipping interface removal`);
resolve();
return;
}
exec(command, (err, stdout, stderr) => {
if (err) {
logError('VirtualInterface', `Error removing virtual interface ${ip}: ${stderr || err.message}`);
} else {
logInfo('VirtualInterface', `Removed virtual interface IP: ${ip}`);
}
resolve();
});
}).catch(err => {
logError('VirtualInterface', `Error checking if IP ${ip} exists: ${err.message}`);
resolve();
});
});
}
// Helper function to create virtual interface
async function createVirtualInterface(ip) {
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', 'Virtual interfaces disabled, skipping interface creation');
return ip; // Return the IP even though we're not creating the interface
}
// Check if IP is already configured
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(ip)) {
logWarn('VirtualInterface', `IP ${ip} already configured, using it`);
// Verify interface is up for ICMP
const isUp = await ensureBaseInterfaceUp();
if (isUp) {
logDebug('VirtualInterface', `Interface ${state.subnetName} is up, ICMP should be functional for ${ip}`);
}
return ip;
}
await removeVirtualInterface(ip); // Ensure IP is not already assigned
// Ensure base interface is up before adding IPs
const baseInterfaceUp = await ensureBaseInterfaceUp();
if (!baseInterfaceUp) {
logWarn('VirtualInterface', `Base interface ${state.subnetName} is not up, ICMP may not work for ${ip}`);
}
if (!state.subnetName) {
logWarn('VirtualInterface', 'Virtual interfaces not supported, skipping');
return ip;
}
const cidr = '/24';
const mask = '255.255.255.0';
let command;
if (os.platform() === 'darwin') {
command = `sudo ifconfig ${state.subnetName} alias ${ip} ${mask}`;
} else if (os.platform() === 'linux') {
command = `sudo ip addr add ${ip}${cidr} dev ${state.subnetName}`;
} else if (os.platform() === 'win32') {
// Windows: Add IP address using netsh
const interfaceName = state.subnetName || 'Loopback Pseudo-Interface 1';
command = `netsh interface ip add address "${interfaceName}" ${ip} ${mask}`;
} else {
logWarn('VirtualInterface', `Unsupported platform: ${os.platform()}, skipping interface creation`);
return ip;
}
try {
const { stdout, stderr } = await execAsync(command);
logInfo('VirtualInterface', `Created virtual interface with IP ${ip}`);
// Platform-specific post-creation steps for ICMP support
try {
if (os.platform() === 'linux') {
// On Linux, ensure the interface is up after adding IP
// This is critical for ICMP to work
const upCommand = `sudo ip link set ${state.subnetName} up`;
try {
await execAsync(upCommand);
logDebug('VirtualInterface', `Ensured interface ${state.subnetName} is up for ICMP support`);
} catch (upErr) {
logWarn('VirtualInterface', `Could not bring interface up: ${upErr.message}, ICMP may not work`);
}
} else if (os.platform() === 'darwin') {
// On macOS, verify the interface state after alias creation
const verified = await verifyInterfaceState(ip);
if (verified) {
logDebug('VirtualInterface', `Verified interface state for ${ip}, ICMP should be functional`);
} else {
logWarn('VirtualInterface', `Could not verify interface state for ${ip}, ICMP may not work`);
}
} else if (os.platform() === 'win32') {
// Windows loopback interfaces typically respond to ICMP automatically
const verified = await verifyInterfaceState(ip);
if (verified) {
logDebug('VirtualInterface', `Verified IP ${ip} is configured, ICMP should be functional`);
}
}
// Log that ICMP should now be functional
// Note: Firewall rules may still block ICMP, but the interface is configured correctly
logInfo('VirtualInterface', `Interface ${ip} is configured and ready for ICMP (ping) requests. Note: Ensure firewall allows ICMP if ping fails.`);
} catch (verifyErr) {
logWarn('VirtualInterface', `Error during post-creation verification for ${ip}: ${verifyErr.message}`);
}
return ip;
} catch (err) {
const errorMessage = err.stderr || err.message || '';
if (errorMessage.includes('Address already assigned') || errorMessage.includes('File exists')) {
logWarn('VirtualInterface', `IP ${ip} already assigned, using it`);
return ip;
} else {
logError('VirtualInterface', `Error creating virtual interface ${ip}: ${errorMessage}`);
throw new Error(`Error creating virtual interface ${ip}: ${errorMessage}`);
}
}
}
// Function to create interface for domain with round-robin across multiple subnets
async function createInterfaceForDomain(domain) {
// Check if this is a plugin/internal domain (these run on 127.0.0.1 and don't need virtual interfaces)
try {
const { getInternalDomains } = require('../plugins/plugin-handler');
const internalDomains = await getInternalDomains();
if (internalDomains.includes(domain)) {
const pluginIP = '127.0.0.1';
const existingIP = state.domainToIPMap.get(domain);
// If domain already has a virtual IP, remove it and update to 127.0.0.1
if (existingIP && existingIP !== pluginIP) {
logWarn('VirtualInterface', `Plugin domain ${domain} had virtual IP ${existingIP}, removing virtual interface and updating to 127.0.0.1`);
try {
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(existingIP)) {
await removeVirtualInterface(existingIP);
}
} catch (removeErr) {
logWarn('VirtualInterface', `Error removing virtual interface for plugin domain: ${removeErr.message}`);
}
}
// Always set plugin domains to 127.0.0.1
state.domainToIPMap.set(domain, pluginIP);
logDebug('VirtualInterface', `Domain ${domain} is a plugin/internal domain, using 127.0.0.1 (no virtual interface created)`);
return pluginIP;
}
} catch (err) {
// If plugin handler is not available, continue with normal flow
logDebug('VirtualInterface', `Could not check if ${domain} is a plugin domain: ${err.message}`);
}
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('VirtualInterface', `Virtual interfaces disabled, skipping interface creation for domain: ${domain}`);
// Still return an IP for the domain mapping, but don't create the interface
// Generate a placeholder IP that won't be used for actual interface creation
if (!state.domainToIPMap.has(domain)) {
// Generate a placeholder IP based on domain hash to ensure consistency
const crypto = require('crypto');
const hash = crypto.createHash('md5').update(domain).digest('hex');
const ipIndex = parseInt(hash.substring(0, 2), 16) % 253 + 2; // Ensure it's between 2-254
const subnetBase = process.env.SUBNET_BASE || '192.168.3.';
const placeholderIP = `${subnetBase}${ipIndex}`;
state.domainToIPMap.set(domain, placeholderIP);
logDebug('VirtualInterface', `Assigned placeholder IP: ${placeholderIP} for domain: ${domain} (interface not created)`);
}
return state.domainToIPMap.get(domain);
}
if (state.domainToIPMap.has(domain)) {
const existingIP = state.domainToIPMap.get(domain);
// Validate existing IP
const ipParts = existingIP.split('.');
if (ipParts.length === 4 && ipParts.every(part => !isNaN(part) && part >= 0 && part <= 255)) {
// Check if IP is still configured
const configuredIPs = await getConfiguredIPs();
if (configuredIPs.includes(existingIP)) {
logDebug('VirtualInterface', `Interface already assigned for domain: ${domain}, IP: ${existingIP}`);
return existingIP;
} else {
logWarn('VirtualInterface', `IP ${existingIP} for domain ${domain} no longer configured, reassigning`);
state.domainToIPMap.delete(domain);
}
} else {
logWarn('VirtualInterface', `Removing invalid existing IP ${existingIP} for domain ${domain}`);
await removeVirtualInterface(existingIP);
state.domainToIPMap.delete(domain);
}
}
const subnets = state.subnets || [];
let attempts = 0;
const maxAttempts = 50; // Increased for multiple subnets
const maxSubnetAttempts = 10; // Max attempts per subnet before moving to next
// If no subnets configured, fall back to legacy behavior
if (subnets.length === 0) {
const subnetBase = process.env.SUBNET_BASE || '192.168.3.';
while (attempts < maxAttempts) {
const subnetID = state.currentIP;
const ip = `${subnetBase}${subnetID}`;
try {
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
throw new Error(`Invalid IP format: ${ip}`);
}
for (const [existingDomain, existingIP] of state.domainToIPMap) {
if (existingIP === ip && existingDomain !== domain) {
throw new Error(`IP ${ip} already assigned to ${existingDomain}`);
}
}
const assignedIP = await createVirtualInterface(ip);
state.domainToIPMap.set(domain, assignedIP);
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain}`);
state.currentIP++;
return assignedIP;
} catch (error) {
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
state.currentIP++;
attempts++;
}
}
logError('VirtualInterface', `Exceeded max attempts to assign IP for domain: ${domain}`);
return null;
}
// Round-robin across multiple subnets
let subnetAttempts = 0;
let startSubnetIndex = state.currentSubnetIndex;
while (attempts < maxAttempts && subnetAttempts < maxSubnetAttempts * subnets.length) {
const subnetIndex = state.currentSubnetIndex;
const subnet = subnets[subnetIndex];
if (!subnet) {
logError('VirtualInterface', `Invalid subnet index: ${subnetIndex}`);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
subnetAttempts++;
continue;
}
// Get current IP counter for this subnet
const currentIPIndex = state.subnetIPCounters.get(subnetIndex) || subnet.startIndex;
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2;
const maxIndex = Math.min(254, maxIPs); // Cap at 254
// Check if subnet is exhausted
if (currentIPIndex > maxIndex) {
logWarn('VirtualInterface', `Subnet ${subnet.name} (${subnet.base}/${subnet.cidr}) exhausted, moving to next`);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
subnetAttempts++;
// If we've tried all subnets, break
if (state.currentSubnetIndex === startSubnetIndex && subnetAttempts > 0) {
logError('VirtualInterface', `All subnets exhausted, cannot assign IP for domain: ${domain}`);
break;
}
continue;
}
const ip = generateIPFromSubnet(subnet, currentIPIndex);
try {
// Validate IP format
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
throw new Error(`Invalid IP format: ${ip}`);
}
// Check if IP is already assigned to another domain
for (const [existingDomain, existingIP] of state.domainToIPMap) {
if (existingIP === ip && existingDomain !== domain) {
throw new Error(`IP ${ip} already assigned to ${existingDomain}`);
}
}
const assignedIP = await createVirtualInterface(ip);
state.domainToIPMap.set(domain, assignedIP);
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain} from subnet ${subnet.name}`);
// Increment IP counter for this subnet and move to next subnet (round-robin)
state.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
return assignedIP;
} catch (error) {
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
// Increment IP counter and try next IP in same subnet
state.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
attempts++;
subnetAttempts++;
}
}
logError('VirtualInterface', `Exceeded max attempts to assign IP for domain: ${domain}`);
return null;
}
module.exports = {
removeVirtualInterface,
createVirtualInterface,
createInterfaceForDomain,
getAvailableIPsForSubnet,
ipBelongsToSubnet,
generateIPFromSubnet
};