Cut over domain resolution to an Autobase apply-based sidecar fed by claim/vote dual-writes from dnsPass. Remove the legacy full-KV scan getConsensusState path and wire all reads through consensus-view. - Add consensus-resolver, consensus-events, consensus-apply, consensus-autobase, and consensus-view modules - Dual-append consensus events on claim/vote dnsPassAdd/Remove - Bootstrap sidecar from existing KV entries; persist consensusAutobaseKey in network manifest - Expose sidecar health via GET /api/consensus/status and metrics - Add consensus-resolver and consensus-apply unit tests - Expand RFC 0001 to Implemented; update CONSENSUS.md Rollback: deploy prior release; KV data unchanged, sidecar rebuilds on next startup.
1387 lines
47 KiB
JavaScript
1387 lines
47 KiB
JavaScript
const express = require('express');
|
|
const httpProxy = require('http-proxy');
|
|
const http = require('http');
|
|
const net = require('net');
|
|
const url = require('url');
|
|
const Holesail = require('holesail');
|
|
const Corestore = require('corestore');
|
|
const Hyperswarm = require('hyperswarm');
|
|
const Autopass = require('autopass');
|
|
const Protomux = require('protomux');
|
|
const c = require('compact-encoding');
|
|
const crypto = require('crypto');
|
|
const process = require('process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { parseMinutesToMs, parseSecondsToMs, secondsToMs, loadOrCreateKeypair, setupCache, getPersistentPublicKey, isSecureHolesailKey } = require('../includes/infrastructure/utils');
|
|
const { validateDomainDetailed } = require('../includes/infrastructure/validation');
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
const config = {
|
|
// Server configuration
|
|
PROXY_PORT: parseInt(process.env.PROXY_PORT || '5577'),
|
|
PROXY_HOST: process.env.PROXY_HOST || '0.0.0.0',
|
|
|
|
// P2NS configuration
|
|
TOPIC_SEED: process.env.TOPIC_SEED || 'p2ns-dns',
|
|
STORAGE_DIR: process.env.STORAGE_DIR || './my-proxy-storage',
|
|
|
|
// Holesail configuration
|
|
HOLESAIL_TIMEOUT: parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '720'), // 12 hours = 720 minutes
|
|
FULL_PERSISTENCE: process.env.FULL_PERSISTENCE === 'true',
|
|
HTTP_PORT: parseInt(process.env.HTTP_PORT || '80'),
|
|
DISABLE_PROXY_SERVER: process.env.DISABLE_PROXY_SERVER === 'true',
|
|
MAX_HOLESAIL_CLIENTS: parseInt(process.env.MAX_HOLESAIL_CLIENTS || '100'),
|
|
|
|
// Performance configuration
|
|
CACHE_TTL: parseSecondsToMs(process.env.CACHE_TTL || '60'), // 60 seconds
|
|
AUTO_VOTE_DEBOUNCE: parseSecondsToMs(process.env.AUTO_VOTE_DEBOUNCE || '60'), // 60 seconds
|
|
|
|
// Security configuration
|
|
RATE_LIMIT_WINDOW: parseSecondsToMs(process.env.RATE_LIMIT_WINDOW || '60'), // 1 minute = 60 seconds
|
|
RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'),
|
|
RATE_LIMIT_MAX_PER_DOMAIN: parseInt(process.env.RATE_LIMIT_MAX_PER_DOMAIN || '20'),
|
|
REQUEST_TIMEOUT: parseSecondsToMs(process.env.REQUEST_TIMEOUT || '30'), // 30 seconds
|
|
|
|
// Logging
|
|
DEBUG: process.env.DEBUG === 'true',
|
|
|
|
// Domain validation
|
|
MAX_DOMAIN_LENGTH: 253,
|
|
MAX_DOMAIN_CACHE_SIZE: parseInt(process.env.MAX_DOMAIN_CACHE_SIZE || '5000', 10),
|
|
MAX_METRIC_MAP_SIZE: parseInt(process.env.MAX_METRIC_MAP_SIZE || '1000', 10),
|
|
DOMAIN_REGEX: /^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?)*$/i
|
|
};
|
|
|
|
// Validate configuration
|
|
function validateConfig() {
|
|
if (config.PROXY_PORT < 1 || config.PROXY_PORT > 65535) {
|
|
throw new Error(`Invalid PROXY_PORT: ${config.PROXY_PORT}`);
|
|
}
|
|
if (config.HOLESAIL_TIMEOUT < 0) {
|
|
throw new Error(`Invalid HOLESAIL_TIMEOUT: ${config.HOLESAIL_TIMEOUT}`);
|
|
}
|
|
if (config.MAX_HOLESAIL_CLIENTS < 1) {
|
|
throw new Error(`Invalid MAX_HOLESAIL_CLIENTS: ${config.MAX_HOLESAIL_CLIENTS}`);
|
|
}
|
|
}
|
|
|
|
validateConfig();
|
|
|
|
// ============================================================================
|
|
// Enhanced Logging with Request IDs
|
|
// ============================================================================
|
|
let requestIdCounter = 0;
|
|
function generateRequestId() {
|
|
return `req-${Date.now()}-${++requestIdCounter}`;
|
|
}
|
|
|
|
function logWithContext(level, category, message, context = {}) {
|
|
const timestamp = new Date().toISOString();
|
|
const contextStr = Object.keys(context).length > 0
|
|
? ` [${Object.entries(context).map(([k, v]) => `${k}=${v}`).join(', ')}]`
|
|
: '';
|
|
const logMessage = `[${timestamp}] [${level}] [${category}]${contextStr} ${message}`;
|
|
|
|
if (level === 'ERROR') {
|
|
console.error(logMessage);
|
|
} else if (level === 'WARN') {
|
|
console.warn(logMessage);
|
|
} else if (level === 'DEBUG' && config.DEBUG) {
|
|
console.debug(logMessage);
|
|
} else if (level === 'INFO') {
|
|
console.log(logMessage);
|
|
}
|
|
}
|
|
|
|
const logInfo = (category, message, context) => logWithContext('INFO', category, message, context);
|
|
const logError = (category, message, context) => logWithContext('ERROR', category, message, context);
|
|
const logWarn = (category, message, context) => logWithContext('WARN', category, message, context);
|
|
const logDebug = (category, message, context) => logWithContext('DEBUG', category, message, context);
|
|
|
|
// ============================================================================
|
|
// Metrics Collection
|
|
// ============================================================================
|
|
const metrics = {
|
|
requests: {
|
|
total: 0,
|
|
success: 0,
|
|
errors: 0,
|
|
notFound: 0,
|
|
timeouts: 0
|
|
},
|
|
activeConnections: 0,
|
|
holesailClients: 0,
|
|
responseTimes: [],
|
|
errorsByType: new Map(),
|
|
requestsByDomain: new Map(),
|
|
lastReset: Date.now()
|
|
};
|
|
|
|
function recordMetric(type, value = 1) {
|
|
if (type === 'request') {
|
|
metrics.requests.total += value;
|
|
} else if (type === 'success') {
|
|
metrics.requests.success += value;
|
|
} else if (type === 'error') {
|
|
metrics.requests.errors += value;
|
|
} else if (type === 'notFound') {
|
|
metrics.requests.notFound += value;
|
|
} else if (type === 'timeout') {
|
|
metrics.requests.timeouts += value;
|
|
} else if (type === 'responseTime') {
|
|
metrics.responseTimes.push(value);
|
|
// Keep only last 1000 response times
|
|
if (metrics.responseTimes.length > 1000) {
|
|
metrics.responseTimes.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
function evictOldestMapEntry(map, maxSize) {
|
|
if (map.size <= maxSize) return;
|
|
const firstKey = map.keys().next().value;
|
|
if (firstKey !== undefined) {
|
|
map.delete(firstKey);
|
|
}
|
|
}
|
|
|
|
function recordError(type, message) {
|
|
const count = metrics.errorsByType.get(type) || 0;
|
|
metrics.errorsByType.set(type, count + 1);
|
|
evictOldestMapEntry(metrics.errorsByType, config.MAX_METRIC_MAP_SIZE);
|
|
recordMetric('error');
|
|
}
|
|
|
|
function recordDomainRequest(domain) {
|
|
const count = metrics.requestsByDomain.get(domain) || 0;
|
|
metrics.requestsByDomain.set(domain, count + 1);
|
|
evictOldestMapEntry(metrics.requestsByDomain, config.MAX_METRIC_MAP_SIZE);
|
|
}
|
|
|
|
function getMetrics() {
|
|
const avgResponseTime = metrics.responseTimes.length > 0
|
|
? metrics.responseTimes.reduce((a, b) => a + b, 0) / metrics.responseTimes.length
|
|
: 0;
|
|
|
|
return {
|
|
...metrics,
|
|
avgResponseTime: Math.round(avgResponseTime),
|
|
topDomains: Array.from(metrics.requestsByDomain.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([domain, count]) => ({ domain, count })),
|
|
errorTypes: Array.from(metrics.errorsByType.entries())
|
|
.map(([type, count]) => ({ type, count }))
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rate Limiting
|
|
// ============================================================================
|
|
const rateLimitStore = {
|
|
byIP: new Map(),
|
|
byDomain: new Map()
|
|
};
|
|
|
|
function checkRateLimit(ip, domain) {
|
|
const now = Date.now();
|
|
|
|
// Per-IP rate limiting
|
|
const ipKey = ip || 'unknown';
|
|
let ipLimits = rateLimitStore.byIP.get(ipKey);
|
|
if (!ipLimits) {
|
|
ipLimits = { count: 0, resetTime: now + config.RATE_LIMIT_WINDOW };
|
|
rateLimitStore.byIP.set(ipKey, ipLimits);
|
|
}
|
|
|
|
if (now > ipLimits.resetTime) {
|
|
ipLimits.count = 0;
|
|
ipLimits.resetTime = now + config.RATE_LIMIT_WINDOW;
|
|
}
|
|
|
|
if (ipLimits.count >= config.RATE_LIMIT_MAX_REQUESTS) {
|
|
return { allowed: false, reason: 'IP rate limit exceeded' };
|
|
}
|
|
|
|
ipLimits.count++;
|
|
|
|
// Per-domain rate limiting
|
|
if (domain) {
|
|
let domainLimits = rateLimitStore.byDomain.get(domain);
|
|
if (!domainLimits) {
|
|
domainLimits = { count: 0, resetTime: now + config.RATE_LIMIT_WINDOW };
|
|
rateLimitStore.byDomain.set(domain, domainLimits);
|
|
}
|
|
|
|
if (now > domainLimits.resetTime) {
|
|
domainLimits.count = 0;
|
|
domainLimits.resetTime = now + config.RATE_LIMIT_WINDOW;
|
|
}
|
|
|
|
if (domainLimits.count >= config.RATE_LIMIT_MAX_PER_DOMAIN) {
|
|
return { allowed: false, reason: 'Domain rate limit exceeded' };
|
|
}
|
|
|
|
domainLimits.count++;
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
// Cleanup old rate limit entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, limits] of rateLimitStore.byIP.entries()) {
|
|
if (now > limits.resetTime + config.RATE_LIMIT_WINDOW) {
|
|
rateLimitStore.byIP.delete(key);
|
|
}
|
|
}
|
|
for (const [key, limits] of rateLimitStore.byDomain.entries()) {
|
|
if (now > limits.resetTime + config.RATE_LIMIT_WINDOW) {
|
|
rateLimitStore.byDomain.delete(key);
|
|
}
|
|
}
|
|
}, config.RATE_LIMIT_WINDOW * 2);
|
|
|
|
// ============================================================================
|
|
// Input Validation
|
|
// ============================================================================
|
|
function validateDomain(domain) {
|
|
return validateDomainDetailed(domain, { maxLength: config.MAX_DOMAIN_LENGTH });
|
|
}
|
|
|
|
// ============================================================================
|
|
// Domain-to-Hash Caching
|
|
// ============================================================================
|
|
const domainCache = new Map();
|
|
|
|
function getCachedHash(domain) {
|
|
const cached = domainCache.get(domain);
|
|
if (cached && Date.now() < cached.expires) {
|
|
return cached.hash;
|
|
}
|
|
if (cached) {
|
|
domainCache.delete(domain);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setCachedHash(domain, hash) {
|
|
if (domainCache.size >= config.MAX_DOMAIN_CACHE_SIZE) {
|
|
const firstKey = domainCache.keys().next().value;
|
|
if (firstKey !== undefined) {
|
|
domainCache.delete(firstKey);
|
|
}
|
|
}
|
|
domainCache.set(domain, {
|
|
hash,
|
|
expires: Date.now() + config.CACHE_TTL
|
|
});
|
|
}
|
|
|
|
function invalidateDomainCache(domain = null) {
|
|
if (domain) {
|
|
domainCache.delete(domain);
|
|
} else {
|
|
domainCache.clear();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// State Management
|
|
// ============================================================================
|
|
const state = {
|
|
connectedPeers: new Set(),
|
|
peerChannels: new Map(),
|
|
dnsPass: null,
|
|
holesails: new Map(),
|
|
starting: new Map(),
|
|
tlsServers: new Map(),
|
|
httpServers: new Map(),
|
|
swarm: null,
|
|
topic: null,
|
|
store: null,
|
|
core: null,
|
|
keypair: null // Persistent keypair for consistent peer identity
|
|
};
|
|
|
|
// ============================================================================
|
|
// Stubbed TLS Proxy (as per original design)
|
|
// ============================================================================
|
|
const ca = {
|
|
createTlsProxy: (domain, ip, port, log) => {
|
|
const server = net.createServer(); // Placeholder
|
|
log('Holesail', `Stubbed TLS proxy created for ${domain} on ${ip}:${port}`, { domain, ip, port });
|
|
return server;
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Port Management
|
|
// ============================================================================
|
|
async function checkPortAvailability(ip, port) {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once('error', (err) => {
|
|
server.close();
|
|
if (err.code === 'EADDRINUSE') reject(new Error(`Port ${port} on ${ip} is in use`));
|
|
else reject(err);
|
|
});
|
|
server.once('listening', () => {
|
|
server.close();
|
|
resolve();
|
|
});
|
|
server.listen(port, ip);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Holesail Client Management (Improved with better locking)
|
|
// ============================================================================
|
|
async function startHolesailClient(domain, hash, ip, persistent = false) {
|
|
return new Promise((resolve, reject) => {
|
|
// Check connection limit
|
|
if (state.holesails.size >= config.MAX_HOLESAIL_CLIENTS) {
|
|
reject(new Error(`Maximum Holesail clients limit reached: ${config.MAX_HOLESAIL_CLIENTS}`));
|
|
return;
|
|
}
|
|
|
|
const server = net.createServer();
|
|
server.listen(0, ip, async () => {
|
|
const port = server.address().port;
|
|
server.close();
|
|
const key = `${domain}:${port}`;
|
|
|
|
// Check if already exists
|
|
if (state.holesails.has(key)) {
|
|
logDebug('Holesail', `Holesail client already exists for ${key}`, { domain, port });
|
|
resolve(port);
|
|
return;
|
|
}
|
|
|
|
// Get or create start promise (improved locking)
|
|
let startPromise = state.starting.get(key);
|
|
if (!startPromise) {
|
|
startPromise = (async () => {
|
|
try {
|
|
logInfo('Holesail', `Starting Holesail client for domain: ${domain}`, { domain, hash, ip, port });
|
|
|
|
const secure = isSecureHolesailKey(hash);
|
|
logInfo('Holesail', `Creating Holesail client for ${domain} with secure=${secure} (key starts with: ${hash.substring(0, 10)}...)`);
|
|
|
|
const holesail = new Holesail({
|
|
client: true,
|
|
key: hash,
|
|
port: port,
|
|
host: ip,
|
|
secure: secure,
|
|
log: false
|
|
});
|
|
|
|
await holesail.ready();
|
|
state.holesails.set(key, holesail);
|
|
metrics.holesailClients = state.holesails.size;
|
|
logInfo('Holesail', `Holesail client for ${key} connected`, { domain, ip, port });
|
|
|
|
if (!config.DISABLE_PROXY_SERVER) {
|
|
const tlsServer = ca.createTlsProxy(domain, ip, port, logDebug);
|
|
state.tlsServers.set(key, tlsServer);
|
|
|
|
const httpPort = config.HTTP_PORT;
|
|
if (port === httpPort) {
|
|
try {
|
|
await checkPortAvailability(ip, httpPort);
|
|
const httpServer = http.createServer((req, res) => {
|
|
const host = req.headers.host || domain;
|
|
res.writeHead(301, { 'Location': `https://${host}${req.url}` });
|
|
res.end();
|
|
});
|
|
|
|
// Add error handler for HTTP server
|
|
httpServer.on('error', (err) => {
|
|
logError('Holesail', `HTTP redirect server error for ${key}`, { domain, error: err.message });
|
|
});
|
|
|
|
httpServer.listen(httpPort, ip, () => {
|
|
logInfo('Holesail', `HTTP redirect server for ${key} listening`, { domain, ip, port: httpPort });
|
|
});
|
|
|
|
state.httpServers.set(key, httpServer);
|
|
} catch (err) {
|
|
logError('Holesail', `Failed to start HTTP redirect server for ${key}`, { domain, error: err.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!persistent) {
|
|
setTimeout(async () => {
|
|
logInfo('Holesail', `Closing Holesail client for ${key}`, { domain });
|
|
try {
|
|
await holesail.close();
|
|
state.holesails.delete(key);
|
|
metrics.holesailClients = state.holesails.size;
|
|
|
|
const tlsServer = state.tlsServers.get(key);
|
|
if (tlsServer) {
|
|
tlsServer.close();
|
|
state.tlsServers.delete(key);
|
|
}
|
|
|
|
const httpServer = state.httpServers.get(key);
|
|
if (httpServer) {
|
|
httpServer.close();
|
|
state.httpServers.delete(key);
|
|
}
|
|
} catch (err) {
|
|
logError('Holesail', `Error closing Holesail client for ${key}`, { domain, error: err.message });
|
|
}
|
|
}, config.HOLESAIL_TIMEOUT);
|
|
}
|
|
|
|
resolve(port);
|
|
} catch (err) {
|
|
logError('Holesail', `Error connecting Holesail client for ${key}`, { domain, error: err.message });
|
|
reject(err);
|
|
} finally {
|
|
state.starting.delete(key);
|
|
}
|
|
})();
|
|
state.starting.set(key, startPromise);
|
|
}
|
|
|
|
// Wait for the promise
|
|
startPromise.then(resolve).catch(reject);
|
|
});
|
|
|
|
server.on('error', (err) => {
|
|
server.close();
|
|
logError('Holesail', `Failed to bind port for ${domain}`, { domain, ip, error: err.message });
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Functions (Optimized)
|
|
// ============================================================================
|
|
let cachedEntries = null;
|
|
let cachedEntriesTime = 0;
|
|
const ENTRIES_CACHE_TTL = secondsToMs(5); // 5 seconds
|
|
|
|
async function getAllEntries(pass = state.dnsPass, useCache = true) {
|
|
const now = Date.now();
|
|
if (useCache && cachedEntries && (now - cachedEntriesTime) < ENTRIES_CACHE_TTL) {
|
|
return cachedEntries;
|
|
}
|
|
|
|
const entries = [];
|
|
try {
|
|
const stream = pass.list();
|
|
for await (const entry of stream) {
|
|
entries.push({
|
|
key: entry.key.toString('utf8'),
|
|
value: entry.value.toString('utf8')
|
|
});
|
|
}
|
|
cachedEntries = entries;
|
|
cachedEntriesTime = now;
|
|
} catch (err) {
|
|
logError('Core', `Error getting entries`, { error: err.message });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function getHashForDomain(domain) {
|
|
const cached = getCachedHash(domain);
|
|
if (cached) {
|
|
logDebug('Core', `Cache hit for domain: ${domain}`, { domain });
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const { getHashForDomain: coreGetHashForDomain } = require('../includes/core/core');
|
|
const hash = await coreGetHashForDomain(domain);
|
|
if (hash) {
|
|
setCachedHash(domain, hash);
|
|
return hash;
|
|
}
|
|
} catch (err) {
|
|
logDebug('Core', `Core consensus not available: ${err.message}`, { domain });
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function getDomainSSLStatus(domain) {
|
|
try {
|
|
const { getDomainSSLStatus: coreGetDomainSSLStatus } = require('../includes/core/core');
|
|
return await coreGetDomainSSLStatus(domain);
|
|
} catch (err) {
|
|
logDebug('Core', `Core SSL status not available, defaulting to false: ${err.message}`, { domain });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Auto-Voting (Optimized)
|
|
// ============================================================================
|
|
let lastVoteTime = 0;
|
|
async function doAutoVotes() {
|
|
const now = Date.now();
|
|
if (now - lastVoteTime < config.AUTO_VOTE_DEBOUNCE) return;
|
|
lastVoteTime = now;
|
|
|
|
const pass = state.dnsPass;
|
|
if (!pass) return;
|
|
|
|
logInfo('Core', 'Performing auto-votes check');
|
|
|
|
// Use cached entries to avoid multiple getAllEntries calls
|
|
const allEntries = await getAllEntries(pass, true);
|
|
const domains = new Set();
|
|
|
|
for (const entry of allEntries) {
|
|
if (entry.key.startsWith('claim:')) {
|
|
const parts = entry.key.split(':');
|
|
if (parts.length === 3) domains.add(parts[1]);
|
|
}
|
|
}
|
|
|
|
for (const domain of domains) {
|
|
await autoVoteForDomain(domain, allEntries);
|
|
}
|
|
}
|
|
|
|
async function autoVoteForDomain(domain, allEntries) {
|
|
// Try to use core auto-voting logic if available
|
|
try {
|
|
const { autoVoteForDomain: coreAutoVoteForDomain } = require('../includes/core/core');
|
|
await coreAutoVoteForDomain(domain, allEntries);
|
|
return;
|
|
} catch (err) {
|
|
logDebug('Core', `Core auto-voting not available, using fallback: ${err.message}`, { domain });
|
|
}
|
|
|
|
// Fallback to simplified logic if core not available
|
|
const pass = state.dnsPass;
|
|
if (!pass) return;
|
|
|
|
const claims = {};
|
|
const myVotes = new Map();
|
|
const localWriter = getPersistentPublicKey();
|
|
if (!localWriter) {
|
|
logWarn('Core', 'Cannot auto-vote: persistent public key not available', { domain });
|
|
return;
|
|
}
|
|
|
|
for (const entry of allEntries) {
|
|
if (entry.key.startsWith(`claim:${domain}:`)) {
|
|
const claimant = entry.key.slice(`claim:${domain}:`.length);
|
|
// Parse claim value (may be JSON with timestamp or legacy format)
|
|
try {
|
|
const parsed = JSON.parse(entry.value);
|
|
claims[claimant] = parsed.hash || entry.value;
|
|
} catch (e) {
|
|
claims[claimant] = entry.value; // Legacy format
|
|
}
|
|
}
|
|
if (entry.key.startsWith(`vote:${domain}:`)) {
|
|
const parts = entry.key.split(':');
|
|
if (parts.length === 4 && parts[3] === localWriter) {
|
|
myVotes.set(parts[2], entry.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
const claimsLength = Object.keys(claims).length;
|
|
if (claimsLength === 0) return;
|
|
|
|
const localHasClaim = claims.hasOwnProperty(localWriter);
|
|
let intendedClaimant;
|
|
if (localHasClaim) {
|
|
intendedClaimant = localWriter;
|
|
} else if (claimsLength === 1) {
|
|
intendedClaimant = Object.keys(claims)[0];
|
|
} else {
|
|
intendedClaimant = Object.keys(claims).sort((a, b) => a.localeCompare(b))[0];
|
|
}
|
|
|
|
// Validate intended claimant exists
|
|
if (!claims.hasOwnProperty(intendedClaimant)) {
|
|
logWarn('Core', `Cannot vote for invalid claimant: ${intendedClaimant}`, { domain });
|
|
return;
|
|
}
|
|
|
|
for (const [votedClaimant, voteKey] of myVotes) {
|
|
if (votedClaimant !== intendedClaimant) {
|
|
await pass.remove(voteKey);
|
|
logInfo('Core', `Removed vote for ${domain}`, { domain, claimant: votedClaimant });
|
|
}
|
|
}
|
|
|
|
if (!myVotes.has(intendedClaimant)) {
|
|
const voteKey = `vote:${domain}:${intendedClaimant}:${localWriter}`;
|
|
await pass.add(voteKey, claims[intendedClaimant]);
|
|
logInfo('Core', `Auto-voted for ${domain}`, { domain, claimant: intendedClaimant });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// P2NS Setup (Fixed scope issues)
|
|
// ============================================================================
|
|
async function setupP2NS() {
|
|
const isMaster = false;
|
|
const topic = crypto.createHash('sha256').update(config.TOPIC_SEED).digest();
|
|
state.topic = topic;
|
|
|
|
logDebug('Main', `Generated topic`, { topic: topic.toString('hex') });
|
|
|
|
// Ensure cache directory exists
|
|
await setupCache();
|
|
|
|
// Load persistent keypair early and store in state
|
|
const keypair = await loadOrCreateKeypair();
|
|
state.keypair = keypair;
|
|
logInfo('Main', 'Persistent keypair loaded');
|
|
|
|
const store = new Corestore(config.STORAGE_DIR);
|
|
state.store = store;
|
|
logInfo('Main', `Corestore initialized`, { storageDir: config.STORAGE_DIR });
|
|
|
|
// Use the persistent keypair from state
|
|
// Configure Hyperswarm for better local network peer discovery
|
|
const swarmOptions = {
|
|
keyPair: state.keypair,
|
|
maxPeers: parseInt(process.env.MAX_PEERS || '24', 10)
|
|
};
|
|
|
|
const swarm = new Hyperswarm(swarmOptions);
|
|
state.swarm = swarm;
|
|
logInfo('Main', 'Hyperswarm instance created with persisted keypair');
|
|
logInfo('Main', `Hyperswarm configured: maxPeers=${swarmOptions.maxPeers}`);
|
|
|
|
let dnsPass = null;
|
|
let core = null;
|
|
let processingInvite = false;
|
|
let currentInvitePromise = null;
|
|
let currentPairOperation = null;
|
|
const failedInvitePeers = new Set();
|
|
const pendingInviteAcks = new Map();
|
|
const pendingInviteRequestHandlers = new Set();
|
|
const peerChannels = new Map();
|
|
|
|
const channelManager = require('../includes/plugins/channel-manager');
|
|
const coreRpc = require('../includes/core/core-rpc');
|
|
const { createCoreSwarmHandlers } = require('../includes/core/core-swarm-handlers');
|
|
|
|
function isProcessingInvite() {
|
|
return processingInvite;
|
|
}
|
|
|
|
async function acquireInviteLock() {
|
|
if (processingInvite && currentInvitePromise) {
|
|
await currentInvitePromise;
|
|
}
|
|
processingInvite = true;
|
|
}
|
|
|
|
function releaseInviteLock() {
|
|
processingInvite = false;
|
|
currentInvitePromise = null;
|
|
}
|
|
|
|
function withTimeout(promise, timeoutMs, operationName) {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
})
|
|
]);
|
|
}
|
|
|
|
function setupDomainsWatcher() {
|
|
// Proxy does not run full domain watcher
|
|
}
|
|
|
|
async function listDomains() {
|
|
return [];
|
|
}
|
|
|
|
const proxySwarmHandlers = createCoreSwarmHandlers({
|
|
Autopass,
|
|
store,
|
|
core: { get writable() { return core?.writable; }, update: () => core?.update() },
|
|
state,
|
|
isMaster,
|
|
getDnsPass: () => dnsPass,
|
|
setDnsPass: (p) => {
|
|
dnsPass = p;
|
|
state.dnsPass = p;
|
|
core = p.base;
|
|
state.core = core;
|
|
},
|
|
connectedPeers: state.connectedPeers,
|
|
peerChannels,
|
|
failedInvitePeers,
|
|
pendingInviteAcks,
|
|
pendingInviteRequestHandlers,
|
|
isProcessingInvite,
|
|
acquireInviteLock,
|
|
releaseInviteLock,
|
|
withTimeout,
|
|
getCurrentInvitePromise: () => currentInvitePromise,
|
|
setCurrentInvitePromise: (p) => { currentInvitePromise = p; },
|
|
getCurrentPairOperation: () => currentPairOperation,
|
|
setCurrentPairOperation: (p) => { currentPairOperation = p; },
|
|
setupDomainsWatcher,
|
|
listDomains,
|
|
doAutoVotes,
|
|
setupListeners: () => setupListeners(core),
|
|
coreRpc,
|
|
channelManager,
|
|
CORE_DOMAIN: coreRpc.CORE_DOMAIN
|
|
});
|
|
|
|
coreRpc.registerCoreRpcHandlers({
|
|
onInviteRequest: proxySwarmHandlers.onInviteRequest,
|
|
onInviteDeliver: proxySwarmHandlers.onInviteDeliver,
|
|
onInviteAck: proxySwarmHandlers.onInviteAck,
|
|
onInviteUnavailable: proxySwarmHandlers.onInviteUnavailable,
|
|
onInviteQueued: proxySwarmHandlers.onInviteQueued,
|
|
onInviteRelayRequest: proxySwarmHandlers.onInviteRelayRequest,
|
|
onInviteRelayResponse: proxySwarmHandlers.onInviteRelayResponse,
|
|
onCoreStatus: proxySwarmHandlers.onCoreStatus,
|
|
onConsensusRemoveDomain: proxySwarmHandlers.onConsensusRemoveDomain,
|
|
onConsensusRecalculate: proxySwarmHandlers.onConsensusRecalculate,
|
|
onConsensusRemoveConflictClaim: proxySwarmHandlers.onConsensusRemoveConflictClaim
|
|
});
|
|
|
|
channelManager.registerPluginProtocol(coreRpc.CORE_DOMAIN, 'request', {
|
|
methods: {},
|
|
autoReconnect: true
|
|
});
|
|
|
|
await swarm.flush();
|
|
logDebug('Main', 'Swarm flush completed');
|
|
|
|
swarm.join(topic, { server: true, client: true });
|
|
logInfo('Main', 'Joined Hyperswarm topic (server: true, client: true)');
|
|
|
|
// Set up connection handler with timeout handling for better local network peer support
|
|
swarm.on('connection', async (conn, info) => {
|
|
// Set socket timeout to prevent hanging connections (helps with local network peers)
|
|
if (conn && conn.socket) {
|
|
const socketTimeout = parseInt(process.env.SWARM_CONNECTION_TIMEOUT || '30000', 10); // 30 seconds default
|
|
conn.socket.setTimeout(socketTimeout);
|
|
conn.socket.on('timeout', () => {
|
|
const peerId = conn.remotePublicKey ? conn.remotePublicKey.toString('hex').slice(0, 16) : 'unknown';
|
|
logWarn('Swarm', `Connection timeout for peer ${peerId}...`);
|
|
if (!conn.destroyed) {
|
|
conn.destroy();
|
|
}
|
|
});
|
|
}
|
|
// Validate remotePublicKey exists before using it
|
|
if (!conn.remotePublicKey) {
|
|
logWarn('Swarm', 'Connection established without remotePublicKey, closing connection');
|
|
conn.destroy();
|
|
return;
|
|
}
|
|
const peerId = conn.remotePublicKey.toString('hex');
|
|
logInfo('Swarm', `New connection established`, { peerId });
|
|
|
|
if (state.connectedPeers.has(peerId)) {
|
|
logDebug('Swarm', `Peer already connected`, { peerId });
|
|
return;
|
|
}
|
|
|
|
state.connectedPeers.add(peerId);
|
|
// Do not replicate the full Corestore on the p2ns topic — Autopass uses its own pairing/replication.
|
|
logDebug('Swarm', 'Skipping p2ns-topic Corestore replication (Autopass handles dnsPass sync)');
|
|
|
|
const mux = Protomux.from(conn);
|
|
peerChannels.set(peerId, { conn, mux });
|
|
channelManager.registerPeerConnection(peerId, conn, mux);
|
|
channelManager.createPluginChannelsForPeer(peerId, conn, mux);
|
|
|
|
if (!isMaster) {
|
|
const maxRetries = 10;
|
|
let retryCount = 0;
|
|
const retryInterval = 5000;
|
|
const sendInviteRequest = async () => {
|
|
if (dnsPass || retryCount >= maxRetries || conn.destroyed) return;
|
|
retryCount++;
|
|
try {
|
|
const res = await coreRpc.inviteRequest(peerId, 5000);
|
|
if (res?.status === coreRpc.INVITE_STATUS.UNAVAILABLE) {
|
|
failedInvitePeers.add(peerId);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
logDebug('Swarm', `RPC invite.request failed: ${err.message}`);
|
|
}
|
|
setTimeout(sendInviteRequest, retryInterval);
|
|
};
|
|
sendInviteRequest();
|
|
}
|
|
|
|
// Fixed: Use peerId instead of undefined 'key'
|
|
conn.on('close', () => {
|
|
state.connectedPeers.delete(peerId);
|
|
peerChannels.delete(peerId);
|
|
channelManager.unregisterPeerConnection(peerId);
|
|
channelManager.handlePeerDisconnect(peerId);
|
|
logDebug('Swarm', `Connection closed`, { peerId });
|
|
});
|
|
|
|
conn.on('error', (err) => {
|
|
const errMsg = err.message || '';
|
|
const errCode = err.code || '';
|
|
|
|
// Handle common benign network errors - these are normal in P2P networks
|
|
const benignErrors = [
|
|
'connection reset by peer',
|
|
'ECONNRESET',
|
|
'EPIPE',
|
|
'broken pipe',
|
|
'socket hang up',
|
|
'ECONNABORTED',
|
|
'ETIMEDOUT',
|
|
'ENOTFOUND',
|
|
'ECONNREFUSED'
|
|
];
|
|
|
|
const isBenignError = benignErrors.some(benign =>
|
|
errMsg.toLowerCase().includes(benign.toLowerCase()) ||
|
|
errCode === benign
|
|
);
|
|
|
|
if (isBenignError) {
|
|
// Log as debug instead of error - these are normal network events
|
|
logDebug('Swarm', `Connection reset/closed by peer`, { peerId, error: errMsg || errCode });
|
|
} else {
|
|
// For unexpected errors, log as error
|
|
logError('Swarm', `Connection error with peer`, { peerId, error: err.message });
|
|
}
|
|
|
|
state.connectedPeers.delete(peerId);
|
|
state.peerChannels.delete(peerId);
|
|
// Clean up channels
|
|
const channels = state.peerChannels.get(peerId);
|
|
if (channels) {
|
|
try {
|
|
if (channels.inviteChannel && !channels.inviteChannel.destroyed) {
|
|
channels.inviteChannel.close();
|
|
}
|
|
if (channels.requestChannel && !channels.requestChannel.destroyed) {
|
|
channels.requestChannel.close();
|
|
}
|
|
} catch (cleanupErr) {
|
|
logDebug('Swarm', `Error cleaning up channels`, { peerId, error: cleanupErr.message });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
function setupListeners(core) {
|
|
state.dnsPass.on('update', () => {
|
|
// Invalidate cache on DNS updates
|
|
invalidateDomainCache();
|
|
cachedEntries = null;
|
|
doAutoVotes();
|
|
});
|
|
|
|
core.on('peer-add', (peer) => {
|
|
core.update().catch(err => {
|
|
logError('Swarm', `Error updating core after peer-add`, { error: err.message });
|
|
});
|
|
});
|
|
|
|
core.on('append', () => {
|
|
// Invalidate cache on append
|
|
invalidateDomainCache();
|
|
cachedEntries = null;
|
|
});
|
|
}
|
|
|
|
// Wait for P2NS connection with timeout
|
|
const maxWaitTime = secondsToMs(60); // 60 seconds
|
|
const startTime = Date.now();
|
|
while (!state.dnsPass) {
|
|
if (Date.now() - startTime > maxWaitTime) {
|
|
throw new Error('P2NS setup timeout: No DNS pass received within 60 seconds');
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
|
|
logInfo('Main', 'P2NS setup complete as joiner');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Express Proxy Server
|
|
// ============================================================================
|
|
async function main() {
|
|
try {
|
|
await setupP2NS();
|
|
} catch (err) {
|
|
logError('Main', `P2NS setup failed`, { error: err.message });
|
|
throw err;
|
|
}
|
|
|
|
const app = express();
|
|
const proxy = httpProxy.createProxyServer({
|
|
changeOrigin: true,
|
|
timeout: config.REQUEST_TIMEOUT
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
|
|
// Log Express and path-to-regexp versions for debugging
|
|
const expressVersion = require('express/package.json').version;
|
|
let pathToRegexpVersion = 'unknown';
|
|
try {
|
|
const pathToRegexpPackageJson = JSON.parse(fs.readFileSync(path.join(require.resolve('path-to-regexp'), '../package.json')));
|
|
pathToRegexpVersion = pathToRegexpPackageJson.version;
|
|
} catch (err) {
|
|
logWarn('Proxy', `Failed to retrieve path-to-regexp version`, { error: err.message });
|
|
}
|
|
logInfo('Proxy', `Using Express v${expressVersion}, path-to-regexp v${pathToRegexpVersion}`);
|
|
|
|
// Handle proxy errors
|
|
proxy.on('error', (err, req, res) => {
|
|
const requestId = req.requestId || 'unknown';
|
|
logError('Proxy', `Proxy error`, { requestId, url: req.url, error: err.message });
|
|
recordError('proxy_error', err.message);
|
|
|
|
if (res && !res.headersSent) {
|
|
res.status(502).send('Bad Gateway: Proxy error');
|
|
}
|
|
});
|
|
|
|
// Health check endpoint
|
|
app.get('/health', (req, res) => {
|
|
const health = {
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
p2ns: {
|
|
connected: !!state.dnsPass,
|
|
peers: state.connectedPeers.size
|
|
},
|
|
holesail: {
|
|
active: state.holesails.size,
|
|
max: config.MAX_HOLESAIL_CLIENTS
|
|
},
|
|
metrics: getMetrics()
|
|
};
|
|
|
|
if (!state.dnsPass) {
|
|
health.status = 'degraded';
|
|
health.p2ns.error = 'DNS pass not initialized';
|
|
}
|
|
|
|
res.json(health);
|
|
});
|
|
|
|
// Metrics endpoint
|
|
app.get('/metrics', (req, res) => {
|
|
res.json(getMetrics());
|
|
});
|
|
|
|
// Rate limiting middleware
|
|
const rateLimitMiddleware = (req, res, next) => {
|
|
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
|
const domain = req.params?.domain;
|
|
const limitCheck = checkRateLimit(ip, domain);
|
|
|
|
if (!limitCheck.allowed) {
|
|
logWarn('Proxy', `Rate limit exceeded`, { ip, domain, reason: limitCheck.reason });
|
|
recordError('rate_limit', limitCheck.reason);
|
|
return res.status(429).json({ error: 'Too Many Requests', reason: limitCheck.reason });
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
// Request ID middleware
|
|
app.use((req, res, next) => {
|
|
req.requestId = generateRequestId();
|
|
req.startTime = Date.now();
|
|
next();
|
|
});
|
|
|
|
// Handle HTTP requests with error handling
|
|
try {
|
|
// Handle requests with subpaths using wildcard syntax: /:domain/*subpath
|
|
logInfo('Proxy', 'Registering route: /:domain/*subpath');
|
|
app.all('/:domain/*subpath', rateLimitMiddleware, async (req, res) => {
|
|
const requestId = req.requestId;
|
|
const startTime = req.startTime;
|
|
|
|
try {
|
|
let domain = req.params.domain;
|
|
if (domain) {
|
|
domain = domain.toLowerCase();
|
|
}
|
|
|
|
// Validate domain
|
|
const validation = validateDomain(domain);
|
|
if (!validation.valid) {
|
|
logWarn('Proxy', `Invalid domain`, { requestId, domain, error: validation.error });
|
|
recordError('invalid_domain', validation.error);
|
|
recordMetric('notFound');
|
|
return res.status(400).json({ error: 'Invalid domain', details: validation.error });
|
|
}
|
|
|
|
const subpath = req.params.subpath ? req.params.subpath.join('/') : '';
|
|
logDebug('Proxy', `Processing request`, { requestId, domain, subpath: subpath || '(none)' });
|
|
|
|
// Reconstruct the URL with query parameters
|
|
const queryString = req.originalUrl.split('?')[1];
|
|
req.url = '/' + subpath + (queryString ? '?' + queryString : '');
|
|
|
|
// Set request timeout
|
|
const timeout = setTimeout(() => {
|
|
if (!res.headersSent) {
|
|
logWarn('Proxy', `Request timeout`, { requestId, domain });
|
|
recordError('timeout', 'Request timeout');
|
|
recordMetric('timeout');
|
|
res.status(504).json({ error: 'Gateway Timeout' });
|
|
}
|
|
}, config.REQUEST_TIMEOUT);
|
|
|
|
res.on('finish', () => {
|
|
clearTimeout(timeout);
|
|
const duration = Date.now() - startTime;
|
|
recordMetric('responseTime', duration);
|
|
recordMetric('request');
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
recordMetric('success');
|
|
} else if (res.statusCode === 404) {
|
|
recordMetric('notFound');
|
|
} else {
|
|
recordMetric('error');
|
|
}
|
|
recordDomainRequest(domain);
|
|
logInfo('Proxy', `Request completed`, { requestId, domain, status: res.statusCode, duration });
|
|
});
|
|
|
|
const hash = await getHashForDomain(domain);
|
|
if (!hash) {
|
|
logWarn('Proxy', `No hash found for domain`, { requestId, domain });
|
|
recordError('domain_not_found', domain);
|
|
recordMetric('notFound');
|
|
return res.status(404).json({ error: 'Domain not found or unable to resolve in P2NS' });
|
|
}
|
|
|
|
const ip = '127.0.0.1';
|
|
const port = await startHolesailClient(domain, hash, ip, true);
|
|
|
|
// Check if domain uses SSL/TLS
|
|
const useSSL = await getDomainSSLStatus(domain);
|
|
const protocol = useSSL ? 'https' : 'http';
|
|
proxy.web(req, res, { target: `${protocol}://${ip}:${port}` });
|
|
} catch (err) {
|
|
logError('Proxy', `Error processing request`, { requestId, domain: req.params.domain, error: err.message });
|
|
recordError('request_error', err.message);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle requests to domain root: /:domain
|
|
logInfo('Proxy', 'Registering route: /:domain');
|
|
app.all('/:domain', rateLimitMiddleware, async (req, res) => {
|
|
const requestId = req.requestId;
|
|
const startTime = req.startTime;
|
|
|
|
try {
|
|
let domain = req.params.domain;
|
|
if (domain) {
|
|
domain = domain.toLowerCase();
|
|
}
|
|
|
|
// Validate domain
|
|
const validation = validateDomain(domain);
|
|
if (!validation.valid) {
|
|
logWarn('Proxy', `Invalid domain`, { requestId, domain, error: validation.error });
|
|
recordError('invalid_domain', validation.error);
|
|
recordMetric('notFound');
|
|
return res.status(400).json({ error: 'Invalid domain', details: validation.error });
|
|
}
|
|
|
|
logDebug('Proxy', `Processing request`, { requestId, domain, subpath: '(none)' });
|
|
|
|
// Keep original URL with query parameters
|
|
const queryString = req.originalUrl.split('?')[1];
|
|
req.url = '/' + (queryString ? '?' + queryString : '');
|
|
|
|
// Set request timeout
|
|
const timeout = setTimeout(() => {
|
|
if (!res.headersSent) {
|
|
logWarn('Proxy', `Request timeout`, { requestId, domain });
|
|
recordError('timeout', 'Request timeout');
|
|
recordMetric('timeout');
|
|
res.status(504).json({ error: 'Gateway Timeout' });
|
|
}
|
|
}, config.REQUEST_TIMEOUT);
|
|
|
|
res.on('finish', () => {
|
|
clearTimeout(timeout);
|
|
const duration = Date.now() - startTime;
|
|
recordMetric('responseTime', duration);
|
|
recordMetric('request');
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
recordMetric('success');
|
|
} else if (res.statusCode === 404) {
|
|
recordMetric('notFound');
|
|
} else {
|
|
recordMetric('error');
|
|
}
|
|
recordDomainRequest(domain);
|
|
logInfo('Proxy', `Request completed`, { requestId, domain, status: res.statusCode, duration });
|
|
});
|
|
|
|
const hash = await getHashForDomain(domain);
|
|
if (!hash) {
|
|
logWarn('Proxy', `No hash found for domain`, { requestId, domain });
|
|
recordError('domain_not_found', domain);
|
|
recordMetric('notFound');
|
|
return res.status(404).json({ error: 'Domain not found or unable to resolve in P2NS' });
|
|
}
|
|
|
|
const ip = '127.0.0.1';
|
|
const port = await startHolesailClient(domain, hash, ip, true);
|
|
|
|
// Check if domain uses SSL/TLS
|
|
const useSSL = await getDomainSSLStatus(domain);
|
|
const protocol = useSSL ? 'https' : 'http';
|
|
proxy.web(req, res, { target: `${protocol}://${ip}:${port}` });
|
|
} catch (err) {
|
|
logError('Proxy', `Error processing request`, { requestId, domain: req.params.domain, error: err.message });
|
|
recordError('request_error', err.message);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
logError('Proxy', `Failed to register HTTP route`, { error: err.message });
|
|
throw err;
|
|
}
|
|
|
|
// Handle WebSocket upgrades with error handling
|
|
server.on('upgrade', async (req, socket, head) => {
|
|
const requestId = generateRequestId();
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const parsedUrl = url.parse(req.url);
|
|
const match = parsedUrl.pathname.match(/^\/([^\/]+)(.*)$/);
|
|
|
|
if (!match) {
|
|
logWarn('Proxy', `Invalid WebSocket URL`, { requestId, url: req.url });
|
|
recordError('invalid_websocket_url', req.url);
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
let domain = match[1].toLowerCase();
|
|
|
|
// Validate domain
|
|
const validation = validateDomain(domain);
|
|
if (!validation.valid) {
|
|
logWarn('Proxy', `Invalid domain for WebSocket`, { requestId, domain, error: validation.error });
|
|
recordError('invalid_domain', validation.error);
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const subpath = match[2] || '';
|
|
logDebug('Proxy', `Processing WebSocket upgrade`, { requestId, domain, subpath });
|
|
|
|
req.url = subpath + (parsedUrl.search || '');
|
|
|
|
const hash = await getHashForDomain(domain);
|
|
if (!hash) {
|
|
logWarn('Proxy', `No hash found for domain`, { requestId, domain });
|
|
recordError('domain_not_found', domain);
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const ip = '127.0.0.1';
|
|
const port = await startHolesailClient(domain, hash, ip, true);
|
|
|
|
// Check if domain uses SSL/TLS for WebSocket
|
|
const useSSL = await getDomainSSLStatus(domain);
|
|
const protocol = useSSL ? 'wss' : 'ws';
|
|
proxy.ws(req, socket, head, { target: `${protocol}://${ip}:${port}` });
|
|
|
|
// Track WebSocket connection
|
|
metrics.activeConnections++;
|
|
socket.on('close', () => {
|
|
metrics.activeConnections--;
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
logError('Proxy', `WebSocket error`, { requestId, domain, error: err.message });
|
|
recordError('websocket_error', err.message);
|
|
metrics.activeConnections--;
|
|
});
|
|
} catch (err) {
|
|
logError('Proxy', `Error processing WebSocket upgrade`, { requestId, error: err.message });
|
|
recordError('websocket_upgrade_error', err.message);
|
|
if (!socket.destroyed) {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
try {
|
|
server.listen(config.PROXY_PORT, config.PROXY_HOST, () => {
|
|
logInfo('Proxy', `Express proxy server listening`, { port: config.PROXY_PORT, host: config.PROXY_HOST });
|
|
});
|
|
|
|
server.on('error', (err) => {
|
|
logError('Proxy', `Server error`, { error: err.message });
|
|
recordError('server_error', err.message);
|
|
});
|
|
} catch (err) {
|
|
logError('Proxy', `Failed to start server`, { port: config.PROXY_PORT, error: err.message });
|
|
throw err;
|
|
}
|
|
|
|
// Cleanup (Fixed scope issues)
|
|
let isShuttingDown = false;
|
|
const cleanup = async () => {
|
|
if (isShuttingDown) return;
|
|
isShuttingDown = true;
|
|
logInfo('Main', 'Shutting down gracefully...');
|
|
|
|
const timeout = setTimeout(() => {
|
|
logError('Main', 'Shutdown timed out, forcing exit');
|
|
process.exit(1);
|
|
}, 10000);
|
|
|
|
// Close Holesail clients
|
|
for (const [key, holesail] of state.holesails) {
|
|
try {
|
|
await holesail.close();
|
|
logInfo('Main', `Closed Holesail client`, { key });
|
|
} catch (err) {
|
|
logError('Main', `Error closing Holesail client`, { key, error: err.message });
|
|
}
|
|
}
|
|
state.holesails.clear();
|
|
|
|
// Close TLS servers
|
|
for (const [key, tlsServer] of state.tlsServers) {
|
|
try {
|
|
tlsServer.close();
|
|
logInfo('Main', `Closed TLS server`, { key });
|
|
} catch (err) {
|
|
logError('Main', `Error closing TLS server`, { key, error: err.message });
|
|
}
|
|
}
|
|
state.tlsServers.clear();
|
|
|
|
// Close HTTP servers
|
|
for (const [key, httpServer] of state.httpServers) {
|
|
try {
|
|
httpServer.close();
|
|
logInfo('Main', `Closed HTTP server`, { key });
|
|
} catch (err) {
|
|
logError('Main', `Error closing HTTP server`, { key, error: err.message });
|
|
}
|
|
}
|
|
state.httpServers.clear();
|
|
|
|
// Close peer channels
|
|
for (const [peerId, channels] of state.peerChannels) {
|
|
try {
|
|
if (channels.inviteChannel && !channels.inviteChannel.destroyed) {
|
|
channels.inviteChannel.close();
|
|
}
|
|
if (channels.requestChannel && !channels.requestChannel.destroyed) {
|
|
channels.requestChannel.close();
|
|
}
|
|
} catch (err) {
|
|
logError('Main', `Error closing channels for peer`, { peerId, error: err.message });
|
|
}
|
|
}
|
|
state.peerChannels.clear();
|
|
|
|
// Close swarm (using state.swarm and state.topic)
|
|
if (state.swarm && state.topic) {
|
|
try {
|
|
state.swarm.leave(state.topic);
|
|
await state.swarm.flush();
|
|
await state.swarm.destroy();
|
|
logInfo('Main', 'Swarm destroyed');
|
|
} catch (err) {
|
|
logError('Main', `Error destroying swarm`, { error: err.message });
|
|
}
|
|
}
|
|
|
|
// Close DNS pass (includes closing its internal Autobase core)
|
|
if (state.dnsPass && typeof state.dnsPass.close === 'function') {
|
|
try {
|
|
await state.dnsPass.close();
|
|
logInfo('Main', 'DNS pass closed - Autobase core should be closed');
|
|
} catch (err) {
|
|
logError('Main', `Error closing dnsPass`, { error: err.message });
|
|
}
|
|
}
|
|
|
|
// Verify dnsPass base core is closed (dnsPass.close() should handle this)
|
|
if (state.core && typeof state.core.close === 'function' && !state.core.closed) {
|
|
try {
|
|
await state.core.close();
|
|
logInfo('Main', 'DNS pass base core closed explicitly');
|
|
} catch (err) {
|
|
if (err.message.includes('Corestore is closed') ||
|
|
err.message.includes('already closed')) {
|
|
logInfo('Main', 'DNS pass base core was already closed');
|
|
} else {
|
|
logError('Main', `Error closing dnsPass base core`, { error: err.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close corestore
|
|
if (state.store && typeof state.store.close === 'function' && !state.store.closed) {
|
|
try {
|
|
await state.store.close();
|
|
logInfo('Main', 'Corestore closed');
|
|
} catch (err) {
|
|
// Ignore errors if corestore was already closed
|
|
if (err.message.includes('Corestore is closed') ||
|
|
err.message.includes('already closed')) {
|
|
logInfo('Main', 'Corestore already closed, skipping cleanup');
|
|
} else {
|
|
logError('Main', `Error closing corestore`, { error: err.message });
|
|
}
|
|
}
|
|
} else {
|
|
logInfo('Main', 'Corestore not available or already closed');
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
process.on('SIGQUIT', cleanup);
|
|
}
|
|
|
|
main().catch(err => {
|
|
logError('Main', `Fatal error`, { error: err.message });
|
|
process.exit(1);
|
|
});
|