Files
p2ns/includes/admin/routes/holesail.js
T

678 lines
28 KiB
JavaScript

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, getHolesailClientStatus } = require('../holesail-clients');
const { ensurePortFree } = require('../port-management');
const { broadcast } = require('../websocket');
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
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 info = state.holesailClientInfos.get(id) || {};
const status = getHolesailClientStatus(id, opts, info);
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, key, port, protocol } = validation;
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}`);
}
const id = crypto.randomBytes(16).toString('hex');
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();
broadcast({ type: 'update-holesail-clients' });
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 holesail = state.holesails.get(key);
if (holesail) {
if (holesail instanceof dgram.Socket) {
await new Promise(resolve => {
holesail.close(() => {
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
resolve();
});
setTimeout(() => {
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
holesail.close();
resolve();
}, 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) {
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) {
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}`);
}
const ip = state.domainToIPMap.get(opts.domain);
if (ip && opts.port) {
const freed = await ensurePortFree(ip, opts.port);
if (!freed) {
logError('Admin', `Failed to ensure port ${opts.port} free on ${ip} for ${key}`);
} else {
logInfo('Admin', `Successfully ensured port ${opts.port} free on ${ip} for ${key}`);
}
}
}
state.holesailClientOpts.delete(id);
state.holesailClientInfos.delete(id);
await saveHolesailClients();
broadcast({ type: 'update-holesail-clients' });
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 === '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/admin-backend/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 };