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
@@ -0,0 +1,276 @@
const { createBackup, listBackups, restoreBackup, cleanupOldBackups } = require('../../../maintenance/backup');
const { logError } = require('../../../infrastructure/logger');
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
const fs = require('fs').promises;
const path = require('path');
async function handleBackupsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
// GET /api/backups - List all backups
if (method === 'GET' && urlPath === '/api/backups') {
try {
const startTime = Date.now();
const backups = await listBackups();
// Add formatted size to each backup (size is already calculated in listBackups for tar.gz)
const backupsWithSize = backups.map((backup) => {
return {
...backup,
size: backup.size || 0,
sizeFormatted: formatBytes(backup.size || 0)
};
});
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/backups', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(backupsWithSize));
} catch (err) {
logError('Admin', `Failed to list backups: ${err.message}`);
trackRequest('/api/backups', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/backups/create - Create manual backup
if (method === 'POST' && urlPath === '/api/backups/create') {
try {
const startTime = Date.now();
// Cleanup before creating backup
await cleanupOldBackups();
const backupPath = await createBackup();
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/backups/create', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, path: backupPath }));
} catch (err) {
logError('Admin', `Failed to create backup: ${err.message}`);
trackRequest('/api/backups/create', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/backups/restore - Restore from backup
if (method === 'POST' && urlPath === '/api/backups/restore') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { backupName } = data;
if (!backupName) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'backupName is required' }));
trackRequest('/api/backups/restore', false);
return true;
}
const startTime = Date.now();
await restoreBackup(backupName);
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/backups/restore', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Backup restored successfully' }));
} catch (err) {
logError('Admin', `Failed to restore backup: ${err.message}`);
trackRequest('/api/backups/restore', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// DELETE /api/backups/:id - Delete backup
if (method === 'DELETE' && urlPath.startsWith('/api/backups/')) {
try {
const backupName = urlPath.split('/api/backups/')[1];
if (!backupName) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Backup name is required' }));
trackRequest(urlPath, false);
return true;
}
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
const backupPath = path.join(BACKUP_DIR, backupName);
// Verify it's a backup (directory or tar.gz)
if (!backupName.startsWith('backup-')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid backup name' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
// Check if it's a file (tar.gz) or directory
const stats = await fs.stat(backupPath).catch(() => null);
if (stats) {
if (stats.isFile()) {
// Delete tar.gz file
await fs.unlink(backupPath);
} else {
// Delete directory
await fs.rm(backupPath, { recursive: true, force: true });
}
}
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Backup deleted successfully' }));
} catch (err) {
logError('Admin', `Failed to delete backup: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/backups/:id/metadata - Get backup metadata
if (method === 'GET' && urlPath.startsWith('/api/backups/') && urlPath.endsWith('/metadata')) {
try {
const backupName = urlPath.split('/api/backups/')[1].replace('/metadata', '');
if (!backupName) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Backup name is required' }));
trackRequest(urlPath, false);
return true;
}
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
const backupPath = path.join(BACKUP_DIR, backupName);
const startTime = Date.now();
let metadata, restoreSource, extractDir = null;
try {
// Check if it's a tar.gz file
const stats = await fs.stat(backupPath);
const isTarGz = backupName.endsWith('.tar.gz') && stats.isFile();
if (isTarGz) {
// Extract tar.gz to read metadata
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
extractDir = path.join(BACKUP_DIR, `extract-metadata-${Date.now()}`);
await fs.mkdir(extractDir, { recursive: true });
try {
await execAsync(`tar -xzf "${backupPath}" -C "${extractDir}"`);
const entries = await fs.readdir(extractDir, { withFileTypes: true });
if (entries.length === 1 && entries[0].isDirectory()) {
restoreSource = path.join(extractDir, entries[0].name);
} else {
restoreSource = extractDir;
}
const metadataPath = path.join(restoreSource, 'metadata.json');
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
} finally {
// Clean up extraction
if (extractDir) {
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
}
}
} else {
// Directory format
restoreSource = backupPath;
const metadataPath = path.join(backupPath, 'metadata.json');
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
}
// Get file sizes (only for directory format, tar.gz files are already compressed)
const filesWithSize = await Promise.all(
(metadata.files || []).map(async (fileName) => {
if (isTarGz) {
// For tar.gz, we can't easily get individual file sizes without extracting
return {
name: fileName,
size: 0,
sizeFormatted: 'N/A (compressed)',
modified: null
};
} else {
const filePath = path.join(restoreSource, 'cache', fileName);
const altPath = path.join(restoreSource, fileName);
try {
let stats;
try {
stats = await fs.stat(filePath);
} catch {
stats = await fs.stat(altPath);
}
return {
name: fileName,
size: stats.size,
sizeFormatted: formatBytes(stats.size),
modified: stats.mtime.toISOString()
};
} catch (err) {
return {
name: fileName,
size: 0,
sizeFormatted: '0 B',
modified: null
};
}
}
})
);
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...metadata,
files: filesWithSize
}));
} catch (readErr) {
if (extractDir) {
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
}
throw readErr;
}
} catch (err) {
if (err.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Backup not found' }));
} else {
logError('Admin', `Failed to get backup metadata: ${err.message}`);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
trackRequest(urlPath, false);
}
return true;
}
return false;
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
module.exports = { handleBackupsRoutes };
@@ -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 };
@@ -0,0 +1,117 @@
const state = require('../../../infrastructure/state');
const { getConsensusState, getConsensusMetrics, doAutoVotes, invalidateEntriesCache } = require('../../../core/core');
const { logDebug, logError, logInfo } = require('../../../infrastructure/logger');
const { trackRequest } = require('../../../maintenance/metrics');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
const { broadcast } = require('../websocket');
async function handleConsensusRoutes(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}`);
// GET /api/consensus/metrics - Get consensus metrics
// Check this BEFORE the domain route to avoid matching "metrics" as a domain
if (method === 'GET' && urlPath === '/api/consensus/metrics') {
try {
trackRequest('/api/consensus/metrics', true);
logDebug('Consensus', 'Getting consensus metrics');
const metrics = getConsensusMetrics();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(metrics));
return true;
} catch (err) {
logError('Consensus', `Failed to get consensus metrics: ${err.message}`);
trackRequest('/api/consensus/metrics', false);
createErrorResponse(res, 500, 'Failed to get consensus metrics', err.message);
return true;
}
}
// GET /api/consensus/:domain - Get consensus state for a domain
const domainMatch = urlPath.match(/^\/api\/consensus\/([^\/]+)$/);
if (method === 'GET' && domainMatch) {
try {
trackRequest('/api/consensus/:domain', true);
const domain = decodeURIComponent(domainMatch[1]);
logDebug('Consensus', `Getting consensus state for domain: ${domain}`);
const consensusState = await getConsensusState(domain);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(consensusState));
return true;
} catch (err) {
logError('Consensus', `Failed to get consensus state: ${err.message}`);
trackRequest('/api/consensus/:domain', false);
createErrorResponse(res, 500, 'Failed to get consensus state', err.message);
return true;
}
}
// POST /api/consensus/recalculate - Force consensus recalculation
if (method === 'POST' && urlPath === '/api/consensus/recalculate') {
try {
trackRequest('/api/consensus/recalculate', true);
logInfo('Consensus', 'Forcing consensus recalculation');
// Invalidate caches
invalidateEntriesCache();
// Trigger auto-votes
await doAutoVotes();
// Broadcast update
broadcast({ type: 'update-database' });
broadcast({ type: 'update-stats' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Consensus recalculation triggered' }));
return true;
} catch (err) {
logError('Consensus', `Failed to recalculate consensus: ${err.message}`);
trackRequest('/api/consensus/recalculate', false);
createErrorResponse(res, 500, 'Failed to recalculate consensus', err.message);
return true;
}
}
// POST /api/consensus/recalculate/:domain - Force consensus recalculation for specific domain
const recalcDomainMatch = urlPath.match(/^\/api\/consensus\/recalculate\/([^\/]+)$/);
if (method === 'POST' && recalcDomainMatch) {
try {
trackRequest('/api/consensus/recalculate/:domain', true);
const domain = decodeURIComponent(recalcDomainMatch[1]);
logInfo('Consensus', `Forcing consensus recalculation for domain: ${domain}`);
// Invalidate caches for this domain
invalidateEntriesCache();
// Get all entries and trigger auto-vote for this domain
const { getAllEntries, autoVoteForDomain } = require('../../../core/core');
const allEntries = await getAllEntries();
await autoVoteForDomain(domain, allEntries);
// Broadcast update
broadcast({ type: 'update-database' });
broadcast({ type: 'update-stats' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: `Consensus recalculation triggered for ${domain}` }));
return true;
} catch (err) {
logError('Consensus', `Failed to recalculate consensus for domain: ${err.message}`);
trackRequest('/api/consensus/recalculate/:domain', false);
createErrorResponse(res, 500, 'Failed to recalculate consensus for domain', err.message);
return true;
}
}
// Route not handled by consensus routes
return false;
}
module.exports = { handleConsensusRoutes };
@@ -0,0 +1,509 @@
const dns = require('dns').promises;
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
const net = require('net');
const os = require('os');
const { logError, logDebug } = require('../../../infrastructure/logger');
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
const execAsync = promisify(exec);
async function handleDiagnosticsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
// POST /api/diagnostics/dns-lookup
if (method === 'POST' && urlPath === '/api/diagnostics/dns-lookup') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { domain, type = 'A' } = data;
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'domain is required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
let results = [];
try {
switch (type.toUpperCase()) {
case 'A':
results = await dns.resolve4(domain);
break;
case 'AAAA':
results = await dns.resolve6(domain);
break;
case 'MX':
results = await dns.resolveMx(domain);
break;
case 'TXT':
results = await dns.resolveTxt(domain);
break;
case 'NS':
results = await dns.resolveNs(domain);
break;
case 'CNAME':
results = await dns.resolveCname(domain);
break;
case 'SRV':
results = await dns.resolveSrv(domain);
break;
case 'PTR':
results = await dns.resolvePtr(domain);
break;
case 'SOA':
results = await dns.resolveSoa(domain);
break;
default:
throw new Error(`Unsupported DNS record type: ${type}`);
}
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
domain,
type,
results: Array.isArray(results) ? results : [results],
responseTime
}));
} catch (dnsErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
domain,
type,
error: dnsErr.message,
results: [],
responseTime
}));
}
} catch (err) {
logError('Admin', `DNS lookup failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/ping
if (method === 'POST' && urlPath === '/api/diagnostics/ping') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { target, count = 4, stream = false } = data;
if (!target) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
trackRequest(urlPath, false);
return true;
}
// Streaming mode
if (stream) {
const startTime = Date.now();
const platform = os.platform();
const pingArgs = platform === 'win32'
? ['-n', count.toString(), target]
: ['-c', count.toString(), target];
const pingProcess = spawn('ping', pingArgs);
// Set up streaming response
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let output = '';
let errorOutput = '';
pingProcess.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Send each line as it arrives
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
res.write(JSON.stringify({
type: 'output',
data: line,
timestamp: Date.now()
}) + '\n');
}
});
pingProcess.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
res.write(JSON.stringify({
type: 'error',
data: text,
timestamp: Date.now()
}) + '\n');
});
pingProcess.on('close', (code) => {
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.write(JSON.stringify({
type: 'complete',
success,
exitCode: code,
output,
error: errorOutput || null,
responseTime
}) + '\n');
res.end();
});
pingProcess.on('error', (err) => {
res.write(JSON.stringify({
type: 'error',
error: err.message,
timestamp: Date.now()
}) + '\n');
res.end();
trackRequest(urlPath, false);
});
return true;
}
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const pingCmd = platform === 'win32'
? `ping -n ${count} ${target}`
: `ping -c ${count} ${target}`;
try {
const { stdout, stderr } = await execAsync(pingCmd, { timeout: 30000 });
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
target,
count,
output: stdout,
error: stderr || null,
responseTime
}));
} catch (execErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target,
count,
output: execErr.stdout || '',
error: execErr.stderr || execErr.message,
responseTime
}));
}
} catch (err) {
logError('Admin', `Ping failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/traceroute
if (method === 'POST' && urlPath === '/api/diagnostics/traceroute') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { target, stream = false } = data;
if (!target) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
trackRequest(urlPath, false);
return true;
}
// Streaming mode
if (stream) {
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
const tracerouteArgs = platform === 'win32' ? [target] : [target];
const tracerouteProcess = spawn(tracerouteCmd, tracerouteArgs);
// Set up streaming response
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let output = '';
let errorOutput = '';
tracerouteProcess.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Send each line as it arrives
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
res.write(JSON.stringify({
type: 'output',
data: line,
timestamp: Date.now()
}) + '\n');
}
});
tracerouteProcess.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
res.write(JSON.stringify({
type: 'error',
data: text,
timestamp: Date.now()
}) + '\n');
});
tracerouteProcess.on('close', (code) => {
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.write(JSON.stringify({
type: 'complete',
success,
exitCode: code,
output,
error: errorOutput || null,
responseTime
}) + '\n');
res.end();
});
tracerouteProcess.on('error', (err) => {
res.write(JSON.stringify({
type: 'error',
error: err.message,
timestamp: Date.now()
}) + '\n');
res.end();
trackRequest(urlPath, false);
});
return true;
}
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32'
? `tracert ${target}`
: `traceroute ${target}`;
try {
const { stdout, stderr } = await execAsync(tracerouteCmd, { timeout: 60000 });
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
target,
output: stdout,
error: stderr || null,
responseTime
}));
} catch (execErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target,
output: execErr.stdout || '',
error: execErr.stderr || execErr.message,
responseTime
}));
}
} catch (err) {
logError('Admin', `Traceroute failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/connection-test
if (method === 'POST' && urlPath === '/api/diagnostics/connection-test') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { domain, port } = data;
if (!domain || !port) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'domain and port are required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
// First resolve domain to IP
let ip;
try {
const addresses = await dns.resolve4(domain);
ip = addresses[0];
} catch (dnsErr) {
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
domain,
port,
error: `DNS resolution failed: ${dnsErr.message}`,
responseTime: Date.now() - startTime
}));
return true;
}
// Test TCP connection
const testConnection = () => {
return new Promise((resolve) => {
const socket = new net.Socket();
const timeout = 5000;
let connected = false;
socket.setTimeout(timeout);
socket.on('connect', () => {
connected = true;
socket.destroy();
resolve({ success: true, latency: Date.now() - startTime });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ success: false, error: 'Connection timeout' });
});
socket.on('error', (err) => {
resolve({ success: false, error: err.message });
});
socket.connect(port, ip);
});
};
const result = await testConnection();
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, result.success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...result,
domain,
ip,
port,
responseTime
}));
} catch (err) {
logError('Admin', `Connection test failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/diagnostics/bandwidth
if (method === 'GET' && urlPath === '/api/diagnostics/bandwidth') {
try {
const startTime = Date.now();
const networkInterfaces = os.networkInterfaces();
const stats = {};
for (const [name, addresses] of Object.entries(networkInterfaces)) {
if (!addresses) continue;
let totalBytes = 0;
let totalPackets = 0;
for (const addr of addresses) {
if (addr.family === 'IPv4' || addr.family === 'IPv6') {
// Note: Node.js doesn't provide real-time bandwidth stats
// This is a placeholder structure
stats[name] = {
name,
addresses: addresses.map(a => ({
address: a.address,
netmask: a.netmask,
family: a.family,
mac: a.mac || 'N/A',
internal: a.internal
})),
// These would need system-specific tools to get real values
bytesReceived: 0,
bytesSent: 0,
packetsReceived: 0,
packetsSent: 0
};
}
}
}
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
interfaces: stats,
note: 'Bandwidth statistics require system-specific tools. Interface information only.',
responseTime
}));
} catch (err) {
logError('Admin', `Bandwidth stats failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handleDiagnosticsRoutes };
@@ -0,0 +1,267 @@
const fs = require('fs').promises;
const state = require('../../../infrastructure/state');
const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState, invalidateEntriesCache } = 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, logInfo } = 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;
// Check if local writer is the resolved claimant (owner)
let isOwner = false;
try {
const consensusState = await getConsensusState(domain);
isOwner = localWriter ? consensusState.resolvedClaimant === localWriter : false;
} catch (err) {
logDebug('Admin', `Error checking ownership for ${domain}: ${err.message}`);
}
resolved.push({ domain, hash, isLocal, isOwner });
}
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, isOwner: 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;
}
// POST /api/reset-claims-and-entries - Reset all claims and entries, request peers to provide domains
if (method === 'POST' && urlPath === '/api/reset-claims-and-entries') {
try {
trackRequest('/api/reset-claims-and-entries', true);
logInfo('Admin', 'Resetting all claims and entries, requesting peers to provide domains');
const pass = state.dnsPass;
if (!pass) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'DNS pass not initialized' }));
return true;
}
// Get all entries
const allEntries = await getAllEntries(pass, false); // Don't use cache
// Filter for claims and votes
const entriesToRemove = allEntries.filter(entry =>
entry.key.startsWith('claim:') || entry.key.startsWith('vote:')
);
logInfo('Admin', `Removing ${entriesToRemove.length} claim and vote entries`);
// Remove all claim and vote entries
let removedCount = 0;
let errorCount = 0;
for (const entry of entriesToRemove) {
try {
await pass.remove(entry.key);
removedCount++;
} catch (err) {
logError('Admin', `Error removing entry ${entry.key}: ${err.message}`);
errorCount++;
}
}
// Invalidate cache
invalidateEntriesCache();
// Send message to all peers requesting them to provide their domains
let peersNotified = 0;
if (state.peerChannels && state.peerChannels.size > 0) {
for (const channels of state.peerChannels.values()) {
try {
if (channels.requestMessage && channels.requestChannel && !channels.requestChannel.destroyed) {
channels.requestMessage.send('request_domains');
peersNotified++;
}
} catch (err) {
logError('Admin', `Error sending request_domains message to peer: ${err.message}`);
}
}
logInfo('Admin', `Sent request_domains message to ${peersNotified} peer(s)`);
}
// Broadcast updates
broadcast({ type: 'update-database' });
broadcast({ type: 'update-stats' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Claims and entries reset successfully',
removed: removedCount,
errors: errorCount,
peersNotified: peersNotified
}));
return true;
} catch (err) {
logError('Admin', `Failed to reset claims and entries: ${err.message}`);
trackRequest('/api/reset-claims-and-entries', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
return true;
}
}
return false;
}
module.exports = { handleDomainsRoutes };
@@ -0,0 +1,49 @@
const { getAllEntries, removeAllRecords } = require('../../../core/core');
const { logError, logInfo } = require('../../../infrastructure/logger');
const { trackRequest } = require('../../../maintenance/metrics');
const { broadcast } = require('../websocket');
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 };
@@ -0,0 +1,824 @@
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 };
@@ -0,0 +1,62 @@
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');
const { handleBackupsRoutes } = require('./backups');
const { handleDiagnosticsRoutes } = require('./diagnostics');
const { handleConsensusRoutes } = require('./consensus');
const { handlePluginsRoutes } = require('./plugins');
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;
if (await handleBackupsRoutes(req, res)) return;
if (await handleDiagnosticsRoutes(req, res)) return;
if (await handleConsensusRoutes(req, res)) return;
if (await handlePluginsRoutes(req, res)) return;
// No route matched
res.writeHead(404);
res.end('Not Found');
}
module.exports = { handleAdminRequest };
@@ -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 };
@@ -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 };
@@ -0,0 +1,215 @@
const state = require('../../../infrastructure/state');
const { logError } = require('../../../infrastructure/logger');
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
const { saveBlockedPeers } = require('../cache');
async function handlePeersRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
// GET /api/peers - List all peers with details
if (method === 'GET' && urlPath === '/api/peers') {
try {
const startTime = Date.now();
const peers = Array.from(state.connectedPeers).map(peerId => {
const connectTime = state.peerStartTimes.get(peerId);
const uptime = connectTime ? Date.now() - connectTime : 0;
const metrics = state.peerMetrics.get(peerId) || {
connections: 0,
totalDuration: 0,
avgDuration: 0,
lastSeen: null
};
const isBlocked = state.blockedPeers && state.blockedPeers.has(peerId);
return {
id: peerId,
connected: true,
connectTime: connectTime || null,
uptime,
metrics,
isBlocked
};
});
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/peers', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(peers));
} catch (err) {
logError('Admin', `Failed to fetch peers: ${err.message}`);
trackRequest('/api/peers', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/peers/:id - Get peer details
if (method === 'GET' && urlPath.startsWith('/api/peers/') && !urlPath.endsWith('/history') && !urlPath.endsWith('/blocked')) {
try {
const peerId = urlPath.split('/api/peers/')[1];
if (!peerId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Peer ID is required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
const connectTime = state.peerStartTimes.get(peerId);
const uptime = connectTime ? Date.now() - connectTime : 0;
const history = state.peerHistory.get(peerId) || [];
const metrics = state.peerMetrics.get(peerId) || {
connections: 0,
totalDuration: 0,
avgDuration: 0,
lastSeen: null
};
const isBlocked = state.blockedPeers && state.blockedPeers.has(peerId);
const isConnected = state.connectedPeers.has(peerId);
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: peerId,
connected: isConnected,
connectTime: connectTime || null,
uptime,
history: history.slice(-50), // Last 50 events
metrics,
isBlocked
}));
} catch (err) {
logError('Admin', `Failed to fetch peer details: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/peers/:id/history - Get peer connection history
if (method === 'GET' && urlPath.endsWith('/history')) {
try {
const peerId = urlPath.split('/api/peers/')[1].replace('/history', '');
if (!peerId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Peer ID is required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
const history = (state.peerHistory.get(peerId) || []).slice(-50);
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(history));
} catch (err) {
logError('Admin', `Failed to fetch peer history: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/peers/:id/block - Block peer
if (method === 'POST' && urlPath.endsWith('/block')) {
try {
const peerId = urlPath.split('/api/peers/')[1].replace('/block', '');
if (!peerId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Peer ID is required' }));
trackRequest(urlPath, false);
return true;
}
if (!state.blockedPeers) {
state.blockedPeers = new Set();
}
state.blockedPeers.add(peerId);
// Save blocked peers to disk
await saveBlockedPeers();
// Disconnect if currently connected
if (state.connectedPeers.has(peerId)) {
// Find and close the connection
// Note: This is a simplified approach - in practice you'd need to track connections
logError('Admin', `Peer ${peerId} is currently connected. Blocking will take effect on next connection attempt.`);
}
trackRequest(urlPath, true);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: `Peer ${peerId} blocked` }));
} catch (err) {
logError('Admin', `Failed to block peer: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/peers/:id/unblock - Unblock peer
if (method === 'POST' && urlPath.endsWith('/unblock')) {
try {
const peerId = urlPath.split('/api/peers/')[1].replace('/unblock', '');
if (!peerId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Peer ID is required' }));
trackRequest(urlPath, false);
return true;
}
if (state.blockedPeers) {
state.blockedPeers.delete(peerId);
}
// Save blocked peers to disk
await saveBlockedPeers();
trackRequest(urlPath, true);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: `Peer ${peerId} unblocked` }));
} catch (err) {
logError('Admin', `Failed to unblock peer: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/peers/blocked - List blocked peers
if (method === 'GET' && urlPath === '/api/peers/blocked') {
try {
const blocked = state.blockedPeers ? Array.from(state.blockedPeers) : [];
trackRequest(urlPath, true);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(blocked));
} catch (err) {
logError('Admin', `Failed to fetch blocked peers: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handlePeersRoutes };
@@ -0,0 +1,650 @@
const { getAllPluginDomains, getPlugin, getAllPluginRegistrations, reloadPlugin, stopPlugin, startPlugin } = require('../../../plugins/plugin-handler');
const { logError, logInfo, logDebug } = require('../../../infrastructure/logger');
const { broadcast } = require('../websocket');
const fs = require('fs').promises;
const path = require('path');
async function handlePluginsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
/**
* Get plugin settings file path
* @param {string} domain - Plugin domain
* @returns {string} Settings file path
*/
function getPluginSettingsPath(domain) {
const settingsDir = path.join(process.cwd(), 'cache', 'plugin-settings');
return path.join(settingsDir, `${domain}.json`);
}
/**
* Load plugin settings from file
* @param {string} domain - Plugin domain
* @returns {Promise<Object>} Plugin settings
*/
async function loadPluginSettings(domain) {
try {
const settingsPath = getPluginSettingsPath(domain);
const data = await fs.readFile(settingsPath, 'utf8');
return JSON.parse(data);
} catch (err) {
if (err.code === 'ENOENT') {
// Settings file doesn't exist yet, return empty object
return {};
}
logError('PluginsRoute', `Error loading settings for ${domain}: ${err.message}`);
return {};
}
}
/**
* Save plugin settings to file
* @param {string} domain - Plugin domain
* @param {Object} settings - Settings to save
* @returns {Promise<void>}
*/
async function savePluginSettings(domain, settings) {
try {
const settingsPath = getPluginSettingsPath(domain);
const settingsDir = path.dirname(settingsPath);
// Ensure directory exists
await fs.mkdir(settingsDir, { recursive: true });
// Save settings to file
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
logDebug('PluginsRoute', `Saved settings for plugin ${domain}`);
} catch (err) {
logError('PluginsRoute', `Error saving settings for ${domain}: ${err.message}`);
throw err;
}
}
// GET /api/plugins - List all plugins with their info
if (method === 'GET' && urlPath === '/api/plugins') {
try {
// Get all plugin domains from disk (including stopped ones)
const pluginHandler = require('../../../plugins/plugin-handler');
// We need to get all domains from disk, not just loaded ones
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const allDomains = new Set(['p2ns.admin']);
try {
const entries = await fs.readdir(pluginSitesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const domain = entry.name;
const pluginDir = path.join(pluginSitesDir, domain);
const configPath = path.join(pluginDir, 'config.json');
try {
await fs.access(configPath);
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
if (config && config.name && config.version) {
allDomains.add(domain);
}
} catch (err) {
// No valid config
}
}
}
} catch (err) {
// Directory might not exist
}
const pluginDomains = Array.from(allDomains);
const loadedDomains = getAllPluginDomains();
const registrations = getAllPluginRegistrations();
const plugins = await Promise.all(pluginDomains.map(async (domain) => {
const plugin = getPlugin(domain);
// Plugin might be stopped, check if it exists in plugin-sites
const isLoaded = loadedDomains.includes(domain);
if (!plugin) {
// Check if plugin directory exists to show stopped plugin
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
try {
await fs.access(pluginDir);
// Plugin exists but is stopped - load config to show basic info
const configPath = path.join(pluginDir, 'config.json');
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
// Config might not exist
}
return {
domain,
name: config?.name || domain,
version: config?.version || '1.0.0',
description: config?.description || '',
author: config?.author || '',
homepage: config?.homepage || '',
license: config?.license || '',
enabled: config?.enabled !== false, // Default to true if not specified
status: 'stopped',
hasHandler: false,
hasWww: false,
hasDatabase: false,
actions: [],
settings: {}
};
} catch (err) {
return null; // Plugin directory doesn't exist
}
}
// Load saved settings
const savedSettings = await loadPluginSettings(domain);
// Merge saved settings with registered settings (saved values override defaults)
const registeredSettings = registrations[domain]?.settings || {};
const mergedSettings = {};
for (const [key, config] of Object.entries(registeredSettings)) {
mergedSettings[key] = {
...config,
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
};
}
return {
domain,
name: plugin.config?.name || domain,
version: plugin.config?.version || '1.0.0',
description: plugin.config?.description || '',
author: plugin.config?.author || '',
homepage: plugin.config?.homepage || '',
license: plugin.config?.license || '',
icon: plugin.config?.icon || null,
enabled: plugin.config?.enabled !== false, // Default to true if not specified
status: plugin.handler ? 'loaded' : 'static',
hasHandler: !!plugin.handler,
hasWww: !!plugin.wwwDir,
hasDatabase: !!plugin.db,
actions: registrations[domain]?.actions || [],
settings: mergedSettings
};
}));
const filteredPlugins = plugins.filter(Boolean);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ plugins: filteredPlugins }));
} catch (err) {
logError('PluginsRoute', `Error listing plugins: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to list plugins' }));
}
return true;
}
// GET /api/plugins/:domain - Get specific plugin info
if (method === 'GET' && urlPath.startsWith('/api/plugins/') && !urlPath.includes('/actions/') && !urlPath.includes('/settings') && !urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
const plugin = getPlugin(domain);
if (!plugin) {
// Check if plugin exists but is stopped
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
try {
await fs.access(pluginDir);
const configPath = path.join(pluginDir, 'config.json');
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
// Config might not exist
}
const pluginInfo = {
domain,
name: config?.name || domain,
version: config?.version || '1.0.0',
description: config?.description || '',
author: config?.author || '',
homepage: config?.homepage || '',
license: config?.license || '',
status: 'stopped',
hasHandler: false,
hasWww: false,
hasDatabase: false,
pluginDir,
actions: [],
settings: {}
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(pluginInfo));
return true;
} catch (err) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin not found' }));
return true;
}
}
const registrations = getAllPluginRegistrations();
// Load saved settings
const savedSettings = await loadPluginSettings(domain);
// Merge saved settings with registered settings
const registeredSettings = registrations[domain]?.settings || {};
const mergedSettings = {};
for (const [key, config] of Object.entries(registeredSettings)) {
mergedSettings[key] = {
...config,
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
};
}
const pluginInfo = {
domain,
name: plugin.config?.name || domain,
version: plugin.config?.version || '1.0.0',
description: plugin.config?.description || '',
author: plugin.config?.author || '',
homepage: plugin.config?.homepage || '',
license: plugin.config?.license || '',
status: plugin.handler ? 'loaded' : 'static',
hasHandler: !!plugin.handler,
hasWww: !!plugin.wwwDir,
hasDatabase: !!plugin.db,
pluginDir: plugin.pluginDir,
actions: registrations[domain]?.actions || [],
settings: mergedSettings
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(pluginInfo));
} catch (err) {
logError('PluginsRoute', `Error getting plugin info: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to get plugin info' }));
}
return true;
}
// POST /api/plugins/:domain/reload - Reload a plugin
if (method === 'POST' && urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/reload')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
logInfo('PluginsRoute', `Reloading plugin ${domain} via API`);
const reloadedPlugin = await reloadPlugin(domain);
if (!reloadedPlugin) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to reload plugin' }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} reloaded successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error reloading plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to reload plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/stop - Stop a plugin
if (method === 'POST' && urlPath.includes('/stop')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/stop')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Check if this is a system plugin that cannot be stopped
const SYSTEM_PLUGINS = ['global.profile'];
if (SYSTEM_PLUGINS.includes(domain)) {
logWarn('PluginsRoute', `Cannot stop system plugin ${domain}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Cannot stop system plugin: ${domain}. This plugin is required by the system.`,
domain,
isSystemPlugin: true
}));
return true;
}
logInfo('PluginsRoute', `Stopping plugin ${domain} via API`);
try {
const success = await stopPlugin(domain);
if (!success) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to stop plugin' }));
return true;
}
} catch (err) {
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} stopped successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to stop plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/toggle - Enable/disable a plugin
if (method === 'POST' && urlPath.includes('/toggle')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/toggle')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Read request body to get enabled state
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
const data = body ? JSON.parse(body) : {};
const enabled = data.enabled !== undefined ? data.enabled : true;
// Check if this is a system plugin that cannot be disabled
const SYSTEM_PLUGINS = ['global.profile'];
if (!enabled && SYSTEM_PLUGINS.includes(domain)) {
logWarn('PluginsRoute', `Cannot disable system plugin ${domain}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Cannot disable system plugin: ${domain}. This plugin is required by the system.`,
domain,
isSystemPlugin: true
}));
return true;
}
logInfo('PluginsRoute', `${enabled ? 'Enabling' : 'Disabling'} plugin ${domain} via API`);
// Get plugin directory
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
const configPath = path.join(pluginDir, 'config.json');
// Read current config
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin config.json not found' }));
return true;
}
// Update enabled flag (system plugins are always enabled)
if (SYSTEM_PLUGINS.includes(domain)) {
config.enabled = true;
} else {
config.enabled = enabled;
}
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
logInfo('PluginsRoute', `Updated config.json for ${domain}: enabled=${enabled}`);
// Apply the enabled/disabled state by reloading, starting, or stopping the plugin
const pluginHandler = require('../../../plugins/plugin-handler');
const plugin = pluginHandler.getPlugin(domain);
if (enabled) {
// Enabling the plugin
if (plugin) {
// Plugin is currently loaded - reload it to ensure it's properly enabled
// This will read the fresh config.json with enabled=true
const reloaded = await pluginHandler.reloadPlugin(domain);
if (!reloaded) {
// Reload failed (might be disabled in config still) - try starting fresh
logInfo('PluginsRoute', `Reload failed for ${domain}, attempting fresh start`);
await pluginHandler.startPlugin(domain);
}
} else {
// Plugin is not loaded - start it (this will read config.json with enabled=true)
await pluginHandler.startPlugin(domain);
}
} else {
// Disabling the plugin
if (plugin) {
// Plugin is loaded - stop it (this will clear caches and remove from internal domains)
await pluginHandler.stopPlugin(domain);
} else {
// Plugin is not loaded - just clear caches to remove from internal domains
pluginHandler.clearInternalDomainsCache();
try {
const { invalidateEntriesCache } = require('../../../core/core');
invalidateEntriesCache();
logDebug('PluginsRoute', 'DNS cache invalidated after disabling plugin');
} catch (err) {
logDebug('PluginsRoute', `Could not invalidate DNS cache: ${err.message}`);
}
// Update proxy server certificates to remove internal domain
try {
const { updateProxyServerCertificates } = require('../../../networking/internal_domains_proxy');
await updateProxyServerCertificates();
logDebug('PluginsRoute', 'Proxy server certificates updated after disabling plugin');
} catch (err) {
logWarn('PluginsRoute', `Could not update proxy server certificates: ${err.message}`);
}
}
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} ${enabled ? 'enabled' : 'disabled'} successfully`,
domain,
enabled
}));
} catch (err) {
logError('PluginsRoute', `Error toggling plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to toggle plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/start - Start a plugin
if (method === 'POST' && urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/start')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
logInfo('PluginsRoute', `Starting plugin ${domain} via API`);
const startedPlugin = await startPlugin(domain);
if (!startedPlugin) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to start plugin' }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} started successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error starting plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to start plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/actions/:actionName - Execute a plugin action
if (method === 'POST' && urlPath.includes('/actions/')) {
try {
const match = urlPath.match(/\/api\/plugins\/([^\/]+)\/actions\/(.+)$/);
if (!match) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid action path' }));
return true;
}
const domain = match[1];
const actionName = match[2];
const plugin = getPlugin(domain);
if (!plugin) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin not found' }));
return true;
}
const registrations = getAllPluginRegistrations();
const action = registrations[domain]?.actions?.find(a => a.name === actionName);
if (!action || !action.handler) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Action not found' }));
return true;
}
// Parse request body for parameters
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
const params = body ? JSON.parse(body) : {};
// Execute the action (handler may be a proxy function for child process)
try {
const result = await action.handler(params);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
result
}));
} catch (err) {
logError('PluginsRoute', `Error executing action ${actionName} for plugin ${domain}: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Action execution failed: ${err.message}`
}));
}
} catch (err) {
logError('PluginsRoute', `Error handling action request: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to execute action' }));
}
return true;
}
// POST /api/plugins/:domain/settings - Update plugin settings
if (method === 'POST' && urlPath.includes('/settings') && !urlPath.includes('/actions/')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/settings')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Parse request body
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
const settings = body ? JSON.parse(body) : {};
// Save settings to file
await savePluginSettings(domain, settings);
// Broadcast update
broadcast({
type: 'update-plugin-settings',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Settings updated for plugin ${domain}`
}));
} catch (err) {
logError('PluginsRoute', `Error updating plugin settings: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to update settings' }));
}
return true;
}
return false;
}
module.exports = { handlePluginsRoutes };
@@ -0,0 +1,528 @@
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const state = require('../../../infrastructure/state');
const { logError, logWarn, logDebug } = 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 = {};
// Build settings and metadata - always start from settingsMetadata as source of truth
Object.keys(settingsMetadata).forEach(key => {
let value = process.env[key] || '';
// Use default value from metadata if setting is not set
if (!value && settingsMetadata[key].default) {
value = settingsMetadata[key].default;
}
settings[key] = value;
// Always create metadata from the full settingsMetadata object, then add currentValue
metadata[key] = {
...settingsMetadata[key], // This includes category, type, label, description, etc.
currentValue: value || settingsMetadata[key].default || ''
};
});
// Then, ensure all envWhitelist settings that have metadata are included
envWhitelist.forEach(key => {
// Only include settings that have metadata (skip file/directory paths that shouldn't be in UI)
if (settingsMetadata[key]) {
if (!settings.hasOwnProperty(key)) {
settings[key] = process.env[key] || '';
}
// If setting has metadata, ensure it's in metadata with full structure
if (!metadata[key]) {
metadata[key] = {
...settingsMetadata[key],
currentValue: settings[key] || settingsMetadata[key].default || ''
};
}
}
});
// Final pass: Ensure ALL settingsMetadata entries are in metadata with complete structure
Object.keys(settingsMetadata).forEach(key => {
const currentValue = metadata[key]?.currentValue || settings[key] || settingsMetadata[key].default || '';
metadata[key] = {
...settingsMetadata[key], // Complete source structure (includes category, type, label, etc.)
currentValue: currentValue // Preserve the current value
};
});
// Ensure settings exist for all metadata entries
Object.keys(settingsMetadata).forEach(key => {
if (!settings.hasOwnProperty(key)) {
settings[key] = process.env[key] || settingsMetadata[key].default || '';
}
});
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 if (key === 'PUBLIC_DNS_SERVER') {
// Validate comma-separated IP addresses
const dnsServers = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
if (dnsServers.length === 0) {
errors.push(`${meta.label}: at least one DNS server is required`);
continue;
}
const invalidServers = [];
dnsServers.forEach((server, index) => {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(server)) {
invalidServers.push(`server ${index + 1} (${server})`);
}
});
if (invalidServers.length > 0) {
errors.push(`${meta.label}: invalid IP addresses: ${invalidServers.join(', ')}`);
continue;
}
process.env[key] = value;
} 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 if (key === 'PUBLIC_DNS_SERVER') {
// Validate comma-separated IP addresses
const dnsServers = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
if (dnsServers.length === 0) {
errors.push(`PUBLIC_DNS_SERVER: at least one DNS server is required`);
continue;
}
const invalidServers = [];
dnsServers.forEach((server, index) => {
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(server)) {
invalidServers.push(`server ${index + 1} (${server})`);
}
});
if (invalidServers.length > 0) {
errors.push(`PUBLIC_DNS_SERVER: invalid IP addresses: ${invalidServers.join(', ')}`);
continue;
}
process.env[key] = value;
} 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;
}
if (method === 'GET' && urlPath === '/api/network-interfaces') {
try {
const networkInterfaces = os.networkInterfaces();
const interfaces = [];
const interfaceSet = new Set();
// Get OS-specific default interface name
const osDefault = os.platform() === 'darwin' ? 'lo0'
: os.platform() === 'linux' ? 'lo'
: os.platform() === 'win32' ? 'Loopback Pseudo-Interface 1'
: '';
// Collect all interface names
for (const [name, addresses] of Object.entries(networkInterfaces)) {
if (!addresses || addresses.length === 0) continue;
if (!interfaceSet.has(name)) {
interfaceSet.add(name);
interfaces.push({
value: name,
label: name
});
}
}
// Sort interfaces, prioritizing loopback interfaces (lo, lo0) and the OS default
interfaces.sort((a, b) => {
const aIsDefault = a.value === osDefault;
const bIsDefault = b.value === osDefault;
if (aIsDefault && !bIsDefault) return -1;
if (!aIsDefault && bIsDefault) return 1;
const aIsLoopback = a.value.startsWith('lo');
const bIsLoopback = b.value.startsWith('lo');
if (aIsLoopback && !bIsLoopback) return -1;
if (!aIsLoopback && bIsLoopback) return 1;
return a.value.localeCompare(b.value);
});
// Ensure OS default is in the list if it exists
if (osDefault && !interfaceSet.has(osDefault)) {
interfaces.unshift({
value: osDefault,
label: `${osDefault} (default)`
});
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ interfaces }));
} catch (err) {
logError('Admin', `Failed to fetch network interfaces: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/identity') {
try {
const { getPersistentPublicKey } = require('../../../infrastructure/utils');
const publicKey = getPersistentPublicKey();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
publicKey: publicKey || null,
hasIdentity: !!publicKey
}));
} catch (err) {
logError('Admin', `Failed to fetch identity: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/reset-identity') {
try {
const keypairPath = path.resolve('./cache/keypair.json');
// Check if keypair file exists
try {
await fs.access(keypairPath);
} catch (err) {
if (err.code === 'ENOENT') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Keypair file not found' }));
return true;
}
throw err;
}
// Delete the keypair file
await fs.unlink(keypairPath);
logWarn('Admin', 'Identity keypair deleted by user');
// Clear the keypair from state
state.keypair = null;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Identity reset successfully. A new keypair will be generated on next restart. Please restart the application for the changes to take effect.'
}));
} catch (err) {
logError('Admin', `Failed to reset identity: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return true;
}
// POST /api/recheck-peers - Force peer recheck/rediscovery
if (method === 'POST' && urlPath === '/api/recheck-peers') {
try {
const crypto = require('crypto');
const { logInfo } = require('../../../infrastructure/logger');
if (!state.swarm) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Swarm not initialized' }));
return true;
}
// Get the topic
const TOPIC_SEED = process.env.TOPIC_SEED || 'p2ns-dns';
const topic = crypto.createHash('sha256').update(TOPIC_SEED).digest();
// Flush the DHT to force a recheck for peers
logInfo('Admin', 'Forcing peer recheck via swarm.flush()');
await state.swarm.flush();
// Rejoin the topic to trigger new peer discovery
// Note: join() is idempotent, so calling it again is safe
state.swarm.join(topic, { server: true, client: true });
logInfo('Admin', 'Rejoined Hyperswarm topic to trigger peer discovery');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Peer recheck initiated. New peers will be discovered shortly.'
}));
} catch (err) {
logError('Admin', `Failed to recheck peers: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to recheck peers: ${err.message}` }));
}
return true;
}
return false;
}
module.exports = { handleSettingsRoutes };
@@ -0,0 +1,96 @@
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, '..', '..', 'admin-frontend', '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, '..', '..', 'admin-frontend', '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;
}
// Serve frontend JavaScript files
if (method === 'GET' && (urlPath === '/admin.js' || urlPath === '/utils.js' || urlPath === '/ws-client.js')) {
try {
const fileName = urlPath.substring(1); // Remove leading '/'
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.end(js);
} catch (err) {
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
res.writeHead(500);
res.end('Failed to load script');
}
return true;
}
// Serve UI module files
if (method === 'GET' && urlPath.startsWith('/ui/') && urlPath.endsWith('.js')) {
try {
const fileName = urlPath.substring(1); // Remove leading '/' -> 'ui/filename.js'
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.end(js);
} catch (err) {
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
res.writeHead(404);
res.end('Not Found');
}
return true;
}
if (urlPath === '/favicon.ico') {
res.writeHead(404);
res.end('Not Found');
return true;
}
return false;
}
module.exports = { handleStaticRoutes };
@@ -0,0 +1,591 @@
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');
// Import db-manager and replication-manager for HyperDB stats
let dbManager = null;
let replicationManager = null;
let driveReplicationManager = null;
let driveManager = null;
try {
dbManager = require('../../../plugins/db-manager');
} catch (e) {
logDebug('Admin', 'db-manager not available for stats');
}
try {
replicationManager = require('../../../plugins/replication-manager');
} catch (e) {
logDebug('Admin', 'replication-manager not available for stats');
}
try {
driveReplicationManager = require('../../../plugins/drive-replication-manager');
} catch (e) {
logDebug('Admin', 'drive-replication-manager not available for stats');
}
try {
driveManager = require('../../../plugins/drive-manager');
} catch (e) {
logDebug('Admin', 'drive-manager not available for stats');
}
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;
// Collect Peer Channels stats
const peerChannelsStats = collectPeerChannelsStats();
stats.peerChannels = peerChannelsStats;
// Collect HyperDB stats
const hyperdbStats = collectHyperDBStats();
stats.hyperdb = hyperdbStats;
// Collect Hyperdrive stats
const hyperdriveStats = collectHyperdriveStats();
stats.hyperdrive = hyperdriveStats;
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;
}
/**
* Collect comprehensive Peer Channels statistics
*/
function collectPeerChannelsStats() {
const stats = {
totalPlugins: 0,
totalProtocols: 0,
totalPeerConnections: 0,
openChannels: 0,
closedChannels: 0,
plugins: []
};
try {
if (!state.pluginChannels || state.pluginChannels.size === 0) {
return stats;
}
stats.totalPlugins = state.pluginChannels.size;
for (const [pluginDomain, protocolMap] of state.pluginChannels.entries()) {
const handlers = state.pluginChannelHandlers?.get(pluginDomain);
const pluginInfo = {
domain: pluginDomain,
protocols: []
};
for (const [protocol, channelInfo] of protocolMap.entries()) {
stats.totalProtocols++;
const handler = handlers?.get(protocol);
const protocolInfo = {
name: protocol,
fullName: `${pluginDomain}-${protocol}`,
encoding: handler?.encoding || 'unknown',
autoReconnect: handler?.autoReconnect !== false,
peerCount: 0,
openCount: 0,
closedCount: 0,
peers: []
};
if (channelInfo.peerChannels) {
protocolInfo.peerCount = channelInfo.peerChannels.size;
stats.totalPeerConnections += channelInfo.peerChannels.size;
for (const [peerId, peerChannel] of channelInfo.peerChannels.entries()) {
const isOpen = peerChannel.channel?.opened || false;
const isClosed = peerChannel.channel?.closed || false;
if (isOpen) {
protocolInfo.openCount++;
stats.openChannels++;
} else {
protocolInfo.closedCount++;
stats.closedChannels++;
}
const peerInfo = {
peerId: peerId.substring(0, 16) + '...',
fullPeerId: peerId,
status: isClosed ? 'closed' : (isOpen ? 'open' : 'connecting'),
localOpened: peerChannel.localOpened || false,
remoteOpened: peerChannel.remoteOpened || false,
openedAt: peerChannel.openedAt || null,
closedAt: peerChannel.closedAt || null,
lastRemoteOpen: peerChannel.lastRemoteOpen || null,
reopenAttempts: peerChannel.reopenAttempts || 0,
lastReopenAttempt: peerChannel.lastReopenAttempt || null,
connectionValid: peerChannel.conn && !peerChannel.conn.destroyed,
muxValid: !!peerChannel.mux
};
protocolInfo.peers.push(peerInfo);
}
}
pluginInfo.protocols.push(protocolInfo);
}
stats.plugins.push(pluginInfo);
}
} catch (err) {
logError('Admin', `Error collecting peer channels stats: ${err.message}`);
}
return stats;
}
/**
* Collect comprehensive HyperDB statistics
*/
function collectHyperDBStats() {
const stats = {
totalDatabases: 0,
totalStores: 0,
replicationActive: 0,
databases: []
};
try {
if (!dbManager) {
return stats;
}
// Get all plugin stores
const pluginStores = dbManager.getAllPluginStores ? dbManager.getAllPluginStores() : new Map();
stats.totalStores = pluginStores.size;
for (const [pluginDomain, store] of pluginStores.entries()) {
const dbInfo = {
pluginDomain,
pluginVersion: null,
storeReady: false,
coreKey: null,
coreKeyShort: null,
databaseOpen: false,
replicationActive: false,
writable: false,
length: 0
};
try {
// Get plugin version from config
try {
const pluginHandler = require('../../../plugins/plugin-handler');
const plugin = pluginHandler.getPlugin(pluginDomain);
if (plugin && plugin.config && plugin.config.version) {
dbInfo.pluginVersion = plugin.config.version;
}
} catch (err) {
// Ignore version lookup errors
}
// Check store status
if (store && !store.closed) {
dbInfo.storeReady = true;
}
// Get core key
const coreKey = dbManager.getPluginCoreKey ? dbManager.getPluginCoreKey(pluginDomain) : null;
if (coreKey) {
dbInfo.coreKey = coreKey.toString('hex');
dbInfo.coreKeyShort = coreKey.toString('hex').substring(0, 16) + '...';
}
// Get core instance for more details
const core = dbManager.getPluginCore ? dbManager.getPluginCore(pluginDomain) : null;
if (core) {
dbInfo.writable = core.writable || false;
dbInfo.length = core.length || 0;
}
// Get database instance
const db = dbManager.getDatabaseInstance ? dbManager.getDatabaseInstance(pluginDomain) : null;
if (db && !db.closed) {
dbInfo.databaseOpen = true;
stats.totalDatabases++;
}
// Check replication status
if (replicationManager && replicationManager.isReplicationActive) {
dbInfo.replicationActive = replicationManager.isReplicationActive(pluginDomain);
if (dbInfo.replicationActive) {
stats.replicationActive++;
}
}
} catch (err) {
logDebug('Admin', `Error getting HyperDB info for ${pluginDomain}: ${err.message}`);
}
stats.databases.push(dbInfo);
}
} catch (err) {
logError('Admin', `Error collecting HyperDB stats: ${err.message}`);
}
return stats;
}
/**
* Collect Hyperdrive statistics
*/
function collectHyperdriveStats() {
const stats = {
totalDrives: 0,
replicationActive: 0,
drives: []
};
try {
if (!driveManager) {
return stats;
}
// Get all drives from drive manager
const allDrives = driveManager.getAllDriveInstances ? driveManager.getAllDriveInstances() : new Map();
stats.totalDrives = allDrives.size;
for (const [driveKey, driveInfo] of allDrives.entries()) {
const { pluginDomain, driveName, drive } = driveInfo;
const info = {
pluginDomain,
driveName,
driveReady: false,
discoveryKey: null,
discoveryKeyShort: null,
replicationActive: false,
writable: false,
version: 0
};
try {
if (drive) {
info.driveReady = !drive.closed;
info.writable = drive.writable || false;
info.version = drive.version || 0;
if (drive.discoveryKey) {
info.discoveryKey = drive.discoveryKey.toString('hex');
info.discoveryKeyShort = drive.discoveryKey.toString('hex').substring(0, 16) + '...';
}
}
// Check replication status
if (driveReplicationManager && driveReplicationManager.isReplicationActive) {
info.replicationActive = driveReplicationManager.isReplicationActive(pluginDomain, driveName);
if (info.replicationActive) {
stats.replicationActive++;
}
}
} catch (err) {
logDebug('Admin', `Error getting Hyperdrive info for ${pluginDomain}/${driveName}: ${err.message}`);
}
stats.drives.push(info);
}
} catch (err) {
logError('Admin', `Error collecting Hyperdrive stats: ${err.message}`);
}
return stats;
}
module.exports = { handleStatsRoutes };
@@ -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 };