825 lines
32 KiB
JavaScript
825 lines
32 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 } = 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;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
module.exports = { handleHolesailRoutes };
|
|
|