This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
const fs = require('fs').promises;
const pathModule = require('path');
const state = require('../../infrastructure/state');
const ca = require('../../security/certificate_authority');
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
const { logDebug, logError } = require('../../infrastructure/logger');
const { broadcast } = require('../websocket');
const certsDir = process.env.CERTS_DIR || './certs';
async function handleCertsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
const url = new URL(req.url, `https://${req.headers.host}`);
if (method === 'GET' && urlPath === '/api/certs') {
try {
const certDomains = await fs.readdir(certsDir);
const filteredDomains = [];
for (const file of certDomains) {
if ((await fs.stat(pathModule.join(certsDir, file))).isDirectory()) {
filteredDomains.push(file);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(filteredDomains));
} catch (err) {
logError('Admin', `Failed to fetch certs: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch certs' }));
}
return true;
}
if (method === 'GET' && urlPath.startsWith('/api/cert-details')) {
const domain = url.searchParams.get('domain');
try {
const certPath = pathModule.join(certsDir, domain, 'cert.pem');
const certContent = await fs.readFile(certPath, 'utf8');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(certContent);
} catch (err) {
logError('Admin', `Failed to fetch cert details: ${err.message}`);
res.writeHead(500);
res.end('Failed to fetch cert details');
}
return true;
}
if (method === 'POST' && urlPath === '/api/regenerate-ca') {
try {
ca.regenerateRootCA();
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate CA: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
if (method === 'POST' && urlPath === '/api/install-ca') {
try {
ca.installRootCA();
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to install CA: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
if (method === 'POST' && urlPath === '/api/generate-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to generate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/delete-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const domainDir = pathModule.join(certsDir, data.domain);
await fs.rm(domainDir, { recursive: true, force: true });
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to delete cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/regenerate-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const domainDir = pathModule.join(certsDir, data.domain);
await fs.rm(domainDir, { recursive: true, force: true });
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleCertsRoutes };
+181
View File
@@ -0,0 +1,181 @@
const fs = require('fs').promises;
const state = require('../../infrastructure/state');
const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState } = require('../../core/core');
const { addDomain } = require('../../core/domains');
const { validateDomainAddition, validateDomainRemoval } = require('../../infrastructure/validation');
const { atomicDomainCleanup } = require('../../core/domain_cleanup');
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
const { logDebug, logError } = require('../../infrastructure/logger');
const { trackRequest } = require('../../maintenance/metrics');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { broadcast } = require('../websocket');
const { getPersistentPublicKey } = require('../../infrastructure/utils');
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
async function handleDomainsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
const url = new URL(req.url, `https://${req.headers.host}`);
if (method === 'GET' && urlPath === '/api/resolved-domains') {
try {
const allEntries = await getAllEntries();
const domainClaimants = new Map();
for (const entry of allEntries) {
if (entry.key.startsWith('claim:')) {
const parts = entry.key.split(':');
if (parts.length === 3) {
const domain = parts[1];
const claimant = parts[2];
if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set());
domainClaimants.get(domain).add(claimant);
}
}
}
const localWriter = getPersistentPublicKey();
const domains = new Set(domainClaimants.keys());
const resolved = [];
for (const domain of domains) {
const hash = await getHashForDomain(domain) || 'none';
const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false;
resolved.push({ domain, hash, isLocal });
}
let internalDomains = ['p2ns.admin'];
try {
const { getInternalDomains } = require('../../plugins/plugin-handler');
internalDomains = await getInternalDomains();
} catch (err) {
// Fallback if plugin system not available
}
for (const d of internalDomains) {
resolved.push({ domain: d, hash: 'internal', isLocal: true });
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(resolved.sort((a, b) => a.domain.localeCompare(b.domain))));
} catch (err) {
logError('Admin', `Failed to fetch resolved domains: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch domains' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/add-domain') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const validation = validateDomainAddition(data);
if (!validation.valid) {
res.writeHead(400);
res.end(validation.error || 'Invalid input');
return;
}
// Extract SSL flag - handle both boolean true and string "true"
const ssl = data.ssl === true || data.ssl === 'true' || data.ssl === 1;
await addDomain(validation.domain, validation.hash, ssl);
await doAutoVotes();
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 === validation.domain);
if (existingIndex !== -1) {
domains[existingIndex].hash = validation.hash;
domains[existingIndex].ssl = ssl; // Update SSL flag
} else {
domains.push({ domain: validation.domain, hash: validation.hash, ssl: ssl });
}
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
if (!state.domainToIPMap.has(validation.domain)) {
await createInterfaceForDomain(validation.domain);
logDebug('Admin', `Assigned IP to ${validation.domain}: ${state.domainToIPMap.get(validation.domain)}`);
}
broadcast({ type: 'update-database' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
trackRequest('/api/add-domain', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/remove-domain') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const validation = validateDomainRemoval(data);
if (!validation.valid) {
res.writeHead(400);
res.end(validation.error || 'Invalid input');
return;
}
const domain = validation.domain;
// Authorization check: verify peer has claim and is resolved claimant
const localWriter = getPersistentPublicKey();
if (!localWriter) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Peer not initialized');
return;
}
// Check if peer has a claim for this domain
const allEntries = await getAllEntries();
const claimKey = `claim:${domain}:${localWriter}`;
const hasClaim = allEntries.some(entry => entry.key === claimKey);
if (!hasClaim) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Only domains you have claims and resolutions for can be deleted');
return;
}
// Check if peer is the resolved claimant
const consensusState = await getConsensusState(domain);
if (consensusState.resolvedClaimant !== localWriter) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Only domains you have claims and resolutions for can be deleted');
return;
}
await atomicDomainCleanup(domain);
if (state.sendRemovalRequest) {
state.sendRemovalRequest(domain);
}
trackRequest('/api/remove-domain', true);
broadcast({ type: 'update-database' });
broadcast({ type: 'update-holesail-clients' });
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
trackRequest('/api/remove-domain', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
});
return true;
}
return false;
}
module.exports = { handleDomainsRoutes };
+57
View File
@@ -0,0 +1,57 @@
const { getAllEntries, removeAllRecords } = require('../../core/core');
const { logError, logInfo } = require('../../infrastructure/logger');
const { trackRequest } = require('../../maintenance/metrics');
// Get broadcast function if available
let broadcast;
try {
broadcast = require('../admin-backend/websocket').broadcast;
} catch (e) {
// Fallback if websocket module not available
broadcast = () => {};
}
async function handleEntriesRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/entries') {
try {
const entries = await getAllEntries();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(entries));
} catch (err) {
logError('Admin', `Failed to fetch entries: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch entries' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/remove-all-records') {
try {
const result = await removeAllRecords();
trackRequest('/api/remove-all-records', true);
broadcast({ type: 'update-database' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Removed ${result.removed} records from the network`,
removed: result.removed,
errors: result.errors
}));
logInfo('Admin', `Removed all records: ${result.removed} removed, ${result.errors} errors`);
} catch (err) {
trackRequest('/api/remove-all-records', false);
logError('Admin', `Failed to remove all records: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to remove all records', message: err.message }));
}
return true;
}
return false;
}
module.exports = { handleEntriesRoutes };
+505
View File
@@ -0,0 +1,505 @@
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 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 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, 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;
}
return false;
}
module.exports = { handleHolesailRoutes };
+54
View File
@@ -0,0 +1,54 @@
const { checkRateLimit } = require('../../infrastructure/rate_limit');
const { trackRequest } = require('../../maintenance/metrics');
const { handleStaticRoutes } = require('./static');
const { handleDomainsRoutes } = require('./domains');
const { handleEntriesRoutes } = require('./entries');
const { handlePeersRoutes } = require('./peers');
const { handleCertsRoutes } = require('./certs');
const { handleInterfacesRoutes } = require('./interfaces');
const { handleLocalDnsRoutes } = require('./local-dns');
const { handleStatusRoutes } = require('./status');
const { handleStatsRoutes } = require('./stats');
const { handleHolesailRoutes } = require('./holesail');
const { handleSettingsRoutes } = require('./settings');
async function handleAdminRequest(req, res) {
const url = new URL(req.url, `https://${req.headers.host}`);
const urlPath = url.pathname;
const method = req.method;
// Check rate limit for API endpoints (GET requests and local IPs are exempt)
// Only rate limit POST requests, GET requests are safe and expected to be frequent
if (urlPath.startsWith('/api/') && method === 'POST') {
const rateLimitError = checkRateLimit(req);
if (rateLimitError) {
res.writeHead(rateLimitError.statusCode, rateLimitError.headers);
res.end(rateLimitError.body);
trackRequest(urlPath, false);
return;
}
}
// Attach urlPath to req for route handlers
req.urlPath = urlPath;
// Try each route handler in order
if (await handleStaticRoutes(req, res)) return;
if (await handleDomainsRoutes(req, res)) return;
if (await handleEntriesRoutes(req, res)) return;
if (await handlePeersRoutes(req, res)) return;
if (await handleCertsRoutes(req, res)) return;
if (await handleInterfacesRoutes(req, res)) return;
if (await handleLocalDnsRoutes(req, res)) return;
if (await handleStatusRoutes(req, res)) return;
if (await handleStatsRoutes(req, res)) return;
if (await handleHolesailRoutes(req, res)) return;
if (await handleSettingsRoutes(req, res)) return;
// No route matched
res.writeHead(404);
res.end('Not Found');
}
module.exports = { handleAdminRequest };
+41
View File
@@ -0,0 +1,41 @@
const state = require('../../infrastructure/state');
const { cleanupInterfaces } = require('../../maintenance/cleanup');
const { logError } = require('../../infrastructure/logger');
const { broadcast } = require('../websocket');
async function handleInterfacesRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/interfaces') {
try {
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => ({ domain, ip }));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(interfaces));
} catch (err) {
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch interfaces' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
try {
await cleanupInterfaces();
broadcast({ type: 'update-interfaces' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to cleanup interfaces: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
return false;
}
module.exports = { handleInterfacesRoutes };
+175
View File
@@ -0,0 +1,175 @@
const fs = require('fs').promises;
const dns = require('dns').promises;
const state = require('../../infrastructure/state');
const { logError, logWarn, logInfo } = require('../../infrastructure/logger');
const { saveSelectorCache } = require('../cache');
const { broadcast } = require('../websocket');
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
async function handleLocalDnsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/local-dns') {
try {
let records = state.localDnsRecords || [];
let conflicts = [];
const domains = new Set([...state.domainsWithBoth, ...state.versionPreferences.keys()]);
for (const domain of domains) {
let publicIP = state.publicIpForDomain[domain];
if (!publicIP && state.versionPreferences.has(domain)) {
try {
const ips = await dns.resolve4(domain);
publicIP = ips[0] || 'N/A';
state.publicIpForDomain[domain] = publicIP;
} catch (err) {
logWarn('Admin', `Failed to resolve public IP for ${domain}: ${err.message}`);
publicIP = 'N/A';
}
}
conflicts.push({
domain,
version: state.versionPreferences.get(domain) || 'p2p',
publicIP: publicIP || 'N/A'
});
}
records = records.map((rec, index) => ({ ...rec, index }));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ records, conflicts }));
} catch (err) {
logError('Admin', `Failed to fetch local DNS and conflicts: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch local DNS and conflicts' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/selector-cache') {
try {
const preferences = Object.fromEntries(state.versionPreferences);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(preferences));
} catch (err) {
logError('Admin', `Failed to fetch selector cache: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch selector cache' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/add-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const record = JSON.parse(body);
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
throw new Error('Missing or invalid required fields: name, type, ttl');
}
record.class = record.class || 'IN';
let records = state.localDnsRecords || [];
records.push(record);
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to add local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { index, record } = JSON.parse(body);
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
throw new Error('Missing or invalid required fields: name, type, ttl');
}
let records = state.localDnsRecords || [];
if (index >= 0 && index < records.length) {
record.class = record.class || 'IN';
records[index] = record;
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} else {
res.writeHead(400);
res.end('Invalid index');
}
} catch (err) {
logError('Admin', `Failed to update local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/delete-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { index } = JSON.parse(body);
let records = state.localDnsRecords || [];
if (index >= 0 && index < records.length) {
records.splice(index, 1);
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} else {
res.writeHead(400);
res.end('Invalid index');
}
} catch (err) {
logError('Admin', `Failed to delete local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-version-preference') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { domain, version } = JSON.parse(body);
if (version !== 'p2p' && version !== 'public') {
res.writeHead(400);
res.end('Invalid version, must be "p2p" or "public"');
return;
}
state.versionPreferences.set(domain, version);
await saveSelectorCache();
broadcast({ type: 'update-local-dns' });
logInfo('Admin', `Updated version preference for ${domain} to ${version}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to update version preference: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleLocalDnsRoutes };
+25
View File
@@ -0,0 +1,25 @@
const state = require('../../infrastructure/state');
const { logError } = require('../../infrastructure/logger');
async function handlePeersRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/peers') {
try {
const peers = Array.from(state.connectedPeers);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(peers));
} catch (err) {
logError('Admin', `Failed to fetch peers: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch peers' }));
}
return true;
}
return false;
}
module.exports = { handlePeersRoutes };
+296
View File
@@ -0,0 +1,296 @@
const fs = require('fs').promises;
const state = require('../../infrastructure/state');
const { logError, logWarn } = require('../../infrastructure/logger');
const { getAvailableIPsForSubnet } = require('../../networking/virtual_interfaces');
const { settingsMetadata, restartRequiredSettings, liveReloadableSettings, envWhitelist, applyLiveSettings } = require('../settings');
const { broadcast } = require('../websocket');
async function handleSettingsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/settings') {
try {
const settings = {};
const metadata = {};
envWhitelist.forEach(key => {
const value = process.env[key] || '';
settings[key] = value;
if (settingsMetadata[key]) {
metadata[key] = {
...settingsMetadata[key],
currentValue: value
};
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ settings, metadata }));
} catch (err) {
logError('Admin', `Failed to fetch settings: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch settings' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/subnets') {
try {
let subnets = [];
if (process.env.SUBNETS) {
try {
subnets = JSON.parse(process.env.SUBNETS);
if (!Array.isArray(subnets)) {
subnets = [];
}
} catch (err) {
logWarn('Admin', `Failed to parse SUBNETS: ${err.message}`);
subnets = [];
}
}
if (subnets.length === 0) {
const subnetBase = process.env.SUBNET_BASE || '192.168.3';
const baseParts = subnetBase.split('.');
if (baseParts.length === 3) {
subnets = [{
base: `${subnetBase}.0`,
cidr: 24,
startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10),
name: 'Default Subnet'
}];
}
}
const subnetInfo = subnets.map((subnet, index) => {
const available = getAvailableIPsForSubnet(subnet);
const used = Array.from(state.domainToIPMap.values()).filter(ip => {
const ipParts = ip.split('.');
const subnetParts = subnet.base.split('.');
return ipParts[0] === subnetParts[0] &&
ipParts[1] === subnetParts[1] &&
ipParts[2] === subnetParts[2];
}).length;
return {
...subnet,
index,
available,
used,
remaining: Math.max(0, available - used)
};
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ subnets: subnetInfo }));
} catch (err) {
logError('Admin', `Failed to fetch subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch subnets' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/subnets') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { subnets } = JSON.parse(body);
if (!Array.isArray(subnets)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'subnets must be an array' }));
return;
}
const errors = [];
subnets.forEach((subnet, index) => {
if (!subnet || typeof subnet !== 'object') {
errors.push(`subnets[${index}]: must be an object`);
return;
}
if (!subnet.base || typeof subnet.base !== 'string') {
errors.push(`subnets[${index}]: base is required and must be a string`);
} else if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(subnet.base)) {
errors.push(`subnets[${index}]: base must be a valid IPv4 address`);
}
const cidr = parseInt(subnet.cidr, 10);
if (isNaN(cidr) || cidr < 1 || cidr > 32) {
errors.push(`subnets[${index}]: cidr must be between 1 and 32`);
}
const startIndex = parseInt(subnet.startIndex || process.env.INITIAL_IP_INDEX || '2', 10);
const maxIPs = Math.pow(2, 32 - cidr) - 2;
if (isNaN(startIndex) || startIndex < 1 || startIndex > Math.min(254, maxIPs)) {
errors.push(`subnets[${index}]: startIndex must be between 1 and ${Math.min(254, maxIPs)}`);
}
if (!subnet.name || typeof subnet.name !== 'string') {
subnet.name = `Subnet ${index + 1}`;
}
});
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
process.env.SUBNETS = JSON.stringify(subnets);
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${JSON.stringify(subnets)}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
state.subnets = subnets;
state.currentSubnetIndex = 0;
state.subnetIPCounters.clear();
subnets.forEach((subnet, index) => {
state.subnetIPCounters.set(index, subnet.startIndex || parseInt(process.env.INITIAL_IP_INDEX || '2', 10));
});
broadcast({ type: 'update-settings' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Subnets updated. Restart required to fully apply changes.',
restartRequired: true
}));
} catch (err) {
logError('Admin', `Failed to update subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-settings') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { settings } = JSON.parse(body);
const errors = [];
for (const [key, value] of Object.entries(settings)) {
if (!envWhitelist.includes(key)) {
errors.push(`Setting ${key} is not whitelisted`);
continue;
}
const meta = settingsMetadata[key];
if (meta) {
if (meta.type === 'number') {
const numValue = parseInt(value, 10);
if (isNaN(numValue)) {
errors.push(`${meta.label}: must be a number`);
continue;
}
if (meta.min !== undefined && numValue < meta.min) {
errors.push(`${meta.label}: must be at least ${meta.min}`);
continue;
}
if (meta.max !== undefined && numValue > meta.max) {
errors.push(`${meta.label}: must be at most ${meta.max}`);
continue;
}
process.env[key] = numValue.toString();
} else if (meta.type === 'checkbox') {
process.env[key] = (value === true || value === 'true' || value === '1') ? 'true' : 'false';
} else if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
} else {
if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
}
}
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${process.env[key] || '[]'}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
const restartRequired = Object.keys(settings).some(key => restartRequiredSettings.includes(key));
const liveSettings = {};
for (const [key, value] of Object.entries(settings)) {
if (liveReloadableSettings.includes(key)) {
liveSettings[key] = value;
}
}
if (Object.keys(liveSettings).length > 0) {
await applyLiveSettings(liveSettings);
}
broadcast({ type: 'update-settings' });
if (restartRequired) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved. Some settings require restart to take effect.',
restartRequired: true,
restartRequiredSettings: Object.keys(settings).filter(k => restartRequiredSettings.includes(k))
}));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved and applied successfully.',
restartRequired: false
}));
}
} catch (err) {
logError('Admin', `Failed to update settings: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleSettingsRoutes };
+79
View File
@@ -0,0 +1,79 @@
const fs = require('fs').promises;
const pathModule = require('path');
const { logDebug, logError } = require('../../infrastructure/logger');
async function handleStaticRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
const tabs = ['domains', 'host', 'local-dns', 'entries', 'peers', 'certs', 'interfaces', 'logs', 'settings', 'stats'];
if (method === 'GET' && urlPath.startsWith('/') && tabs.includes(urlPath.substring(1))) {
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
res.end();
return true;
}
if (method === 'GET' && urlPath === '/') {
logDebug('Admin', 'Serving admin panel HTML');
try {
const html = await fs.readFile(pathModule.join(__dirname, '..', 'index.html'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (err) {
logError('Admin', `Failed to serve index.html: ${err.message}`);
res.writeHead(500);
res.end('Failed to load admin panel');
}
return true;
}
if (method === 'GET' && urlPath === '/tailwind.css') {
try {
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', 'css', 'tailwind.css'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(css);
} catch (err) {
logError('Admin', `Failed to serve tailwind.css: ${err.message}`);
res.writeHead(500);
res.end('Failed to load Tailwind CSS');
}
return true;
}
if (method === 'GET' && urlPath === '/styles.css') {
try {
const css = await fs.readFile(pathModule.join(__dirname, '..', 'styles.css'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(css);
} catch (err) {
logError('Admin', `Failed to serve styles.css: ${err.message}`);
res.writeHead(500);
res.end('Failed to load styles');
}
return true;
}
if (method === 'GET' && urlPath === '/admin.js') {
try {
const js = await fs.readFile(pathModule.join(__dirname, '..', 'admin.js'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.end(js);
} catch (err) {
logError('Admin', `Failed to serve admin.js: ${err.message}`);
res.writeHead(500);
res.end('Failed to load script');
}
return true;
}
if (urlPath === '/favicon.ico') {
res.writeHead(404);
res.end('Not Found');
return true;
}
return false;
}
module.exports = { handleStaticRoutes };
+309
View File
@@ -0,0 +1,309 @@
const dgram = require('dgram');
const state = require('../../infrastructure/state');
const { getMetrics, getHistoricalData, trackRequestWithTiming, trackRequest } = require('../../maintenance/metrics');
const { getHashForDomain } = require('../../core/core');
const { logDebug, logError } = require('../../infrastructure/logger');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { parseMinutesToMs } = require('../../infrastructure/utils');
const pidusage = require('pidusage');
async function handleStatsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/stats') {
try {
const startTime = Date.now();
const stats = getMetrics();
const holesailChildren = [];
const pidStatsPromises = [];
const pidToChildMap = new Map();
for (const [id, child] of state.holesailChildren.entries()) {
try {
const opts = state.holesailOpts.get(id) || {};
const info = state.holesailInfos.get(id) || {};
const startTime = state.holesailChildStartTimes.get(id);
const uptime = startTime ? Date.now() - startTime : 0;
const status = child && !child.killed ? 'running' : 'stopped';
const pid = child ? child.pid : null;
if (pid && child && !child.killed) {
pidStatsPromises.push(
pidusage(pid).then(stats => ({ id, type: 'server', stats })).catch(err => {
logDebug('Admin', `Failed to get stats for server child ${id} (PID ${pid}): ${err.message}`);
return { id, type: 'server', stats: null };
})
);
}
pidToChildMap.set(id, {
id,
type: 'server',
status,
pid,
uptime,
cpuUsage: null,
memoryUsage: null,
opts: {
port: opts.port,
host: opts.host || '0.0.0.0',
protocol: opts.udp ? 'udp' : 'tcp',
secure: opts.secure || false,
domain: opts.domain || null
},
info: info
});
} catch (err) {
logError('Admin', `Error collecting stats for server child ${id}: ${err.message}`);
}
}
for (const [id, child] of state.holesailClientChildren.entries()) {
try {
const opts = state.holesailClientOpts.get(id) || {};
const info = state.holesailClientInfos.get(id) || {};
const startTime = state.holesailChildStartTimes.get(id);
const uptime = startTime ? Date.now() - startTime : 0;
const status = child && !child.killed ? 'running' : 'stopped';
const pid = child ? child.pid : null;
if (pid && child && !child.killed) {
pidStatsPromises.push(
pidusage(pid).then(stats => ({ id, type: 'client', stats })).catch(err => {
logDebug('Admin', `Failed to get stats for client child ${id} (PID ${pid}): ${err.message}`);
return { id, type: 'client', stats: null };
})
);
}
pidToChildMap.set(id, {
id,
type: 'client',
status,
pid,
uptime,
cpuUsage: null,
memoryUsage: null,
opts: {
domain: opts.domain || null,
port: opts.port,
host: opts.host || null,
protocol: opts.protocol || 'tcp'
},
info: info
});
} catch (err) {
logError('Admin', `Error collecting stats for client child ${id}: ${err.message}`);
}
}
const pidStatsResults = await Promise.all(pidStatsPromises);
for (const result of pidStatsResults) {
if (result.stats) {
const childData = pidToChildMap.get(result.id);
if (childData) {
childData.cpuUsage = {
user: result.stats.cpu / 2,
system: result.stats.cpu / 2,
percentage: result.stats.cpu
};
childData.memoryUsage = {
rss: result.stats.memory,
heapUsed: result.stats.memory * 0.8,
heapTotal: result.stats.memory,
external: 0,
arrayBuffers: 0
};
}
}
}
for (const childData of pidToChildMap.values()) {
holesailChildren.push(childData);
}
const managedConnections = new Set();
for (const child of holesailChildren) {
if (child.opts && child.opts.domain && child.opts.port) {
managedConnections.add(`${child.opts.domain}:${child.opts.port}`);
}
}
const p2pDomainEntries = [];
const hashPromises = [];
for (const [key, holesail] of state.holesails.entries()) {
try {
if (managedConnections.has(key)) {
continue;
}
const keyParts = key.split(':');
if (keyParts.length !== 2) {
logDebug('Admin', `Skipping invalid holesail key format: ${key}`);
continue;
}
const domain = keyParts[0];
const port = parseInt(keyParts[1], 10);
if (isNaN(port)) {
logDebug('Admin', `Skipping holesail key with invalid port: ${key}`);
continue;
}
const ip = state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain];
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
const status = holesail && typeof holesail === 'object' ? 'running' : 'stopped';
let holesailInfo = null;
try {
if (holesail && typeof holesail === 'object' && holesail.info) {
holesailInfo = holesail.info;
}
} catch (e) {
// Info not available
}
p2pDomainEntries.push({
key,
domain,
port,
ip,
isPersistent,
status,
holesailInfo
});
hashPromises.push(
getHashForDomain(domain).catch(err => {
logDebug('Admin', `Could not get hash for domain ${domain}: ${err.message}`);
return null;
})
);
} catch (err) {
logError('Admin', `Error processing p2p domain connection ${key}: ${err.message}`);
}
}
const hashResults = await Promise.all(hashPromises);
let mainProcessStats = null;
try {
mainProcessStats = await pidusage(process.pid);
} catch (err) {
logDebug('Admin', `Failed to get main process stats for p2p connections: ${err.message}`);
}
const p2pConnectionCount = p2pDomainEntries.length || 1;
let perConnectionCpu = null;
let perConnectionMemory = null;
if (mainProcessStats) {
perConnectionCpu = {
user: mainProcessStats.cpu / (p2pConnectionCount * 2),
system: mainProcessStats.cpu / (p2pConnectionCount * 2),
percentage: mainProcessStats.cpu / p2pConnectionCount
};
perConnectionMemory = {
rss: Math.floor(mainProcessStats.memory / p2pConnectionCount),
heapUsed: Math.floor((mainProcessStats.memory * 0.8) / p2pConnectionCount),
heapTotal: Math.floor(mainProcessStats.memory / p2pConnectionCount),
external: 0,
arrayBuffers: 0
};
}
for (let i = 0; i < p2pDomainEntries.length; i++) {
try {
const entry = p2pDomainEntries[i];
const hash = hashResults[i];
const startTime = state.holesailStartTimes && state.holesailStartTimes.get(entry.key);
const uptime = startTime ? Date.now() - startTime : 0;
let timeRemaining = null;
if (!entry.isPersistent && process.env.FULL_PERSISTENCE !== 'true' && startTime) {
const timeoutDuration = parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5');
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, timeoutDuration - elapsed);
timeRemaining = remaining;
}
holesailChildren.push({
id: entry.key,
type: 'p2p-domain',
status: entry.status,
pid: process.pid,
uptime: uptime,
timeRemaining: timeRemaining,
cpuUsage: perConnectionCpu,
memoryUsage: perConnectionMemory,
opts: {
domain: entry.domain,
port: entry.port,
host: entry.ip || null,
protocol: 'tcp'
},
persistent: entry.isPersistent,
hash: hash || null,
info: entry.holesailInfo || null
});
} catch (err) {
logError('Admin', `Error creating stats entry for p2p domain connection ${p2pDomainEntries[i].key}: ${err.message}`);
}
}
stats.holesailChildren = holesailChildren;
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/stats', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(stats));
} catch (err) {
logError('Admin', `Error in /api/stats: ${err.message}`, err);
trackRequest('/api/stats', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
if (method === 'GET' && urlPath === '/api/stats/historical') {
try {
let minutes = 60;
if (req.url.includes('?')) {
const queryString = req.url.split('?')[1];
const params = new URLSearchParams(queryString);
const minutesParam = params.get('minutes');
if (minutesParam) {
minutes = parseInt(minutesParam, 10);
if (isNaN(minutes) || minutes < 1) minutes = 60;
if (minutes > 1440) minutes = 1440;
}
}
const startTime = Date.now();
const historical = getHistoricalData(minutes);
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/stats/historical', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(historical));
} catch (err) {
logError('Admin', `Error in /api/stats/historical: ${err.message}`, err);
trackRequest('/api/stats/historical', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handleStatsRoutes };
+123
View File
@@ -0,0 +1,123 @@
const url = require('url');
const state = require('../../infrastructure/state');
const { trackRequest } = require('../../maintenance/metrics');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { metrics } = require('../../maintenance/metrics');
async function handleStatusRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/status') {
try {
const status = {
isMaster: state.isMaster,
isConnected: !!state.dnsPass,
peersCount: state.connectedPeers.size
};
trackRequest('/api/status', true);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
} catch (err) {
trackRequest('/api/status', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
if (method === 'GET' && urlPath === '/api/health') {
try {
const query = url.parse(req.url, true).query;
const probeType = query.probe || 'liveness';
const dnsHealthy = !!state.dnsPass && state.dnsPass.ready;
const proxyHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
const swarmHealthy = state.connectedPeers !== undefined;
const corestoreHealthy = state.dnsPass && state.dnsPass.base && state.dnsPass.base.writable !== undefined;
const hyperswarmHealthy = swarmHealthy;
const dnsServerHealthy = process.env.DISABLE_DNS_SERVER !== 'true';
const httpsServerHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
const allServicesHealthy = dnsHealthy && proxyHealthy && swarmHealthy && corestoreHealthy && hyperswarmHealthy;
const status = allServicesHealthy ? 'healthy' : 'degraded';
const health = {
status: status,
timestamp: new Date().toISOString(),
uptime: Date.now() - (metrics?.startTime || Date.now()),
probe: probeType,
services: {
dns: {
enabled: dnsServerHealthy,
healthy: dnsHealthy,
initialized: !!state.dnsPass,
details: {
passReady: !!state.dnsPass?.ready,
domainsCount: state.domainToIPMap?.size || 0
}
},
proxy: {
enabled: httpsServerHealthy,
healthy: proxyHealthy,
details: {
httpsEnabled: process.env.DISABLE_PROXY_SERVER !== 'true',
httpEnabled: process.env.DISABLE_PROXY_SERVER !== 'true'
}
},
swarm: {
healthy: swarmHealthy,
details: {
connectedPeers: state.connectedPeers?.size || 0,
isMaster: state.isMaster || false
}
}
},
dependencies: {
corestore: {
healthy: corestoreHealthy,
details: {
initialized: !!state.dnsPass,
writable: state.dnsPass?.base?.writable || false
}
},
hyperswarm: {
healthy: hyperswarmHealthy,
details: {
connectedPeers: state.connectedPeers?.size || 0
}
}
}
};
if (probeType === 'readiness') {
const ready = allServicesHealthy && state.dnsPass && state.dnsPass.ready;
if (!ready) {
trackRequest('/api/health', false);
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ...health, status: 'not_ready' }));
return true;
}
}
const statusCode = allServicesHealthy ? 200 : 503;
trackRequest('/api/health', allServicesHealthy);
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(health));
} catch (err) {
trackRequest('/api/health', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handleStatusRoutes };