reorg
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user