reorg
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user