const fs = require('fs').promises; const dgram = require('dgram'); const crypto = require('crypto'); const state = require('../../../infrastructure/state'); const { addDomain } = require('../../../core/domains'); const { validateHolesailClient } = require('../../../infrastructure/validation'); const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces'); const { logDebug, logError, logInfo, logWarn } = require('../../../infrastructure/logger'); const { startHolesailServer, saveHolesailServers } = require('../holesail-servers'); const { startForkedHolesailClient, saveHolesailClients } = require('../holesail-clients'); const { ensurePortFree } = require('../port-management'); const { broadcast } = require('../websocket'); const { getConsensusState, getClaimClients, updateClaimClients } = require('../../../core/core'); const { getPersistentPublicKey } = require('../../../infrastructure/utils'); const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json'; const subscriptionsFile = process.env.SUBSCRIPTIONS_FILE || './cache/subscriptions.json'; const subscriptionManager = require('../../subscription-manager'); async function handleHolesailRoutes(req, res) { const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname; const method = req.method; if (method === 'GET' && urlPath === '/api/holesail-servers') { try { const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => { const child = state.holesailChildren.get(id); const info = state.holesailInfos.get(id) || {}; const status = child && !child.killed ? 'running' : 'stopped'; return { id, opts, info: { ...info, state: status } }; }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(servers)); } catch (err) { logError('Admin', `Failed to fetch Holesail servers: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch Holesail servers' })); } return true; } if (method === 'GET' && urlPath === '/api/holesail-clients') { try { const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => { const child = state.holesailClientChildren.get(id); const info = state.holesailClientInfos.get(id) || {}; const key = `${opts.domain}:${opts.port}`; const isHolesailActive = state.holesails.has(key); const isChildRunning = child && !child.killed; let status = 'stopped'; if (isChildRunning && isHolesailActive && info.state !== 'error') { status = 'running'; } else if (isChildRunning || isHolesailActive) { status = 'starting'; } else if (info.state === 'error') { status = 'error'; } return { id, opts, info: { ...info, state: status } }; }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(clients)); } catch (err) { logError('Admin', `Failed to fetch Holesail clients: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch Holesail clients' })); } return true; } if (method === 'POST' && urlPath === '/api/holesail-create') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const data = JSON.parse(body); const opts = { ...data }; const domain = opts.domain; delete opts.domain; const id = crypto.randomBytes(16).toString('hex'); logDebug('Admin', `Creating Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`); const { id: createdId, info } = await startHolesailServer(id, opts); if (domain) { const hash = info.url; await addDomain(domain, hash); if (!state.domainToIPMap.has(domain)) { await createInterfaceForDomain(domain); logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`); } let domains = []; if (await fs.access(domainsFile).then(() => true).catch(() => false)) { const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8')); if (!Array.isArray(parsed)) { logError('Admin', `Domains file does not contain an array, resetting to empty array`); domains = []; } else { domains = parsed; } } const existingIndex = domains.findIndex(d => d.domain === domain); if (existingIndex !== -1) { domains[existingIndex].hash = hash; } else { domains.push({ domain, hash }); } await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2)); logInfo('Admin', `Automatically added domain ${domain} with hash ${hash} to P2P network and domains.json`); } await saveHolesailServers(); broadcast({ type: 'update-holesail' }); broadcast({ type: 'update-database' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ id: createdId })); } catch (err) { logError('Admin', `Failed to create Holesail server: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/holesail-delete') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { id } = JSON.parse(body); const child = state.holesailChildren.get(id); const opts = state.holesailOpts.get(id); if (child) { child.kill('SIGTERM'); await new Promise(resolve => { child.on('exit', () => resolve()); setTimeout(() => { child.kill('SIGKILL'); logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`); resolve(); }, 3000); }); state.holesailChildren.delete(id); state.holesailChildStartTimes.delete(id); logInfo('Admin', `Closed Holesail server child process ${id}`); } state.holesailOpts.delete(id); state.holesailInfos.delete(id); await saveHolesailServers(); broadcast({ type: 'update-holesail' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { logError('Admin', `Failed to delete Holesail server: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/holesail-restart') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { id } = JSON.parse(body); const child = state.holesailChildren.get(id); const opts = state.holesailOpts.get(id); if (!opts) { throw new Error('Server not found'); } let exitPromise; if (child) { logDebug('Admin', `Terminating existing Holesail server child process ${id}`); exitPromise = new Promise((resolve) => { child.once('exit', resolve); setTimeout(() => { child.kill('SIGKILL'); logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`); resolve(); }, 3000); }); child.kill('SIGTERM'); await exitPromise; logInfo('Admin', `Closed Holesail server child process ${id}`); } state.holesailInfos.delete(id); broadcast({ type: 'update-holesail' }); logDebug('Admin', `Restarting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`); const { id: createdId, info } = await startHolesailServer(id, opts); if (opts.domain) { const hash = info.url.replace('hs://', ''); await addDomain(opts.domain, hash); if (!state.domainToIPMap.has(opts.domain)) { await createInterfaceForDomain(opts.domain); logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`); } let domains = []; if (await fs.access(domainsFile).then(() => true).catch(() => false)) { const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8')); if (!Array.isArray(parsed)) { logError('Admin', `Domains file does not contain an array, resetting to empty array`); domains = []; } else { domains = parsed; } } const existingIndex = domains.findIndex(d => d.domain === opts.domain); if (existingIndex !== -1) { domains[existingIndex].hash = hash; } else { domains.push({ domain: opts.domain, hash }); } await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2)); logInfo('Admin', `Automatically added domain ${opts.domain} with hash ${hash} to P2P network and domains.json`); } await saveHolesailServers(); broadcast({ type: 'update-holesail' }); broadcast({ type: 'update-database' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { logError('Admin', `Failed to restart Holesail server: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/holesail-client-create') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const data = JSON.parse(body); const validation = validateHolesailClient(data); if (!validation.valid) { res.writeHead(400); res.end(validation.error || 'Invalid input'); return; } const { domain, serviceName, key, port, protocol } = { ...validation, serviceName: data.serviceName }; // Validate ownership const consensusState = await getConsensusState(domain); const localWriter = getPersistentPublicKey(); if (!localWriter || consensusState.resolvedClaimant !== localWriter) { res.writeHead(403); res.end('You must own this domain to create a client'); return; } if (!state.domainToIPMap.has(domain)) { await createInterfaceForDomain(domain); logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`); } const ip = state.domainToIPMap.get(domain); const portFree = await ensurePortFree(ip, port); if (!portFree) { throw new Error(`Unable to ensure port ${port} free on ${ip}`); } // Use domain_servicename format for client ID if serviceName provided const id = serviceName ? `${domain}_${serviceName}`.replace(/[^a-zA-Z0-9_]/g, '_') : crypto.randomBytes(16).toString('hex'); // Check if client with this ID already exists if (state.holesailClientOpts.has(id)) { res.writeHead(409); res.end('A client with this service name already exists for this domain'); return; } state.holesailClientInfos.set(id, { state: 'starting' }); broadcast({ type: 'update-holesail-clients' }); await startForkedHolesailClient(id, { domain, key, port, protocol: protocol || 'tcp' }); state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' }); await saveHolesailClients(); // Update claim record with new client if (serviceName && localWriter) { const existingClients = await getClaimClients(domain, localWriter); const newClient = { name: serviceName, key: key, port: port, protocol: protocol || 'tcp' }; // Remove existing client with same name if any const updatedClients = existingClients.filter(c => c.name !== serviceName); updatedClients.push(newClient); await updateClaimClients(domain, localWriter, updatedClients); logInfo('Admin', `Updated claim record for ${domain} with client ${serviceName}`); // Trigger claim change check to auto-subscribe peers with subscribeAll enabled try { const { checkClaimChanges } = require('../../subscription-manager'); checkClaimChanges(); } catch (err) { logWarn('Admin', `Error triggering claim change check: ${err.message}`); } } broadcast({ type: 'update-holesail-clients' }); broadcast({ type: 'update-database' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ id })); } catch (err) { logError('Admin', `Failed to create Holesail client: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/holesail-client-delete') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { id } = JSON.parse(body); const child = state.holesailClientChildren.get(id); const opts = state.holesailClientOpts.get(id); if (child) { child.kill('SIGTERM'); await new Promise(resolve => { child.on('exit', () => resolve()); setTimeout(() => { child.kill('SIGKILL'); logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`); resolve(); }, 3000); }); state.holesailClientChildren.delete(id); state.holesailChildStartTimes.delete(id); logInfo('Admin', `Closed Holesail client ${id} for ${opts.domain}:${opts.port}`); } if (opts) { const key = `${opts.domain}:${opts.port}`; const isUDP = opts.protocol === 'udp'; const holesail = state.holesails.get(key); if (holesail) { if (holesail instanceof dgram.Socket || isUDP) { try { await new Promise(resolve => { if (holesail.close) { holesail.close(() => { logInfo('Admin', `Closed UDP Holesail connection for ${key}`); resolve(); }); setTimeout(() => { logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`); try { if (holesail.close) holesail.close(); } catch (e) { // Ignore errors on forced close } resolve(); }, 5000); } else { resolve(); } }); } catch (err) { logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`); } } else { try { await holesail.close(); logInfo('Admin', `Closed TCP Holesail connection for ${key}`); } catch (err) { logWarn('Admin', `Error closing TCP Holesail for ${key}: ${err.message}`); } } state.holesails.delete(key); if (state.holesailStartTimes) { state.holesailStartTimes.delete(key); } } // Only cleanup TLS/HTTP servers for TCP connections if (!isUDP) { const tlsServer = state.tlsServers.get(key); if (tlsServer) { try { await new Promise(resolve => { tlsServer.close(resolve); setTimeout(() => { logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`); try { if (tlsServer.destroy) tlsServer.destroy(); else if (tlsServer.close) tlsServer.close(); } catch (e) { // Ignore errors } resolve(); }, 5000); }); state.tlsServers.delete(key); logInfo('Admin', `Closed TLS server for ${key}`); } catch (err) { logWarn('Admin', `Error closing TLS server for ${key}: ${err.message}`); } } const httpServer = state.httpServers.get(key); if (httpServer) { try { await new Promise(resolve => { httpServer.close(resolve); setTimeout(() => { logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`); try { if (httpServer.destroy) httpServer.destroy(); else if (httpServer.close) httpServer.close(); } catch (e) { // Ignore errors } resolve(); }, 5000); }); state.httpServers.delete(key); logInfo('Admin', `Closed HTTP server for ${key}`); } catch (err) { logWarn('Admin', `Error closing HTTP server for ${key}: ${err.message}`); } } } // For UDP, don't call ensurePortFree as it may kill wrong processes // UDP sockets don't hold ports the same way TCP does // The child process termination should be sufficient if (!isUDP) { const ip = state.domainToIPMap.get(opts.domain); if (ip && opts.port) { try { const freed = await ensurePortFree(ip, opts.port); if (!freed) { logWarn('Admin', `Port ${opts.port} on ${ip} may still be in use for ${key}`); } else { logInfo('Admin', `Successfully ensured port ${opts.port} free on ${ip} for ${key}`); } } catch (err) { logWarn('Admin', `Error ensuring port free for ${key}: ${err.message}`); } } } else { logDebug('Admin', `Skipping port cleanup for UDP client ${key} - child process termination should be sufficient`); } } // Update claim record to remove client if (opts && opts.domain) { const consensusState = await getConsensusState(opts.domain); const localWriter = getPersistentPublicKey(); if (localWriter && consensusState.resolvedClaimant === localWriter) { const existingClients = await getClaimClients(opts.domain, localWriter); // Try to find client by matching port and domain const updatedClients = existingClients.filter(c => { // If client ID matches domain_servicename format, extract service name if (id.includes('_') && id.startsWith(opts.domain + '_')) { const serviceName = id.substring(opts.domain.length + 1); return c.name !== serviceName; } // Otherwise, match by port return c.port !== opts.port; }); await updateClaimClients(opts.domain, localWriter, updatedClients); logInfo('Admin', `Updated claim record for ${opts.domain} after client deletion`); // Trigger claim change check to auto-unsubscribe peers try { const { checkClaimChanges } = require('../../subscription-manager'); checkClaimChanges(); } catch (err) { logWarn('Admin', `Error triggering claim change check: ${err.message}`); } } } state.holesailClientOpts.delete(id); state.holesailClientInfos.delete(id); await saveHolesailClients(); broadcast({ type: 'update-holesail-clients' }); broadcast({ type: 'update-database' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { logError('Admin', `Failed to delete Holesail client: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'GET' && urlPath === '/api/domain-services') { try { const url = new URL(req.url, `https://${req.headers.host}`); const domain = url.searchParams.get('domain'); if (!domain) { res.writeHead(400); res.end(JSON.stringify({ error: 'Domain parameter required' })); return true; } const consensusState = await getConsensusState(domain); if (!consensusState.resolvedClaimant) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify([])); return true; } const clients = await getClaimClients(domain, consensusState.resolvedClaimant); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(clients)); } catch (err) { logError('Admin', `Failed to fetch domain services: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch domain services' })); } return true; } if (method === 'GET' && urlPath === '/api/service-subscriptions') { try { const subscriptions = await subscriptionManager.loadSubscriptions(); // Convert to flat list format for backward compatibility with UI const flatList = []; for (const domainSub of subscriptions) { for (const service of domainSub.services) { flatList.push({ domain: domainSub.domain, serviceName: service.serviceName, key: service.key, port: service.port, protocol: service.protocol }); } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(flatList)); } catch (err) { logError('Admin', `Failed to fetch subscriptions: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch subscriptions' })); } return true; } if (method === 'GET' && urlPath === '/api/subscribe-all-domains') { try { const domains = await subscriptionManager.getSubscribeAllDomains(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(domains)); } catch (err) { logError('Admin', `Failed to fetch subscribe-all domains: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch subscribe-all domains' })); } return true; } if (method === 'POST' && urlPath === '/api/service-subscribe') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { domain, serviceName, key, port, protocol } = JSON.parse(body); if (!domain || !serviceName || !key || !port) { res.writeHead(400); res.end('Missing required fields'); return; } const success = await subscriptionManager.subscribeToService(domain, serviceName, key, port, protocol); if (!success) { res.writeHead(409); res.end('Already subscribed to this service'); return; } broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (err) { logError('Admin', `Failed to subscribe to service: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/service-unsubscribe') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { domain, serviceName } = JSON.parse(body); if (!domain || !serviceName) { res.writeHead(400); res.end('Missing required fields'); return; } const success = await subscriptionManager.unsubscribeFromService(domain, serviceName); if (!success) { res.writeHead(404); res.end('Subscription not found'); return; } broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (err) { logError('Admin', `Failed to unsubscribe from service: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/subscribe-all') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { domain } = JSON.parse(body); if (!domain) { res.writeHead(400); res.end('Missing domain field'); return; } await subscriptionManager.setSubscribeAll(domain, true); broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (err) { logError('Admin', `Failed to set subscribe-all: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/unsubscribe-all') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { domain } = JSON.parse(body); if (!domain) { res.writeHead(400); res.end('Missing domain field'); return; } await subscriptionManager.setSubscribeAll(domain, false); broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (err) { logError('Admin', `Failed to clear subscribe-all: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/holesail-client-restart') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { id } = JSON.parse(body); logDebug('Admin', `Initiating restart for Holesail client ${id}`); const child = state.holesailClientChildren.get(id); const opts = state.holesailClientOpts.get(id); if (!opts) { throw new Error(`Client ${id} not found`); } const key = `${opts.domain}:${opts.port}`; let exitPromise; if (child) { logDebug('Admin', `Terminating existing child process for client ${id}`); exitPromise = new Promise((resolve) => { child.once('exit', resolve); setTimeout(() => { child.kill('SIGKILL'); logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`); resolve(); }, 3000); }); child.kill('SIGTERM'); await exitPromise; state.holesailClientChildren.delete(id); state.holesailChildStartTimes.delete(id); logInfo('Admin', `Closed Holesail client child process ${id}`); } const holesail = state.holesails.get(key); if (holesail) { logDebug('Admin', `Closing Holesail connection for ${key}`); if (holesail instanceof dgram.Socket) { await new Promise((resolve, reject) => { holesail.close((err) => { if (err) { logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`); reject(err); } else { logInfo('Admin', `Closed UDP Holesail connection for ${key}`); resolve(); } }); setTimeout(() => { logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`); try { holesail.close(); resolve(); } catch (err) { reject(err); } }, 5000); }); } else { await holesail.close(); logInfo('Admin', `Closed TCP Holesail connection for ${key}`); } state.holesails.delete(key); if (state.holesailStartTimes) { state.holesailStartTimes.delete(key); } } const tlsServer = state.tlsServers.get(key); if (tlsServer) { logDebug('Admin', `Closing TLS server for ${key}`); await new Promise(resolve => { tlsServer.close(resolve); setTimeout(() => { logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`); tlsServer.destroy ? tlsServer.destroy() : tlsServer.close(); resolve(); }, 5000); }); state.tlsServers.delete(key); logInfo('Admin', `Closed TLS server for ${key}`); } const httpServer = state.httpServers.get(key); if (httpServer) { logDebug('Admin', `Closing HTTP server for ${key}`); await new Promise(resolve => { httpServer.close(resolve); setTimeout(() => { logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`); httpServer.destroy ? httpServer.destroy() : httpServer.close(); resolve(); }, 5000); }); state.httpServers.delete(key); logInfo('Admin', `Closed HTTP server for ${key}`); } state.holesailClientInfos.set(id, { state: 'starting' }); broadcast({ type: 'update-holesail-clients' }); logInfo('Admin', `Holesail client ${id} stopped, preparing to restart`); if (!state.domainToIPMap.has(opts.domain)) { await createInterfaceForDomain(opts.domain); logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`); } const ip = state.domainToIPMap.get(opts.domain); const portFree = await ensurePortFree(ip, opts.port); if (!portFree) { state.holesailClientInfos.set(id, { state: 'error', error: `Unable to free port ${opts.port} on ${ip}` }); broadcast({ type: 'update-holesail-clients' }); throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`); } await startForkedHolesailClient(id, opts); logInfo('Admin', `Successfully restarted Holesail client ${id} for ${opts.domain}:${opts.port}`); await new Promise(resolve => setTimeout(resolve, 1000)); const isHolesailActive = state.holesails.has(key); if (!isHolesailActive) { logWarn('Admin', `Holesail client ${id} for ${key} started but not active in state.holesails. Attempting final restart.`); state.holesailClientInfos.set(id, { state: 'starting' }); broadcast({ type: 'update-holesail-clients' }); await startForkedHolesailClient(id, opts); } const finalCheck = state.holesails.has(key); if (!finalCheck) { state.holesailClientInfos.set(id, { state: 'error', error: `Failed to start after final attempt` }); broadcast({ type: 'update-holesail-clients' }); throw new Error(`Holesail client ${id} for ${key} failed to start after final attempt`); } state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' }); logDebug('Admin', `Verified Holesail client ${id} is active for ${key}`); await saveHolesailClients(); broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { state.holesailClientInfos.set(id, { state: 'error', error: err.message }); broadcast({ type: 'update-holesail-clients' }); logError('Admin', `Failed to restart Holesail client: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } if (method === 'POST' && urlPath === '/api/restart-holesail-clients-for-domain') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { domain } = JSON.parse(body); logInfo('Admin', `Restarting all Holesail connections for domain ${domain} due to hash preference change`); // Find all active Holesail connections for this domain const keysToClose = []; for (const key of state.holesails.keys()) { const [connectionDomain, port] = key.split(':'); if (connectionDomain === domain) { keysToClose.push(key); } } // Also find admin-managed clients for this domain const clientIdsToRestart = []; for (const [id, opts] of state.holesailClientOpts) { if (opts.domain === domain) { clientIdsToRestart.push(id); } } logInfo('Admin', `Found ${keysToClose.length} active connections and ${clientIdsToRestart.length} admin clients for domain ${domain}`); // Close all active DNS-triggered connections for this domain const closePromises = keysToClose.map(async (key) => { try { logDebug('Admin', `Closing Holesail connection for ${key}`); const holesail = state.holesails.get(key); if (holesail) { if (holesail instanceof dgram.Socket) { await new Promise((resolve, reject) => { holesail.close((err) => { if (err) { logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`); reject(err); } else { logInfo('Admin', `Closed UDP Holesail connection for ${key}`); resolve(); } }); setTimeout(() => { logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`); try { holesail.close(); resolve(); } catch (e) { reject(e); } }, 2000); }); } else { holesail.close(); logInfo('Admin', `Closed TCP Holesail connection for ${key}`); } } state.holesails.delete(key); if (state.holesailStartTimes) { state.holesailStartTimes.delete(key); } } catch (err) { logError('Admin', `Failed to close Holesail connection ${key}: ${err.message}`); } }); // Restart admin-managed clients const restartPromises = clientIdsToRestart.map(async (id) => { try { logDebug('Admin', `Restarting admin-managed Holesail client ${id} for domain ${domain}`); const child = state.holesailClientChildren.get(id); const opts = state.holesailClientOpts.get(id); if (!opts) { logWarn('Admin', `Client options not found for ${id}`); return; } // Get the new hash for the domain const { getHashForDomain } = require('../../../core/core'); const newHash = await getHashForDomain(domain); const key = `${opts.domain}:${opts.port}`; // Terminate existing child process let exitPromise; if (child) { logDebug('Admin', `Terminating existing child process for client ${id}`); exitPromise = new Promise((resolve) => { child.on('exit', () => { logDebug('Admin', `Child process exited for client ${id}`); resolve(); }); child.on('error', (err) => { logWarn('Admin', `Error during child process termination for ${id}: ${err.message}`); resolve(); }); }); child.kill('SIGTERM'); // Force kill after 5 seconds setTimeout(() => { if (!child.killed) { logWarn('Admin', `Force killing child process for client ${id}`); child.kill('SIGKILL'); } }, 5000); await exitPromise; } // Clean up old state state.holesailClientChildren.delete(id); state.holesailClientInfos.delete(id); state.holesailChildStartTimes.delete(id); // Start new client with updated hash logInfo('Admin', `Starting new admin-managed Holesail client for ${domain} with hash ${newHash}`); const { startHolesailClient } = require('../admin-holesail'); await startHolesailClient(domain, newHash, opts.ip, opts.port); // Wait a moment for the connection to establish await new Promise(resolve => setTimeout(resolve, 200)); // Verify the new connection is active const newKey = `${domain}:${opts.port}`; if (state.holesails.has(newKey)) { logInfo('Admin', `Successfully restarted admin-managed Holesail client for ${domain} - new connection active`); } else { logWarn('Admin', `Admin-managed Holesail client restart for ${domain} completed but connection not yet active`); } } catch (err) { logError('Admin', `Failed to restart admin-managed Holesail client ${id}: ${err.message}`); } }); await Promise.all([...closePromises, ...restartPromises]); // Create new DNS-triggered connections if needed const { getHashForDomain } = require('../../../core/core'); const { startHolesailClient: startNetworkingHolesailClient } = require('../../../networking/holesail'); const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces'); const newHash = await getHashForDomain(domain); // Ensure domain has an IP assigned let localIP = state.domainToIPMap[domain]; if (!localIP) { logInfo('Admin', `Assigning IP for domain ${domain} during restart`); localIP = await createInterfaceForDomain(domain); } if (localIP && newHash) { logInfo('Admin', `Creating new DNS-triggered Holesail client for ${domain} with hash ${newHash} on IP ${localIP}`); try { await startNetworkingHolesailClient(domain, newHash, localIP, state.internalPort); logInfo('Admin', `Successfully created new DNS-triggered Holesail client for ${domain}`); } catch (err) { logError('Admin', `Failed to create new DNS-triggered Holesail client for ${domain}: ${err.message}`); } } else { logWarn('Admin', `Cannot create DNS-triggered client for ${domain}: localIP=${localIP}, newHash=${newHash}`); } // Final verification - ensure new connections are established await new Promise(resolve => setTimeout(resolve, 300)); // Check that we have active connections for this domain const finalConnections = Array.from(state.holesails.keys()).filter(key => key.startsWith(`${domain}:`)); logInfo('Admin', `Final verification: ${finalConnections.length} active connections for domain ${domain}`); broadcast({ type: 'update-holesail-clients' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { logError('Admin', `Failed to restart Holesail clients for domain: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } return false; } module.exports = { handleHolesailRoutes };