Files
p2ns/proxy-server/p2ns_proxy_server.js
T
Raven Scott d230fb3360 EXPERIMENTAL: replace KV-scan consensus with Autobase sidecar engine
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.
2026-05-30 23:49:48 -04:00

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);
});