This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
+750
View File
@@ -0,0 +1,750 @@
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const { logInfo, logError, logWarn, logDebug } = require('../infrastructure/logger');
const state = require('../infrastructure/state');
const { parseMinutesToMs } = require('../infrastructure/utils');
const execAsync = promisify(exec);
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
const BACKUP_INTERVAL = parseMinutesToMs(process.env.BACKUP_INTERVAL || '720'); // Default: 12 hours = 720 minutes
// Get backup retention from state (defaults to 25, or env var if state not initialized)
function getBackupRetention() {
return state.backupRetentionCount || parseInt(process.env.BACKUP_RETENTION || '25', 10);
}
// Files and directories to backup
const FILES_TO_BACKUP = [
process.env.DOMAINS_FILE || './cache/domains.json',
process.env.LOCAL_DNS_FILE || './cache/local_dns.json',
process.env.SELECTOR_CACHE_FILE || './cache/selector_cache.json',
process.env.HOLESAIL_SERVERS_FILE || './cache/holesail_servers.json',
process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json',
process.env.BLOCKED_PEERS_FILE || './cache/blocked_peers.json',
process.env.PEER_METRICS_FILE || './cache/peer_metrics.json',
process.env.PEER_HISTORY_FILE || './cache/peer_history.json',
process.env.SUBSCRIPTIONS_FILE || './cache/subscriptions.json',
'./cache/keypair.json', // Persistent hyperswarm keypair - critical for peer identity
];
const DIRS_TO_BACKUP = [
process.env.CERTS_DIR || './certs',
];
const OPTIONAL_FILES = [
'./p2ns.json',
'./.env',
];
let backupInterval = null;
/**
* Get system state snapshot for metadata
*/
function getStateSnapshot() {
try {
const snapshot = {
connectedPeers: state.connectedPeers ? state.connectedPeers.size : 0,
timestamp: new Date().toISOString(),
};
// Try to get domain count from domains.json if accessible
try {
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
const domains = require('fs').readFileSync(domainsFile, 'utf8');
const domainsData = JSON.parse(domains);
snapshot.domainsCount = Array.isArray(domainsData) ? domainsData.length : 0;
} catch (err) {
snapshot.domainsCount = 0;
}
return snapshot;
} catch (err) {
logWarn('Backup', `Failed to capture state snapshot: ${err.message}`);
return {
timestamp: new Date().toISOString(),
connectedPeers: 0,
domainsCount: 0,
};
}
}
/**
* Ensure backup directory exists
*/
async function ensureBackupDir() {
try {
await fs.mkdir(BACKUP_DIR, { recursive: true });
} catch (err) {
logError('Backup', `Failed to create backup directory: ${err.message}`);
throw err;
}
}
/**
* Clean up leftover extract directories
*/
async function cleanupExtractDirectories() {
try {
const entries = await fs.readdir(BACKUP_DIR, { withFileTypes: true });
const extractDirs = entries.filter(
entry => entry.isDirectory() && (entry.name.startsWith('extract-') || entry.name.startsWith('extract-metadata-'))
);
if (extractDirs.length > 0) {
logDebug('Backup', `Cleaning up ${extractDirs.length} leftover extract directory(ies)`);
for (const dir of extractDirs) {
try {
await fs.rm(path.join(BACKUP_DIR, dir.name), { recursive: true, force: true });
logDebug('Backup', `Removed leftover extract directory: ${dir.name}`);
} catch (err) {
logWarn('Backup', `Failed to remove extract directory ${dir.name}: ${err.message}`);
}
}
}
} catch (err) {
logWarn('Backup', `Failed to cleanup extract directories: ${err.message}`);
}
}
/**
* Check if a path exists
*/
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Create a backup of all configured files and directories as tar.gz
* @returns {Promise<string>} Path to backup archive
*/
async function createBackup() {
try {
await ensureBackupDir();
// Clean up any leftover extract directories before creating backup
await cleanupExtractDirectories();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `backup-${timestamp}.tar.gz`;
const backupPath = path.join(BACKUP_DIR, backupName);
logInfo('Backup', `Creating backup: ${backupName}`);
// Create temporary directory for backup contents
const tempDir = path.join(BACKUP_DIR, `temp-${timestamp}`);
await fs.mkdir(tempDir, { recursive: true });
try {
const backedUpItems = [];
// Resolve paths relative to project root (where p2ns.js is located)
// Find project root by going up from this file's location
const projectRoot = path.resolve(__dirname, '../..');
// Backup individual files
for (const filePath of FILES_TO_BACKUP) {
try {
// Resolve path relative to project root to ensure consistency
let fullPath;
if (path.isAbsolute(filePath)) {
fullPath = filePath;
} else {
fullPath = path.resolve(projectRoot, filePath);
}
if (await pathExists(fullPath)) {
const fileName = path.basename(fullPath);
const destPath = path.join(tempDir, 'cache', fileName);
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(fullPath, destPath);
backedUpItems.push(filePath);
logDebug('Backup', `Backed up ${filePath}`);
} else {
logDebug('Backup', `File ${filePath} (resolved: ${fullPath}) does not exist, skipping`);
}
} catch (err) {
logWarn('Backup', `Failed to backup ${filePath}: ${err.message}`);
}
}
// Backup optional files
for (const filePath of OPTIONAL_FILES) {
try {
// Resolve path relative to project root to ensure consistency
let fullPath;
if (path.isAbsolute(filePath)) {
fullPath = filePath;
} else {
fullPath = path.resolve(projectRoot, filePath);
}
if (await pathExists(fullPath)) {
const fileName = path.basename(fullPath);
// Handle .env specially - create sanitized copy
if (fileName === '.env') {
const envContent = await fs.readFile(fullPath, 'utf8');
// Don't sanitize, just copy as-is (user can sanitize manually if needed)
const destPath = path.join(tempDir, fileName);
await fs.writeFile(destPath, envContent);
backedUpItems.push(filePath);
logDebug('Backup', `Backed up ${filePath}`);
} else {
const destPath = path.join(tempDir, fileName);
await fs.copyFile(fullPath, destPath);
backedUpItems.push(filePath);
logDebug('Backup', `Backed up ${filePath}`);
}
}
} catch (err) {
logDebug('Backup', `Optional file ${filePath} not found or failed: ${err.message}`);
}
}
// Backup directories
// Use projectRoot already defined above
const dirsToBackup = DIRS_TO_BACKUP;
logInfo('Backup', `Backing up ${dirsToBackup.length} directories: ${dirsToBackup.join(', ')}`);
for (const dirPath of dirsToBackup) {
try {
// Resolve path relative to project root to ensure consistency
let fullPath;
if (path.isAbsolute(dirPath)) {
fullPath = dirPath;
} else {
// Resolve relative to project root
fullPath = path.resolve(projectRoot, dirPath);
}
if (await pathExists(fullPath)) {
const dirName = path.basename(fullPath);
const destDir = path.join(tempDir, dirName);
// Copy directory recursively
await copyDirectory(fullPath, destDir);
backedUpItems.push(dirPath);
logInfo('Backup', `Successfully backed up directory: ${dirPath} (${fullPath}) -> ${destDir}`);
} else {
logWarn('Backup', `Directory ${dirPath} (resolved: ${fullPath}) does not exist, skipping`);
}
} catch (err) {
logError('Backup', `Failed to backup directory ${dirPath}: ${err.message}`);
// Don't throw - continue with other directories
}
}
// Get state snapshot
const stateSnapshot = getStateSnapshot();
// Create metadata file - ensure all backed up items are properly categorized
// Files are items from FILES_TO_BACKUP or OPTIONAL_FILES
// Directories are items from DIRS_TO_BACKUP
const backedUpFiles = backedUpItems.filter(item =>
FILES_TO_BACKUP.includes(item) || OPTIONAL_FILES.includes(item)
);
// Include all directories that were backed up
const backedUpDirectories = backedUpItems.filter(item =>
DIRS_TO_BACKUP.includes(item)
);
// Create metadata file
const metadata = {
timestamp: new Date().toISOString(),
files: backedUpFiles,
directories: backedUpDirectories,
version: require('../../package.json').version || 'unknown',
format: 'tar.gz',
state: stateSnapshot,
};
// Log what was backed up for debugging
logInfo('Backup', `Backup metadata - Files: ${backedUpFiles.length}, Directories: ${backedUpDirectories.length}`);
if (backedUpDirectories.length > 0) {
logInfo('Backup', `Backed up directories: ${backedUpDirectories.join(', ')}`);
}
await fs.writeFile(
path.join(tempDir, 'metadata.json'),
JSON.stringify(metadata, null, 2)
);
// Create tar.gz archive
const tempDirName = path.basename(tempDir);
const parentDir = path.dirname(tempDir);
try {
// Use tar command to create compressed archive
// Use backupName (not backupPath) since we're cd'ing into parentDir
await execAsync(`cd "${parentDir}" && tar -czf "${backupName}" "${tempDirName}"`);
logDebug('Backup', `Created tar.gz archive: ${backupPath}`);
// Remove temporary directory
await fs.rm(tempDir, { recursive: true, force: true });
} catch (err) {
// Fallback: try without cd if that fails
logWarn('Backup', `Failed to create tar.gz with cd, trying alternative method: ${err.message}`);
try {
await execAsync(`tar -czf "${backupPath}" -C "${parentDir}" "${tempDirName}"`);
await fs.rm(tempDir, { recursive: true, force: true });
} catch (err2) {
// Clean up temp dir even if tar fails
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`Failed to create tar.gz archive: ${err2.message}`);
}
}
logInfo('Backup', `Backup completed: ${backupName}`);
return backupPath;
} catch (err) {
// Clean up temp directory on error
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
} catch (err) {
logError('Backup', `Backup failed: ${err.message}`);
throw err;
}
}
/**
* Copy directory recursively
*/
async function copyDirectory(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Extract tar.gz archive to temporary directory
* @returns {Promise<{extractDir: string, contentDir: string}>} Object with extractDir (for cleanup) and contentDir (for access)
*/
async function extractArchive(archivePath) {
const extractDir = path.join(BACKUP_DIR, `extract-${Date.now()}`);
await fs.mkdir(extractDir, { recursive: true });
try {
await execAsync(`tar -xzf "${archivePath}" -C "${extractDir}"`);
// Find the extracted directory (tar extracts with the directory name)
const entries = await fs.readdir(extractDir, { withFileTypes: true });
let contentDir = extractDir;
if (entries.length === 1 && entries[0].isDirectory()) {
contentDir = path.join(extractDir, entries[0].name);
}
// Return both paths: extractDir for cleanup, contentDir for accessing content
return { extractDir, contentDir };
} catch (err) {
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`Failed to extract archive: ${err.message}`);
}
}
/**
* List all available backups (both directory and tar.gz formats)
* @returns {Promise<Array>} Array of backup metadata
*/
async function listBackups() {
try {
await ensureBackupDir();
// Clean up any leftover extract directories
await cleanupExtractDirectories();
const entries = await fs.readdir(BACKUP_DIR, { withFileTypes: true });
const backups = [];
for (const entry of entries) {
// Skip temporary directories
if (entry.name.startsWith('temp-') || entry.name.startsWith('extract-')) {
continue;
}
let backupPath, metadataPath, isTarGz;
if (entry.isDirectory() && entry.name.startsWith('backup-')) {
// Old directory format
backupPath = path.join(BACKUP_DIR, entry.name);
metadataPath = path.join(backupPath, 'metadata.json');
isTarGz = false;
} else if (entry.isFile() && entry.name.startsWith('backup-') && entry.name.endsWith('.tar.gz')) {
// New tar.gz format
backupPath = path.join(BACKUP_DIR, entry.name);
isTarGz = true;
// Extract metadata from tar.gz
try {
const { extractDir, contentDir } = await extractArchive(backupPath);
metadataPath = path.join(contentDir, 'metadata.json');
// Read metadata
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
// Get file size
const stats = await fs.stat(backupPath);
// Clean up extraction (use extractDir to remove the entire extract directory)
await fs.rm(extractDir, { recursive: true, force: true });
// Extract timestamp from filename if not in metadata
let timestamp = metadata.timestamp;
if (!timestamp) {
const match = entry.name.match(/backup-(.+?)\.tar\.gz/);
if (match) {
timestamp = match[1].replace(/-/g, ':');
}
}
backups.push({
name: entry.name,
path: backupPath,
timestamp: timestamp || new Date().toISOString(),
files: metadata.files || [],
directories: metadata.directories || [],
version: metadata.version || 'unknown',
format: 'tar.gz',
size: stats.size,
state: metadata.state || {},
});
} catch (err) {
logWarn('Backup', `Failed to read metadata from ${entry.name}: ${err.message}`);
// Fallback: use filename for timestamp
const match = entry.name.match(/backup-(.+?)\.tar\.gz/);
const timestamp = match ? match[1].replace(/-/g, ':') : new Date().toISOString();
const stats = await fs.stat(backupPath).catch(() => ({ size: 0 }));
backups.push({
name: entry.name,
path: backupPath,
timestamp: timestamp,
files: [],
directories: [],
version: 'unknown',
format: 'tar.gz',
size: stats.size || 0,
});
}
continue;
} else {
continue;
}
// Handle directory format backups
try {
if (await pathExists(metadataPath)) {
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
// Calculate size for directory backups
let totalSize = 0;
try {
const dirStats = await getDirectorySize(backupPath);
totalSize = dirStats;
} catch (err) {
logDebug('Backup', `Failed to calculate size for ${entry.name}: ${err.message}`);
}
backups.push({
name: entry.name,
path: backupPath,
timestamp: metadata.timestamp || entry.name.replace('backup-', '').replace(/-/g, ':'),
files: metadata.files || [],
directories: metadata.directories || [],
version: metadata.version || 'unknown',
format: 'directory',
size: totalSize,
state: metadata.state || {},
});
} else {
// No metadata, use filename
const timestamp = entry.name.replace('backup-', '').replace(/-/g, ':');
let totalSize = 0;
try {
totalSize = await getDirectorySize(backupPath);
} catch (err) {
logDebug('Backup', `Failed to calculate size for ${entry.name}: ${err.message}`);
}
backups.push({
name: entry.name,
path: backupPath,
timestamp: timestamp,
files: [],
directories: [],
version: 'unknown',
format: 'directory',
size: totalSize,
});
}
} catch (err) {
logWarn('Backup', `Failed to read metadata for ${entry.name}: ${err.message}`);
}
}
// Sort by timestamp (newest first)
backups.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
return backups;
} catch (err) {
logError('Backup', `Failed to list backups: ${err.message}`);
return [];
}
}
/**
* Get total size of directory
*/
async function getDirectorySize(dirPath) {
let totalSize = 0;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
try {
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await fs.stat(entryPath);
totalSize += stats.size;
}
} catch (err) {
// Skip files we can't access
}
}
return totalSize;
}
/**
* Restore from a backup (supports both directory and tar.gz formats)
* @param {string} backupName - Name of backup directory or tar.gz file
* @returns {Promise<void>}
*/
async function restoreBackup(backupName) {
try {
const backupPath = path.join(BACKUP_DIR, backupName);
// Verify backup exists
if (!await pathExists(backupPath)) {
throw new Error(`Backup ${backupName} does not exist`);
}
logInfo('Backup', `Restoring from backup: ${backupName}`);
// Create a backup before restoring (safety measure)
await createBackup();
let extractDir = null;
let restoreSource = backupPath;
let isTarGz = backupName.endsWith('.tar.gz');
try {
// Extract if tar.gz
if (isTarGz) {
const extractResult = await extractArchive(backupPath);
extractDir = extractResult.extractDir;
restoreSource = extractResult.contentDir;
}
// Read metadata
const metadataPath = path.join(restoreSource, 'metadata.json');
let metadata = {};
if (await pathExists(metadataPath)) {
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
}
const filesToRestore = metadata.files || FILES_TO_BACKUP.map(f => path.basename(f));
const dirsToRestore = metadata.directories || DIRS_TO_BACKUP;
// Restore individual files
for (const filePath of filesToRestore) {
const fileName = path.basename(filePath);
let sourcePath;
// Try cache subdirectory first (new format)
sourcePath = path.join(restoreSource, 'cache', fileName);
if (!await pathExists(sourcePath)) {
// Try root (old format or direct placement)
sourcePath = path.join(restoreSource, fileName);
}
if (await pathExists(sourcePath)) {
// Determine destination based on original file path
let destPath;
if (filePath.startsWith('./')) {
destPath = path.resolve(filePath);
} else {
// Try to match to known files
if (fileName === 'domains.json') {
destPath = path.resolve(process.env.DOMAINS_FILE || './cache/domains.json');
} else if (fileName === 'local_dns.json') {
destPath = path.resolve(process.env.LOCAL_DNS_FILE || './cache/local_dns.json');
} else if (fileName === 'selector_cache.json') {
destPath = path.resolve(process.env.SELECTOR_CACHE_FILE || './cache/selector_cache.json');
} else if (fileName === 'holesail_servers.json') {
destPath = path.resolve(process.env.HOLESAIL_SERVERS_FILE || './cache/holesail_servers.json');
} else if (fileName === 'holesail_clients.json') {
destPath = path.resolve(process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json');
} else if (fileName === 'blocked_peers.json') {
destPath = path.resolve(process.env.BLOCKED_PEERS_FILE || './cache/blocked_peers.json');
} else if (fileName === 'peer_metrics.json') {
destPath = path.resolve(process.env.PEER_METRICS_FILE || './cache/peer_metrics.json');
} else if (fileName === 'peer_history.json') {
destPath = path.resolve(process.env.PEER_HISTORY_FILE || './cache/peer_history.json');
} else if (fileName === 'subscriptions.json') {
destPath = path.resolve(process.env.SUBSCRIPTIONS_FILE || './cache/subscriptions.json');
} else if (fileName === 'keypair.json') {
destPath = path.resolve('./cache/keypair.json');
} else {
destPath = path.resolve(filePath);
}
}
// Ensure destination directory exists
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(sourcePath, destPath);
logInfo('Backup', `Restored ${fileName} to ${destPath}`);
} else {
logWarn('Backup', `File ${fileName} not found in backup, skipping`);
}
}
// Restore optional files
for (const fileName of ['p2ns.json', '.env']) {
const sourcePath = path.join(restoreSource, fileName);
if (await pathExists(sourcePath)) {
const destPath = path.resolve(fileName);
await fs.copyFile(sourcePath, destPath);
logInfo('Backup', `Restored ${fileName}`);
}
}
// Restore directories
for (const dirPath of dirsToRestore) {
const dirName = path.basename(dirPath);
const sourceDir = path.join(restoreSource, dirName);
if (await pathExists(sourceDir)) {
const destDir = path.resolve(dirPath);
// Remove existing directory if it exists
if (await pathExists(destDir)) {
await fs.rm(destDir, { recursive: true, force: true });
}
// Copy directory
await fs.mkdir(path.dirname(destDir), { recursive: true });
await copyDirectory(sourceDir, destDir);
logInfo('Backup', `Restored directory ${dirPath}`);
} else {
logWarn('Backup', `Directory ${dirName} not found in backup, skipping`);
}
}
logInfo('Backup', `Restore completed from: ${backupName}`);
} finally {
// Clean up extraction directory
if (extractDir) {
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
}
}
} catch (err) {
logError('Backup', `Restore failed: ${err.message}`);
throw err;
}
}
/**
* Clean up old backups (keep only last N)
*/
async function cleanupOldBackups() {
try {
const backups = await listBackups();
const retention = getBackupRetention();
if (backups.length <= retention) {
return;
}
const toDelete = backups.slice(retention);
logInfo('Backup', `Cleaning up ${toDelete.length} old backup(s) (retention: ${retention})`);
for (const backup of toDelete) {
try {
await fs.rm(backup.path, { recursive: true, force: true });
logDebug('Backup', `Deleted old backup: ${backup.name}`);
} catch (err) {
logError('Backup', `Failed to delete backup ${backup.name}: ${err.message}`);
}
}
} catch (err) {
logError('Backup', `Cleanup failed: ${err.message}`);
}
}
/**
* Start automatic backups
*/
function startAutomaticBackups() {
if (backupInterval) {
clearInterval(backupInterval);
}
// Create initial backup (after cleanup)
(async () => {
try {
await cleanupOldBackups();
await createBackup();
} catch (err) {
logError('Backup', `Initial backup failed: ${err.message}`);
}
})();
// Schedule periodic backups
backupInterval = setInterval(async () => {
try {
// Cleanup BEFORE backup
await cleanupOldBackups();
await createBackup();
} catch (err) {
logError('Backup', `Scheduled backup failed: ${err.message}`);
}
}, BACKUP_INTERVAL);
logInfo('Backup', `Automatic backups started (interval: ${BACKUP_INTERVAL}ms, retention: ${getBackupRetention()})`);
}
/**
* Stop automatic backups
*/
function stopAutomaticBackups() {
if (backupInterval) {
clearInterval(backupInterval);
backupInterval = null;
logInfo('Backup', 'Automatic backups stopped');
}
}
module.exports = {
createBackup,
listBackups,
restoreBackup,
cleanupOldBackups,
startAutomaticBackups,
stopAutomaticBackups,
};
+504
View File
@@ -0,0 +1,504 @@
const { exec } = require('child_process');
const os = require('os');
const util = require('util');
const net = require('net');
const dgram = require('dgram');
const state = require('../infrastructure/state');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
const execAsync = util.promisify(exec);
// Helper function to check if a port is available (TCP and UDP)
async function checkPortAvailability(host, port) {
const tcpPromise = new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (err) => {
server.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`TCP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, host);
});
const udpPromise = new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4');
socket.once('error', (err) => {
socket.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`UDP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
socket.once('listening', () => {
socket.close();
resolve(true);
});
socket.bind(port, host);
});
try {
await Promise.all([tcpPromise, udpPromise]);
return true;
} catch (err) {
throw err;
}
}
// Helper function to wait for a port to be released (TCP and UDP)
async function waitForPortRelease(host, port, maxAttempts = 60, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await checkPortAvailability(host, port);
logDebug('Cleanup', `Port ${port} on ${host} is now free (attempt ${attempt})`);
return true;
} catch (err) {
logDebug('Cleanup', `Port ${port} on ${host} still in use (attempt ${attempt}): ${err.message}`);
if (attempt === maxAttempts) {
logWarn('Cleanup', `Port ${port} on ${host} still in use after ${maxAttempts} attempts`);
return false;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
// Improved Linux-compatible function to find PIDs using a port
async function findPIDsUsingPort(host, port) {
const pids = new Set();
const isLinux = os.platform() === 'linux';
try {
// Method 1: Use lsof (works on both platforms)
const protocols = ['TCP', 'UDP'];
for (const proto of protocols) {
try {
const { stdout } = await execAsync(`sudo lsof -i ${proto}@${host}:${port} -t`);
stdout.trim().split('\n').filter(pid => pid).forEach(pid => pids.add(pid));
} catch (err) {
logDebug('Cleanup', `No ${proto} processes found with lsof for ${host}:${port}: ${err.message}`);
}
}
// Method 2: Linux-specific netstat + ps approach (more reliable on Linux)
if (isLinux) {
try {
// Find TCP connections
const { stdout: netstatTcp } = await execAsync(`netstat -tlnp 2>/dev/null | grep :${port} || true`);
const tcpMatches = netstatTcp.match(/(\d+)\/\w+/g);
if (tcpMatches) {
tcpMatches.forEach(match => {
const pid = match.split('/')[0];
if (pid && pid !== '0' && pid !== '-') pids.add(pid);
});
}
// Find UDP connections
const { stdout: netstatUdp } = await execAsync(`netstat -ulnp 2>/dev/null | grep :${port} || true`);
const udpMatches = netstatUdp.match(/(\d+)\/\w+/g);
if (udpMatches) {
udpMatches.forEach(match => {
const pid = match.split('/')[0];
if (pid && pid !== '0' && pid !== '-') pids.add(pid);
});
}
} catch (err) {
logDebug('Cleanup', `netstat method failed for ${host}:${port}: ${err.message}`);
}
// Method 3: Use ss command (modern Linux)
try {
const { stdout: ssTcp } = await execAsync(`ss -tlnp sport = :${port} 2>/dev/null || true`);
const { stdout: ssUdp } = await execAsync(`ss -ulnp sport = :${port} 2>/dev/null || true`);
const extractPidsFromSs = (output) => {
const matches = output.match(/pid=(\d+)/g);
if (matches) {
matches.forEach(match => {
const pid = match.split('=')[1];
if (pid && pid !== '0') pids.add(pid);
});
}
};
extractPidsFromSs(ssTcp);
extractPidsFromSs(ssUdp);
} catch (err) {
logDebug('Cleanup', `ss method failed for ${host}:${port}: ${err.message}`);
}
}
return Array.from(pids);
} catch (err) {
logError('Cleanup', `Error finding PIDs for ${host}:${port}: ${err.message}`);
return [];
}
}
// Improved function to kill a process with better Linux support
async function killProcess(pid, maxWaitTime = 5000) {
try {
// Check if process exists
try {
await execAsync(`ps -p ${pid}`);
} catch (err) {
logDebug('Cleanup', `PID ${pid} already dead`);
return true;
}
// Send SIGTERM
await execAsync(`sudo kill -15 ${pid}`);
logInfo('Cleanup', `Sent SIGTERM to PID ${pid}`);
// Wait for graceful termination
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
try {
await execAsync(`ps -p ${pid}`);
await new Promise(resolve => setTimeout(resolve, 200));
} catch (err) {
logDebug('Cleanup', `PID ${pid} terminated gracefully`);
return true;
}
}
// If still running, force kill
try {
await execAsync(`ps -p ${pid}`);
await execAsync(`sudo kill -9 ${pid}`);
logWarn('Cleanup', `Forced SIGKILL for PID ${pid}`);
// Give it a moment to die
await new Promise(resolve => setTimeout(resolve, 500));
try {
await execAsync(`ps -p ${pid}`);
logError('Cleanup', `PID ${pid} still alive after SIGKILL`);
return false;
} catch (err) {
logDebug('Cleanup', `PID ${pid} confirmed dead after SIGKILL`);
return true;
}
} catch (err) {
logDebug('Cleanup', `PID ${pid} already dead before SIGKILL`);
return true;
}
} catch (err) {
logError('Cleanup', `Failed to kill PID ${pid}: ${err.message}`);
return false;
}
}
// Helper function to free a port by killing processes using it
async function freePort(host, port, maxRetries = 3) {
for (let retry = 1; retry <= maxRetries; retry++) {
logDebug('Cleanup', `Attempting to free port ${host}:${port} (attempt ${retry}/${maxRetries})`);
const pids = await findPIDsUsingPort(host, port);
if (pids.length === 0) {
logDebug('Cleanup', `No PIDs found for ${host}:${port} (attempt ${retry})`);
// On Linux, check if port is actually free or just in TIME_WAIT
const isReleased = await waitForPortRelease(host, port, 5, 500);
if (isReleased) {
logInfo('Cleanup', `Port ${host}:${port} is now free`);
return true;
} else {
logWarn('Cleanup', `Port ${host}:${port} still appears busy despite no PIDs found`);
}
} else {
logInfo('Cleanup', `Found ${pids.length} PID(s) using ${host}:${port}: ${pids.join(', ')}`);
// Kill all processes
const killPromises = pids.map(pid => killProcess(pid));
await Promise.allSettled(killPromises);
// Wait a bit for the port to be released
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Check if port is now free
const isReleased = await waitForPortRelease(host, port, 10, 500);
if (isReleased) {
logInfo('Cleanup', `Successfully freed port ${host}:${port} on attempt ${retry}`);
return true;
}
if (retry < maxRetries) {
logDebug('Cleanup', `Port ${host}:${port} still busy, retrying in 2 seconds...`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
logError('Cleanup', `Failed to free port ${host}:${port} after ${maxRetries} attempts`);
return false;
}
// Helper function to get all configured IPs on the interface
async function getConfiguredIPs() {
// If virtual interfaces are disabled, return empty array
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
return [];
}
try {
let command;
if (os.platform() === 'darwin') {
command = `ifconfig ${state.subnetName} | grep 'inet ' | awk '{print $2}'`;
} else if (os.platform() === 'linux') {
command = `ip addr show ${state.subnetName} | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1`;
} else if (os.platform() === 'win32') {
command = `netsh interface ip show addresses "${state.subnetName || 'Loopback Pseudo-Interface 1'}" | findstr "IP Address"`;
} else {
logWarn('Cleanup', `Unsupported platform: ${os.platform()}, assuming no configured IPs`);
return [];
}
const { stdout } = await execAsync(command);
if (os.platform() === 'win32') {
// Parse Windows output: "IP Address: 192.168.3.2"
return stdout.split('\n')
.map(line => {
const match = line.match(/IP Address:\s*(\d+\.\d+\.\d+\.\d+)/);
return match ? match[1] : null;
})
.filter(ip => ip);
}
return stdout.split('\n').map(ip => ip.trim()).filter(ip => ip);
} catch (err) {
logError('Cleanup', `Error listing configured IPs: ${err.message}`);
return [];
}
}
// Helper function to remove virtual interface
async function removeVirtualInterface(ip) {
// Check if virtual interfaces are disabled
if (process.env.DISABLE_VIRTUAL_INTERFACES === 'true') {
logDebug('Cleanup', 'Virtual interfaces disabled, skipping interface removal');
return;
}
if (!state.subnetName) {
logWarn('Cleanup', 'SUBNET_NAME not defined, skipping interface removal');
return;
}
const ipParts = ip.split('.');
if (ipParts.length !== 4 || ipParts.some(part => isNaN(part) || part < 0 || part > 255)) {
logWarn('Cleanup', `Skipping removal of invalid IP ${ip}`);
return;
}
let configuredIPs;
try {
configuredIPs = await getConfiguredIPs();
} catch (err) {
logError('Cleanup', `Error checking if IP ${ip} exists: ${err.message}`);
return;
}
if (!configuredIPs.includes(ip)) {
logDebug('Cleanup', `IP ${ip} not configured on system, skipping removal`);
return;
}
const cidrIp = `${ip}/24`;
let command;
if (os.platform() === 'darwin') {
command = `sudo ifconfig ${state.subnetName} -alias ${ip}`;
} else if (os.platform() === 'linux') {
command = `sudo ip addr del ${cidrIp} dev ${state.subnetName}`;
} else if (os.platform() === 'win32') {
const interfaceName = state.subnetName || 'Loopback Pseudo-Interface 1';
command = `netsh interface ip delete address "${interfaceName}" ${ip}`;
} else {
logWarn('Cleanup', `Unsupported platform: ${os.platform()}, skipping interface removal`);
return;
}
try {
await execAsync(command);
logInfo('Cleanup', `Removed virtual interface IP: ${ip}`);
} catch (err) {
if (err.stderr && (err.stderr.includes("Can't assign requested address") || err.stderr.includes("No such device"))) {
logDebug('Cleanup', `IP ${ip} already removed or not configured: ${err.stderr}`);
} else {
logError('Cleanup', `Error removing virtual interface ${ip}: ${err.stderr || err.message}`);
}
}
}
// Helper function to close a server with a timeout
async function closeServer(server, type, key, isUdp = false) {
return new Promise((resolve) => {
if (!server) {
logDebug('Cleanup', `No ${type} server to close for ${key}`);
resolve();
return;
}
const timeout = setTimeout(() => {
logWarn('Cleanup', `Timeout closing ${type} server for ${key}, forcing closure`);
if (isUdp && server.close) {
server.close();
} else if (!isUdp && server.destroy) {
server.destroy();
} else if (!isUdp) {
server.close();
}
resolve();
}, 10000);
if (isUdp && server.close) {
server.close(() => {
clearTimeout(timeout);
logDebug('Cleanup', `Closed UDP ${type} server for ${key}`);
resolve();
});
} else if (!isUdp) {
server.close((err) => {
clearTimeout(timeout);
if (err) {
logError('Cleanup', `Error closing ${type} server for ${key}: ${err.message}`);
} else {
logDebug('Cleanup', `Closed ${type} server for ${key}`);
}
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
}
// Cleanup all interfaces
async function cleanupInterfaces() {
try {
const configuredIPs = await getConfiguredIPs();
logDebug('Cleanup', `Configured IPs on ${state.subnetName}: ${configuredIPs.join(', ')}`);
const portsToCheck = new Map();
for (const key of state.holesails.keys()) {
const [domain, port] = key.split(':');
const ip = state.domainToIPMap.get(domain);
if (ip && port) {
portsToCheck.set(`${ip}:${port}`, { ip, port: parseInt(port), domain });
}
}
// Close all holesail connections first
for (const [key, holesail] of state.holesails) {
try {
if (holesail instanceof dgram.Socket) {
await closeServer(holesail, 'Holesail UDP', key, true);
} else {
await holesail.close();
logDebug('Cleanup', `Closed Holesail TCP client for ${key}`);
}
} catch (err) {
logError('Cleanup', `Error closing Holesail client for ${key}: ${err.message}`);
}
}
state.holesails.clear();
logDebug('Cleanup', 'Cleared Holesail clients state');
// Close TLS servers
for (const [key, server] of state.tlsServers) {
await closeServer(server, 'TLS', key);
}
state.tlsServers.clear();
logDebug('Cleanup', 'Cleared TLS servers state');
// Close HTTP servers
for (const [key, server] of state.httpServers) {
await closeServer(server, 'HTTP', key);
}
state.httpServers.clear();
logDebug('Cleanup', 'Cleared HTTP servers state');
// Kill holesail server children
for (const [id, child] of state.holesailChildren) {
try {
await killProcess(child.pid, 10000);
} catch (err) {
logError('Cleanup', `Error killing Holesail child for server ${id}: ${err.message}`);
}
}
state.holesailChildren.clear();
state.holesailOpts.clear();
state.holesailInfos.clear();
logDebug('Cleanup', 'Cleared Holesail server child processes state');
// Kill holesail client children
for (const [id, child] of state.holesailClientChildren) {
try {
await killProcess(child.pid, 10000);
} catch (err) {
logError('Cleanup', `Error killing Holesail client child for ${id}: ${err.message}`);
}
}
state.holesailClientChildren.clear();
state.holesailClientOpts.clear();
state.holesailClientInfos.clear();
logDebug('Cleanup', 'Cleared Holesail client child processes state');
// Free all ports
for (const [key, { ip, port, domain }] of portsToCheck) {
const isPortFree = await freePort(ip, port);
if (!isPortFree) {
logError('Cleanup', `Port ${port} on ${ip} for ${domain} still in use after exhaustive cleanup attempts`);
}
}
const reservedIPs = ['127.0.0.1'];
// Remove virtual interfaces (skip if disabled)
if (process.env.DISABLE_VIRTUAL_INTERFACES !== 'true') {
const removedIPs = new Set();
for (const [domain, ip] of state.domainToIPMap) {
if (reservedIPs.includes(ip)) {
logDebug('Cleanup', `Skipping removal of reserved IP ${ip} for domain ${domain}`);
continue;
}
if (!configuredIPs.includes(ip)) {
logDebug('Cleanup', `IP ${ip} for domain ${domain} not configured on system, skipping removal`);
removedIPs.add(ip);
continue;
}
await removeVirtualInterface(ip);
removedIPs.add(ip);
}
} else {
logDebug('Cleanup', 'Virtual interfaces disabled, skipping interface removal');
}
// Update state
const newDomainToIPMap = new Map();
for (const [domain, ip] of state.domainToIPMap) {
if (reservedIPs.includes(ip) && !removedIPs.has(ip)) {
newDomainToIPMap.set(domain, ip);
}
}
state.domainToIPMap = newDomainToIPMap;
state.currentIP = parseInt(process.env.INITIAL_IP_INDEX || 2);
logInfo('Cleanup', 'Interfaces cleaned up successfully');
} catch (err) {
logError('Cleanup', `Failed to cleanup interfaces: ${err.message}`);
}
}
module.exports = {
cleanupInterfaces,
removeVirtualInterface,
checkPortAvailability,
waitForPortRelease,
freePort
};
+996
View File
@@ -0,0 +1,996 @@
const state = require('../infrastructure/state');
const os = require('os');
const { performance } = require('perf_hooks');
const { parseMinutesToMs, parseSecondsToMs } = require('../infrastructure/utils');
// Circular buffer for time-series data
class CircularBuffer {
constructor(maxSize = 1000) {
this.maxSize = maxSize;
this.buffer = [];
this.head = 0;
}
push(value) {
if (this.buffer.length < this.maxSize) {
this.buffer.push(value);
} else {
this.buffer[this.head] = value;
this.head = (this.head + 1) % this.maxSize;
}
}
getData() {
if (this.buffer.length < this.maxSize) {
return this.buffer;
}
return [...this.buffer.slice(this.head), ...this.buffer.slice(0, this.head)];
}
getLatest(count = 100) {
const data = this.getData();
return data.slice(-count);
}
length() {
return this.buffer.length;
}
clear() {
this.buffer = [];
this.head = 0;
}
}
// Metrics configuration
const METRICS_CONFIG = {
retentionPeriod: parseMinutesToMs(process.env.METRICS_RETENTION_MS || '60'), // 1 hour = 60 minutes default
samplingRate: parseFloat(process.env.METRICS_SAMPLING_RATE || '1.0'), // 1.0 = 100%, 0.5 = 50%
aggregationInterval: parseSecondsToMs(process.env.METRICS_AGGREGATION_INTERVAL || '60'), // 1 minute = 60 seconds
maxCircularBufferSize: parseInt(process.env.METRICS_MAX_BUFFER_SIZE || '1000', 10)
};
// Aggregated metrics cache
let aggregatedMetrics = {
requests: {
total: 0,
successful: 0,
failed: 0,
avgResponseTime: 0,
minResponseTime: Infinity,
maxResponseTime: 0,
lastUpdated: Date.now()
},
dns: {
queries: 0,
p2pResolutions: 0,
publicResolutions: 0,
failures: 0,
avgResponseTime: 0,
lastUpdated: Date.now()
}
};
let aggregationInterval = null;
// Simple metrics tracking
const metrics = {
requests: {
total: 0,
successful: 0,
failed: 0,
byEndpoint: new Map(),
maxEndpoints: 500, // Limit to 500 endpoints
responseTimes: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
},
dns: {
queries: 0,
p2pResolutions: 0,
publicResolutions: 0,
failures: 0,
responseTimes: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
queryTimestamps: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
queryTypes: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
domainQueries: new Map(), // domain -> count
maxDomainQueries: 1000 // Limit to 1000 domains
},
peers: {
connected: 0,
disconnected: 0,
current: 0,
connectionEvents: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2)),
connectionDurations: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2))
},
domains: {
added: 0,
removed: 0,
current: 0,
addEvents: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2)),
removeEvents: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2))
},
consensus: {
resolutions: 0,
quorumFailures: 0,
ties: 0,
validationFailures: 0,
totalVotes: 0,
avgVotesPerDomain: 0,
events: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2))
},
holesail: {
clientsStarted: 0,
clientsStopped: 0,
serversStarted: 0,
serversStopped: 0,
activeConnections: 0,
connectionEvents: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2)),
connectionDurations: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2)),
protocolCounts: { tcp: 0, udp: 0 }
},
network: {
throughput: {
bytesIn: 0,
bytesOut: 0,
dataPoints: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
}
},
resources: {
sockets: 0,
timers: 0,
intervals: 0,
servers: 0,
dataPoints: new CircularBuffer(Math.floor(METRICS_CONFIG.maxCircularBufferSize / 2))
},
process: {
memory: {
heapUsed: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
heapTotal: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
rss: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
external: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
arrayBuffers: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
},
cpu: {
user: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
system: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
percentage: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
},
eventLoop: {
lag: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
lastCheck: performance.now()
},
system: {
totalMemory: os.totalmem(),
freeMemory: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
usedMemoryPercentage: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
loadAverage: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
},
handles: {
active: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize),
timestamp: new CircularBuffer(METRICS_CONFIG.maxCircularBufferSize)
},
lastCpuUsage: process.cpuUsage(),
lastCpuTime: Date.now(),
collectionInterval: null
},
startTime: Date.now(),
lastUpdate: Date.now()
};
/**
* Increment a metric counter
* @param {string} category - Metric category
* @param {string} metric - Metric name
* @param {number} value - Value to increment by (default: 1)
*/
function increment(category, metric, value = 1) {
if (metrics[category] && typeof metrics[category][metric] === 'number') {
metrics[category][metric] += value;
}
}
/**
* Track DNS query with timing (with sampling)
* @param {string} type - Query type ('p2p', 'public', 'failure')
* @param {string} domain - Domain name
* @param {number} responseTime - Response time in ms
*/
function trackDNSQueryWithTiming(type, domain, responseTime = null) {
// Apply sampling rate
if (Math.random() > METRICS_CONFIG.samplingRate) {
return; // Skip this metric due to sampling
}
const now = Date.now();
increment('dns', 'queries');
if (type === 'p2p') {
increment('dns', 'p2pResolutions');
} else if (type === 'public') {
increment('dns', 'publicResolutions');
} else if (type === 'failure') {
increment('dns', 'failures');
}
metrics.dns.queryTimestamps.push(now);
metrics.dns.queryTypes.push(type);
if (responseTime !== null) {
metrics.dns.responseTimes.push(responseTime);
}
// Track per-domain queries with size limit
if (domain) {
// If map is at limit, remove oldest entries (simple FIFO by removing first key)
if (metrics.dns.domainQueries.size >= metrics.dns.maxDomainQueries) {
const firstKey = metrics.dns.domainQueries.keys().next().value;
if (firstKey) {
metrics.dns.domainQueries.delete(firstKey);
}
}
const count = metrics.dns.domainQueries.get(domain) || 0;
metrics.dns.domainQueries.set(domain, count + 1);
}
}
/**
* Track request with response time (with sampling)
* @param {string} endpoint - API endpoint
* @param {boolean} success - Whether request was successful
* @param {number} responseTime - Response time in ms
*/
function trackRequestWithTiming(endpoint, success, responseTime = null) {
// Apply sampling rate
if (Math.random() > METRICS_CONFIG.samplingRate) {
// Still track basic request count, but skip detailed metrics
trackRequest(endpoint, success);
return;
}
const now = Date.now();
trackRequest(endpoint, success);
if (responseTime !== null) {
metrics.requests.responseTimes.push(responseTime);
metrics.requests.timestamp.push(now);
}
}
/**
* Track peer connection event
* @param {string} event - 'connect' or 'disconnect'
* @param {string} peerId - Peer ID
*/
function trackPeerEvent(event, peerId) {
const now = Date.now();
if (event === 'connect') {
increment('peers', 'connected');
metrics.peers.connectionEvents.push({ type: 'connect', peerId, timestamp: now });
} else if (event === 'disconnect') {
increment('peers', 'disconnected');
const lastConnect = metrics.peers.connectionEvents.getData().slice().reverse()
.find(e => e.type === 'connect' && e.peerId === peerId);
if (lastConnect) {
const duration = now - lastConnect.timestamp;
metrics.peers.connectionDurations.push(duration);
}
metrics.peers.connectionEvents.push({ type: 'disconnect', peerId, timestamp: now });
}
}
/**
* Track domain event
* @param {string} event - 'add' or 'remove'
* @param {string} domain - Domain name
*/
function trackDomainEvent(event, domain) {
const now = Date.now();
if (event === 'add') {
increment('domains', 'added');
metrics.domains.addEvents.push({ domain, timestamp: now });
} else if (event === 'remove') {
increment('domains', 'removed');
metrics.domains.removeEvents.push({ domain, timestamp: now });
}
}
/**
* Track consensus event
* @param {string} event - Event type ('quorum_failure', 'tie', 'validation_failure', etc.)
* @param {object} data - Event data
*/
function trackConsensusEvent(event, data = {}) {
const now = Date.now();
metrics.consensus.events.push({ event, data, timestamp: now });
if (event === 'quorum_failure') {
increment('consensus', 'quorumFailures');
} else if (event === 'tie') {
increment('consensus', 'ties');
} else if (event === 'validation_failure') {
increment('consensus', 'validationFailures');
}
}
/**
* Track Holesail connection event
* @param {string} type - 'client' or 'server'
* @param {string} event - 'start' or 'stop'
* @param {string} protocol - 'tcp' or 'udp'
* @param {number} duration - Connection duration in ms (for stop events)
*/
function trackHolesailEvent(type, event, protocol = 'tcp', duration = null) {
const now = Date.now();
if (type === 'client') {
if (event === 'start') {
increment('holesail', 'clientsStarted');
metrics.holesail.activeConnections++;
} else if (event === 'stop') {
increment('holesail', 'clientsStopped');
metrics.holesail.activeConnections = Math.max(0, metrics.holesail.activeConnections - 1);
}
} else if (type === 'server') {
if (event === 'start') {
increment('holesail', 'serversStarted');
} else if (event === 'stop') {
increment('holesail', 'serversStopped');
}
}
metrics.holesail.connectionEvents.push({ type, event, protocol, timestamp: now });
if (protocol === 'tcp') {
metrics.holesail.protocolCounts.tcp++;
} else if (protocol === 'udp') {
metrics.holesail.protocolCounts.udp++;
}
if (duration !== null) {
metrics.holesail.connectionDurations.push(duration);
}
}
/**
* Update resource metrics
*/
function updateResourceMetrics() {
const now = Date.now();
const resourceCounts = {
sockets: metrics.requests.responseTimes.length(),
timers: 0, // Would need external tracking
intervals: 0, // Would need external tracking
servers: (state.tlsServers?.size || 0) + (state.httpServers?.size || 0),
holesails: state.holesails?.size || 0,
timestamp: now
};
metrics.resources.dataPoints.push(resourceCounts);
metrics.resources.sockets = resourceCounts.sockets;
metrics.resources.servers = resourceCounts.servers;
}
/**
* Track process memory usage
*/
function trackProcessMemory() {
const memUsage = process.memoryUsage();
const now = Date.now();
metrics.process.memory.heapUsed.push(memUsage.heapUsed);
metrics.process.memory.heapTotal.push(memUsage.heapTotal);
metrics.process.memory.rss.push(memUsage.rss);
metrics.process.memory.external.push(memUsage.external || 0);
metrics.process.memory.arrayBuffers.push(memUsage.arrayBuffers || 0);
metrics.process.memory.timestamp.push(now);
}
/**
* Track CPU usage
*/
function trackCPUUsage() {
const now = Date.now();
// Initialize if this is the first call
if (!metrics.process.lastCpuTime || !metrics.process.lastCpuUsage) {
metrics.process.lastCpuUsage = process.cpuUsage();
metrics.process.lastCpuTime = now;
return; // Skip first measurement
}
const currentCpuUsage = process.cpuUsage(metrics.process.lastCpuUsage);
const elapsed = (now - metrics.process.lastCpuTime) * 1000; // Convert to microseconds
// Only calculate if enough time has passed (avoid division by very small numbers)
if (elapsed > 1000) { // At least 1ms in microseconds
const userPercent = (currentCpuUsage.user / elapsed) * 100;
const systemPercent = (currentCpuUsage.system / elapsed) * 100;
const totalPercent = userPercent + systemPercent;
metrics.process.cpu.user.push(currentCpuUsage.user);
metrics.process.cpu.system.push(currentCpuUsage.system);
metrics.process.cpu.percentage.push(Math.min(totalPercent, 100)); // Cap at 100%
metrics.process.cpu.timestamp.push(now);
}
// Always update for next calculation
metrics.process.lastCpuUsage = process.cpuUsage();
metrics.process.lastCpuTime = now;
}
/**
* Track event loop lag - approximate by measuring time between collection intervals
*/
function trackEventLoopLag() {
const now = performance.now();
const expectedInterval = 5000; // Expected interval in ms
const actualElapsed = now - (metrics.process.eventLoop.lastCheck || now);
const lag = Math.max(0, actualElapsed - expectedInterval);
metrics.process.eventLoop.lag.push(lag);
metrics.process.eventLoop.timestamp.push(Date.now());
metrics.process.eventLoop.lastCheck = now;
}
/**
* Track system resources
*/
function trackSystemResources() {
const now = Date.now();
const freeMem = os.freemem();
const totalMem = metrics.process.system.totalMemory;
const usedMem = totalMem - freeMem;
const usedMemPercent = (usedMem / totalMem) * 100;
// Get load average (1 minute average)
const loadAvg = os.loadavg();
metrics.process.system.freeMemory.push(freeMem);
metrics.process.system.usedMemoryPercentage.push(usedMemPercent);
metrics.process.system.loadAverage.push(loadAvg[0] || 0);
metrics.process.system.timestamp.push(now);
// Track active handles (approximate via _getActiveHandles if available)
try {
const activeHandles = process._getActiveHandles ? process._getActiveHandles().length : 0;
metrics.process.handles.active.push(activeHandles);
metrics.process.handles.timestamp.push(now);
} catch (e) {
// _getActiveHandles might not be available in all contexts
metrics.process.handles.active.push(0);
metrics.process.handles.timestamp.push(now);
}
}
/**
* Collect all process metrics
*/
function collectProcessMetrics() {
trackProcessMemory();
trackCPUUsage();
trackEventLoopLag();
trackSystemResources();
}
/**
* Start periodic collection of process metrics
* @param {number} intervalMs - Collection interval in milliseconds (default: 5000)
*/
function startProcessMetricsCollection(intervalMs = 5000) {
// Clear existing interval if any
if (metrics.process.collectionInterval) {
clearInterval(metrics.process.collectionInterval);
}
// Initial collection
collectProcessMetrics();
// Set up periodic collection
metrics.process.collectionInterval = setInterval(() => {
collectProcessMetrics();
}, intervalMs);
}
/**
* Stop periodic collection of process metrics
*/
function stopProcessMetricsCollection() {
if (metrics.process.collectionInterval) {
clearInterval(metrics.process.collectionInterval);
metrics.process.collectionInterval = null;
}
if (aggregationInterval) {
clearInterval(aggregationInterval);
aggregationInterval = null;
}
}
/**
* Aggregate metrics to reduce memory usage
*/
function aggregateMetrics() {
try {
const now = Date.now();
// Aggregate request metrics
const requestTimes = metrics.requests.responseTimes.getData();
if (requestTimes.length > 0) {
aggregatedMetrics.requests.avgResponseTime = requestTimes.reduce((a, b) => a + b, 0) / requestTimes.length;
aggregatedMetrics.requests.minResponseTime = Math.min(...requestTimes);
aggregatedMetrics.requests.maxResponseTime = Math.max(...requestTimes);
}
aggregatedMetrics.requests.total = metrics.requests.total;
aggregatedMetrics.requests.successful = metrics.requests.successful;
aggregatedMetrics.requests.failed = metrics.requests.failed;
aggregatedMetrics.requests.lastUpdated = now;
// Aggregate DNS metrics
const dnsTimes = metrics.dns.responseTimes.getData();
if (dnsTimes.length > 0) {
aggregatedMetrics.dns.avgResponseTime = dnsTimes.reduce((a, b) => a + b, 0) / dnsTimes.length;
}
aggregatedMetrics.dns.queries = metrics.dns.queries;
aggregatedMetrics.dns.p2pResolutions = metrics.dns.p2pResolutions;
aggregatedMetrics.dns.publicResolutions = metrics.dns.publicResolutions;
aggregatedMetrics.dns.failures = metrics.dns.failures;
aggregatedMetrics.dns.lastUpdated = now;
// Clean up old data based on retention period
const cutoff = now - METRICS_CONFIG.retentionPeriod;
cleanOldMetrics(cutoff);
} catch (err) {
const { logError } = require('../infrastructure/logger');
logError('Metrics', `Error aggregating metrics: ${err.message}`);
}
}
/**
* Clean up metrics older than cutoff timestamp
*/
function cleanOldMetrics(cutoff) {
// Clean up timestamps and corresponding data
const cleanBuffer = (buffer, timestampBuffer) => {
const timestamps = timestampBuffer.getData();
const data = buffer.getData();
const filtered = timestamps.map((ts, idx) => ts >= cutoff ? data[idx] : null).filter(v => v !== null);
buffer.buffer = filtered;
buffer.head = 0;
};
// Clean request metrics
cleanBuffer(metrics.requests.responseTimes, metrics.requests.timestamp);
// Clean DNS metrics
cleanBuffer(metrics.dns.responseTimes, metrics.dns.queryTimestamps);
}
/**
* Start metrics aggregation
*/
function startMetricsAggregation() {
if (aggregationInterval) {
clearInterval(aggregationInterval);
}
// Initial aggregation
aggregateMetrics();
// Schedule periodic aggregation
aggregationInterval = setInterval(() => {
aggregateMetrics();
}, METRICS_CONFIG.aggregationInterval);
}
/**
* Get all metrics
* @returns {object} - Metrics object
*/
function getMetrics() {
try {
// Update current counts from state
metrics.peers.current = state.connectedPeers?.size || 0;
metrics.domains.current = state.domainToIPMap?.size || 0;
metrics.holesail.activeConnections = state.holesails?.size || 0;
// Update resource metrics
updateResourceMetrics();
// Always collect fresh process metrics when stats are requested
// This ensures real-time data regardless of collection interval
collectProcessMetrics();
const uptime = Date.now() - metrics.startTime;
metrics.lastUpdate = Date.now();
// Calculate statistics
const dnsResponseTimes = metrics.dns.responseTimes ? metrics.dns.responseTimes.getData() : [];
const requestResponseTimes = metrics.requests.responseTimes ? metrics.requests.responseTimes.getData() : [];
return {
system: {
uptime: {
ms: uptime,
seconds: Math.floor(uptime / 1000),
minutes: Math.floor(uptime / 60000),
hours: Math.floor(uptime / 3600000),
days: Math.floor(uptime / 86400000)
},
isMaster: state.isMaster || false,
isConnected: !!state.dnsPass,
startTime: metrics.startTime,
lastUpdate: metrics.lastUpdate
},
requests: {
total: metrics.requests.total,
successful: metrics.requests.successful,
failed: metrics.requests.failed,
successRate: metrics.requests.total > 0
? (metrics.requests.successful / metrics.requests.total * 100).toFixed(2)
: 0,
avgResponseTime: requestResponseTimes.length > 0
? (requestResponseTimes.reduce((a, b) => a + b, 0) / requestResponseTimes.length).toFixed(2)
: 0,
minResponseTime: requestResponseTimes.length > 0 ? Math.min(...requestResponseTimes) : 0,
maxResponseTime: requestResponseTimes.length > 0 ? Math.max(...requestResponseTimes) : 0,
byEndpoint: metrics.requests.byEndpoint && metrics.requests.byEndpoint.size > 0
? Object.fromEntries(
Array.from(metrics.requests.byEndpoint.entries()).map(([k, v]) => [
k,
{ ...v, successRate: v.total > 0 ? (v.successful / v.total * 100).toFixed(2) : 0 }
])
)
: {}
},
dns: {
queries: metrics.dns.queries,
p2pResolutions: metrics.dns.p2pResolutions,
publicResolutions: metrics.dns.publicResolutions,
failures: metrics.dns.failures,
successRate: metrics.dns.queries > 0
? ((metrics.dns.queries - metrics.dns.failures) / metrics.dns.queries * 100).toFixed(2)
: 0,
p2pRate: metrics.dns.queries > 0
? (metrics.dns.p2pResolutions / metrics.dns.queries * 100).toFixed(2)
: 0,
avgResponseTime: dnsResponseTimes.length > 0
? (dnsResponseTimes.reduce((a, b) => a + b, 0) / dnsResponseTimes.length).toFixed(2)
: 0,
minResponseTime: dnsResponseTimes.length > 0 ? Math.min(...dnsResponseTimes) : 0,
maxResponseTime: dnsResponseTimes.length > 0 ? Math.max(...dnsResponseTimes) : 0,
topDomains: metrics.dns.domainQueries && metrics.dns.domainQueries.size > 0
? Array.from(metrics.dns.domainQueries.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }))
: []
},
peers: {
connected: metrics.peers.connected,
disconnected: metrics.peers.disconnected,
current: metrics.peers.current,
avgConnectionDuration: metrics.peers.connectionDurations && metrics.peers.connectionDurations.length() > 0
? (metrics.peers.connectionDurations.getData().reduce((a, b) => a + b, 0) / metrics.peers.connectionDurations.length()).toFixed(2)
: 0
},
domains: {
added: metrics.domains.added,
removed: metrics.domains.removed,
current: metrics.domains.current
},
consensus: {
resolutions: metrics.consensus.resolutions,
quorumFailures: metrics.consensus.quorumFailures,
ties: metrics.consensus.ties,
validationFailures: metrics.consensus.validationFailures,
totalVotes: metrics.consensus.totalVotes,
avgVotesPerDomain: metrics.consensus.avgVotesPerDomain > 0
? metrics.consensus.avgVotesPerDomain.toFixed(2)
: 0
},
holesail: {
clientsStarted: metrics.holesail.clientsStarted,
clientsStopped: metrics.holesail.clientsStopped,
serversStarted: metrics.holesail.serversStarted,
serversStopped: metrics.holesail.serversStopped,
activeConnections: metrics.holesail.activeConnections,
protocolCounts: metrics.holesail.protocolCounts,
avgConnectionDuration: metrics.holesail.connectionDurations && metrics.holesail.connectionDurations.length() > 0
? (metrics.holesail.connectionDurations.getData().reduce((a, b) => a + b, 0) / metrics.holesail.connectionDurations.length()).toFixed(2)
: 0
},
resources: {
sockets: metrics.resources.sockets,
timers: metrics.resources.timers,
intervals: metrics.resources.intervals,
servers: metrics.resources.servers
},
process: (() => {
// Always get fresh process metrics for current values
const memUsage = process.memoryUsage();
const freeMem = os.freemem();
const totalMem = metrics.process.system.totalMemory;
const usedMem = totalMem - freeMem;
const usedMemPercent = (usedMem / totalMem) * 100;
// Calculate current CPU usage - need a meaningful time window
const now = Date.now();
let currentCpuPercent = '0.00';
if (metrics.process.lastCpuTime && metrics.process.lastCpuUsage) {
const elapsedMs = now - metrics.process.lastCpuTime;
if (elapsedMs > 100) { // Only calculate if enough time has passed (>100ms)
const currentCpuUsage = process.cpuUsage(metrics.process.lastCpuUsage);
const elapsed = elapsedMs * 1000; // Convert to microseconds
const userPercent = elapsed > 0 ? (currentCpuUsage.user / elapsed) * 100 : 0;
const systemPercent = elapsed > 0 ? (currentCpuUsage.system / elapsed) * 100 : 0;
currentCpuPercent = Math.min(userPercent + systemPercent, 100).toFixed(2);
} else {
// Use the latest tracked percentage if available
if (metrics.process.cpu.percentage.length() > 0) {
currentCpuPercent = metrics.process.cpu.percentage.getLatest(1)[0]?.toFixed(2) || '0.00';
}
}
} else {
// First time - use latest if available
if (metrics.process.cpu.percentage.length() > 0) {
currentCpuPercent = metrics.process.cpu.percentage.getLatest(1)[0]?.toFixed(2) || '0.00';
}
}
// Get fresh active handles count
let activeHandles = 0;
try {
activeHandles = process._getActiveHandles ? process._getActiveHandles().length : 0;
} catch (e) {
// Ignore if not available
}
return {
memory: {
current: memUsage,
heapUsedMB: (memUsage.heapUsed / 1024 / 1024).toFixed(2),
heapTotalMB: (memUsage.heapTotal / 1024 / 1024).toFixed(2),
rssMB: (memUsage.rss / 1024 / 1024).toFixed(2),
externalMB: ((memUsage.external || 0) / 1024 / 1024).toFixed(2),
arrayBuffersMB: ((memUsage.arrayBuffers || 0) / 1024 / 1024).toFixed(2)
},
cpu: {
current: process.cpuUsage(),
percentage: currentCpuPercent, // Already a string from .toFixed(2) above
avgPercentage: metrics.process.cpu.percentage.length() > 0
? (metrics.process.cpu.percentage.getData().reduce((a, b) => a + b, 0) / metrics.process.cpu.percentage.length()).toFixed(2)
: '0.00'
},
eventLoop: {
currentLag: metrics.process.eventLoop.lag.length() > 0
? metrics.process.eventLoop.lag.getLatest(1)[0]?.toFixed(2) || '0.00'
: '0.00',
avgLag: metrics.process.eventLoop.lag.length() > 0
? (metrics.process.eventLoop.lag.getData().reduce((a, b) => a + b, 0) / metrics.process.eventLoop.lag.length()).toFixed(2)
: '0.00',
maxLag: metrics.process.eventLoop.lag.length() > 0
? Math.max(...metrics.process.eventLoop.lag.getData()).toFixed(2)
: '0.00'
},
system: {
totalMemoryMB: (totalMem / 1024 / 1024).toFixed(2),
freeMemoryMB: (freeMem / 1024 / 1024).toFixed(2),
usedMemoryPercent: usedMemPercent.toFixed(2),
loadAverage: os.loadavg()[0]?.toFixed(2) || '0.00'
},
handles: {
active: activeHandles,
avgActive: metrics.process.handles.active.length() > 0
? Math.round(metrics.process.handles.active.getData().reduce((a, b) => a + b, 0) / metrics.process.handles.active.length())
: 0
},
uptime: process.uptime(),
nodeVersion: process.version,
platform: process.platform,
arch: process.arch
};
})()
};
} catch (err) {
// Return minimal error response if metrics collection fails
const { logError } = require('./logger');
logError('Metrics', `Error in getMetrics: ${err.message}`, err);
return {
system: {
uptime: { ms: 0, seconds: 0, minutes: 0, hours: 0, days: 0 },
isMaster: false,
isConnected: false,
startTime: metrics.startTime || Date.now(),
lastUpdate: Date.now(),
error: err.message
},
requests: { total: 0, successful: 0, failed: 0, successRate: 0, byEndpoint: {} },
dns: { queries: 0, p2pResolutions: 0, publicResolutions: 0, failures: 0, successRate: 0, topDomains: [] },
peers: { connected: 0, disconnected: 0, current: 0, avgConnectionDuration: 0 },
domains: { added: 0, removed: 0, current: 0 },
holesail: { clientsStarted: 0, clientsStopped: 0, serversStarted: 0, serversStopped: 0, activeConnections: 0, protocolCounts: { tcp: 0, udp: 0 }, avgConnectionDuration: 0 },
resources: { sockets: 0, timers: 0, intervals: 0, servers: 0 }
};
}
}
/**
* Get historical time-series data
* @param {number} minutes - Number of minutes of history to return
* @returns {object} - Time-series data
*/
function getHistoricalData(minutes = 60) {
const now = Date.now();
const cutoff = now - (minutes * 60 * 1000);
try {
const dnsTimestamps = metrics.dns.queryTimestamps.getData();
const dnsTypes = metrics.dns.queryTypes.getData();
const dnsResponseTimes = metrics.dns.responseTimes.getData();
const filteredDnsTimestamps = dnsTimestamps.filter(ts => ts >= cutoff);
const filteredCount = filteredDnsTimestamps.length;
// Filter process metrics by timestamp
const memTimestamps = metrics.process.memory.timestamp.getData();
const memFilteredIndices = memTimestamps.map((ts, idx) => ts >= cutoff ? idx : -1).filter(idx => idx !== -1);
return {
dns: {
queryTimestamps: filteredDnsTimestamps,
queryTypes: dnsTypes.slice(-filteredCount),
responseTimes: dnsResponseTimes.slice(-filteredCount)
},
requests: {
timestamps: metrics.requests.timestamp.getData().filter(ts => ts >= cutoff),
responseTimes: metrics.requests.responseTimes.getData().slice(-metrics.requests.timestamp.length())
},
peers: {
events: metrics.peers.connectionEvents.getData().filter(e => e && e.timestamp && e.timestamp >= cutoff)
},
domains: {
addEvents: metrics.domains.addEvents.getData().filter(e => e && e.timestamp && e.timestamp >= cutoff),
removeEvents: metrics.domains.removeEvents.getData().filter(e => e && e.timestamp && e.timestamp >= cutoff)
},
holesail: {
events: metrics.holesail.connectionEvents.getData().filter(e => e && e.timestamp && e.timestamp >= cutoff)
},
resources: {
dataPoints: metrics.resources.dataPoints.getData().filter(dp => dp && dp.timestamp && dp.timestamp >= cutoff)
},
process: {
memory: {
heapUsed: metrics.process.memory.heapUsed.getData().filter((_, idx) => memFilteredIndices.includes(idx)),
heapTotal: metrics.process.memory.heapTotal.getData().filter((_, idx) => memFilteredIndices.includes(idx)),
rss: metrics.process.memory.rss.getData().filter((_, idx) => memFilteredIndices.includes(idx)),
timestamps: memTimestamps.filter(ts => ts >= cutoff)
},
cpu: {
percentage: metrics.process.cpu.percentage.getData().filter((_, idx) => {
const cpuTimestamps = metrics.process.cpu.timestamp.getData();
return cpuTimestamps[idx] && cpuTimestamps[idx] >= cutoff;
}),
timestamps: metrics.process.cpu.timestamp.getData().filter(ts => ts >= cutoff)
},
eventLoop: {
lag: metrics.process.eventLoop.lag.getData().filter((_, idx) => {
const loopTimestamps = metrics.process.eventLoop.timestamp.getData();
return loopTimestamps[idx] && loopTimestamps[idx] >= cutoff;
}),
timestamps: metrics.process.eventLoop.timestamp.getData().filter(ts => ts >= cutoff)
},
system: {
freeMemory: metrics.process.system.freeMemory.getData().filter((_, idx) => {
const sysTimestamps = metrics.process.system.timestamp.getData();
return sysTimestamps[idx] && sysTimestamps[idx] >= cutoff;
}),
usedMemoryPercent: metrics.process.system.usedMemoryPercentage.getData().filter((_, idx) => {
const sysTimestamps = metrics.process.system.timestamp.getData();
return sysTimestamps[idx] && sysTimestamps[idx] >= cutoff;
}),
loadAverage: metrics.process.system.loadAverage.getData().filter((_, idx) => {
const sysTimestamps = metrics.process.system.timestamp.getData();
return sysTimestamps[idx] && sysTimestamps[idx] >= cutoff;
}),
timestamps: metrics.process.system.timestamp.getData().filter(ts => ts >= cutoff)
},
handles: {
active: metrics.process.handles.active.getData().filter((_, idx) => {
const handleTimestamps = metrics.process.handles.timestamp.getData();
return handleTimestamps[idx] && handleTimestamps[idx] >= cutoff;
}),
timestamps: metrics.process.handles.timestamp.getData().filter(ts => ts >= cutoff)
}
}
};
} catch (err) {
// Return empty data structure if there's an error
return {
dns: { queryTimestamps: [], queryTypes: [], responseTimes: [] },
requests: { timestamps: [], responseTimes: [] },
peers: { events: [] },
domains: { addEvents: [], removeEvents: [] },
holesail: { events: [] },
resources: { dataPoints: [] },
process: {
memory: { heapUsed: [], heapTotal: [], rss: [], timestamps: [] },
cpu: { percentage: [], timestamps: [] },
eventLoop: { lag: [], timestamps: [] },
system: { freeMemory: [], usedMemoryPercent: [], loadAverage: [], timestamps: [] },
handles: { active: [], timestamps: [] }
}
};
}
}
/**
* Track a request
* @param {string} endpoint - API endpoint
* @param {boolean} success - Whether request was successful
*/
function trackRequest(endpoint, success) {
increment('requests', 'total');
if (success) {
increment('requests', 'successful');
} else {
increment('requests', 'failed');
}
// Limit byEndpoint map size
if (!metrics.requests.byEndpoint.has(endpoint)) {
// If map is at limit, remove oldest entries (simple FIFO by removing first key)
if (metrics.requests.byEndpoint.size >= metrics.requests.maxEndpoints) {
const firstKey = metrics.requests.byEndpoint.keys().next().value;
if (firstKey) {
metrics.requests.byEndpoint.delete(firstKey);
}
}
metrics.requests.byEndpoint.set(endpoint, { total: 0, successful: 0, failed: 0 });
}
const endpointMetrics = metrics.requests.byEndpoint.get(endpoint);
endpointMetrics.total++;
if (success) {
endpointMetrics.successful++;
} else {
endpointMetrics.failed++;
}
}
/**
* Track DNS query
* @param {string} type - Query type ('p2p', 'public', 'failure')
*/
function trackDNSQuery(type) {
increment('dns', 'queries');
if (type === 'p2p') {
increment('dns', 'p2pResolutions');
} else if (type === 'public') {
increment('dns', 'publicResolutions');
} else if (type === 'failure') {
increment('dns', 'failures');
}
}
// Start aggregation on module load
startMetricsAggregation();
module.exports = {
increment,
getMetrics,
getHistoricalData,
trackRequest,
trackRequestWithTiming,
trackDNSQuery,
trackDNSQueryWithTiming,
trackPeerEvent,
trackDomainEvent,
trackConsensusEvent,
trackHolesailEvent,
updateResourceMetrics,
collectProcessMetrics,
startProcessMetricsCollection,
stopProcessMetricsCollection,
aggregateMetrics,
startMetricsAggregation,
metrics,
aggregatedMetrics
};
+125
View File
@@ -0,0 +1,125 @@
const { logDebug, logWarn } = require('../infrastructure/logger');
// Resource tracking for proper cleanup
class ResourceTracker {
constructor() {
this.resources = new Map();
this.resourceId = 0;
}
/**
* Register a resource for tracking
* @param {string} type - Resource type (socket, timer, interval, server, etc.)
* @param {object} resource - Resource object
* @param {function} cleanupFn - Cleanup function
* @returns {number} - Resource ID
*/
register(type, resource, cleanupFn) {
const id = ++this.resourceId;
this.resources.set(id, {
type,
resource,
cleanupFn,
registered: Date.now()
});
logDebug('ResourceTracker', `Registered ${type} resource (ID: ${id})`);
return id;
}
/**
* Unregister a resource
* @param {number} id - Resource ID
*/
unregister(id) {
if (this.resources.has(id)) {
const info = this.resources.get(id);
logDebug('ResourceTracker', `Unregistered ${info.type} resource (ID: ${id})`);
this.resources.delete(id);
}
}
/**
* Cleanup a specific resource
* @param {number} id - Resource ID
* @returns {Promise<boolean>} - Success status
*/
async cleanup(id) {
if (!this.resources.has(id)) {
return false;
}
const info = this.resources.get(id);
try {
if (info.cleanupFn) {
await info.cleanupFn();
} else if (info.resource) {
// Default cleanup based on type
if (info.type === 'timer' || info.type === 'interval') {
clearTimeout(info.resource);
clearInterval(info.resource);
} else if (typeof info.resource.close === 'function') {
await new Promise(resolve => {
info.resource.close(resolve);
setTimeout(resolve, 5000); // Timeout
});
} else if (typeof info.resource.destroy === 'function') {
info.resource.destroy();
}
}
this.resources.delete(id);
logDebug('ResourceTracker', `Cleaned up ${info.type} resource (ID: ${id})`);
return true;
} catch (err) {
logWarn('ResourceTracker', `Error cleaning up ${info.type} resource (ID: ${id}): ${err.message}`);
return false;
}
}
/**
* Cleanup all resources
* @returns {Promise<number>} - Number of resources cleaned up
*/
async cleanupAll() {
const ids = Array.from(this.resources.keys());
let cleaned = 0;
for (const id of ids) {
if (await this.cleanup(id)) {
cleaned++;
}
}
logDebug('ResourceTracker', `Cleaned up ${cleaned}/${ids.length} resources`);
return cleaned;
}
/**
* Get all registered resources
* @returns {Array} - Array of resource info
*/
list() {
return Array.from(this.resources.entries()).map(([id, info]) => ({
id,
...info,
age: Date.now() - info.registered
}));
}
/**
* Get count of resources by type
* @returns {object} - Counts by type
*/
getCounts() {
const counts = {};
for (const info of this.resources.values()) {
counts[info.type] = (counts[info.type] || 0) + 1;
}
return counts;
}
}
// Global resource tracker instance
const resourceTracker = new ResourceTracker();
module.exports = { resourceTracker, ResourceTracker };
+251
View File
@@ -0,0 +1,251 @@
const state = require('../infrastructure/state');
const { logDebug, logWarn, logInfo } = require('../infrastructure/logger');
const dgram = require('dgram');
const { parseMinutesToMs, secondsToMs } = require('../infrastructure/utils');
let validationInterval = null;
const VALIDATION_INTERVAL_MS = parseMinutesToMs(process.env.RESOURCE_VALIDATION_INTERVAL || '5'); // Default 5 minutes
/**
* Validate state maps for stale entries and clean them up
*/
function validateStateMaps() {
let cleanedCount = 0;
try {
// Validate holesails map - check if connections are still valid
const holesailKeysToRemove = [];
for (const [key, holesail] of state.holesails.entries()) {
try {
// Check if it's a UDP socket
if (holesail instanceof dgram.Socket) {
// UDP sockets don't have a destroyed property, check if they're closed
if (holesail.closed) {
holesailKeysToRemove.push(key);
}
} else {
// For TCP connections, check if destroyed or closed
if (holesail.destroyed || (holesail.readyState && holesail.readyState === 'closed')) {
holesailKeysToRemove.push(key);
}
}
} catch (err) {
// If we can't check the connection, assume it's stale
logWarn('ResourceValidation', `Error checking holesail connection ${key}: ${err.message}`);
holesailKeysToRemove.push(key);
}
}
// Remove stale holesail connections
for (const key of holesailKeysToRemove) {
logDebug('ResourceValidation', `Removing stale holesail connection: ${key}`);
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
cleanedCount++;
// Also clean up associated resources
const [domain, port] = key.split(':');
if (state.tlsServers.has(key)) {
try {
const tlsServer = state.tlsServers.get(key);
if (tlsServer && typeof tlsServer.close === 'function') {
tlsServer.close();
}
} catch (err) {
logDebug('ResourceValidation', `Error closing TLS server for ${key}: ${err.message}`);
}
state.tlsServers.delete(key);
}
if (state.httpServers.has(key)) {
try {
const httpServer = state.httpServers.get(key);
if (httpServer && typeof httpServer.close === 'function') {
httpServer.close();
}
} catch (err) {
logDebug('ResourceValidation', `Error closing HTTP server for ${key}: ${err.message}`);
}
state.httpServers.delete(key);
}
// Clear timeout if exists
if (state.holesailClientTimeouts.has(key)) {
const timeout = state.holesailClientTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
}
state.holesailClientTimeouts.delete(key);
}
// Remove from persistent connections
if (state.persistentConnections) {
state.persistentConnections.delete(key);
}
}
// Validate TLS servers - check if they're still listening
const tlsKeysToRemove = [];
for (const [key, tlsServer] of state.tlsServers.entries()) {
try {
// Check if server is closed or destroyed
if (tlsServer && (tlsServer.destroyed || (tlsServer.listening === false && !tlsServer.pending))) {
tlsKeysToRemove.push(key);
}
} catch (err) {
logWarn('ResourceValidation', `Error checking TLS server ${key}: ${err.message}`);
tlsKeysToRemove.push(key);
}
}
for (const key of tlsKeysToRemove) {
logDebug('ResourceValidation', `Removing stale TLS server: ${key}`);
state.tlsServers.delete(key);
cleanedCount++;
}
// Validate HTTP servers - check if they're still listening
const httpKeysToRemove = [];
for (const [key, httpServer] of state.httpServers.entries()) {
try {
// Check if server is closed or destroyed
if (httpServer && (httpServer.destroyed || (httpServer.listening === false && !httpServer.pending))) {
httpKeysToRemove.push(key);
}
} catch (err) {
logWarn('ResourceValidation', `Error checking HTTP server ${key}: ${err.message}`);
httpKeysToRemove.push(key);
}
}
for (const key of httpKeysToRemove) {
logDebug('ResourceValidation', `Removing stale HTTP server: ${key}`);
state.httpServers.delete(key);
cleanedCount++;
}
// Validate child processes - check if they're still alive
const childKeysToRemove = [];
for (const [id, child] of state.holesailChildren.entries()) {
try {
// Check if process has exited
if (child.killed || child.exitCode !== null) {
childKeysToRemove.push(id);
}
} catch (err) {
logWarn('ResourceValidation', `Error checking child process ${id}: ${err.message}`);
childKeysToRemove.push(id);
}
}
for (const id of childKeysToRemove) {
logDebug('ResourceValidation', `Removing stale child process entry: ${id}`);
state.holesailChildren.delete(id);
if (state.holesailOpts.has(id)) {
state.holesailOpts.delete(id);
}
if (state.holesailInfos.has(id)) {
state.holesailInfos.delete(id);
}
if (state.holesailChildStartTimes && state.holesailChildStartTimes.has(id)) {
state.holesailChildStartTimes.delete(id);
}
cleanedCount++;
}
// Validate client child processes
const clientChildKeysToRemove = [];
for (const [id, child] of state.holesailClientChildren.entries()) {
try {
if (child.killed || child.exitCode !== null) {
clientChildKeysToRemove.push(id);
}
} catch (err) {
logWarn('ResourceValidation', `Error checking client child process ${id}: ${err.message}`);
clientChildKeysToRemove.push(id);
}
}
for (const id of clientChildKeysToRemove) {
logDebug('ResourceValidation', `Removing stale client child process entry: ${id}`);
state.holesailClientChildren.delete(id);
if (state.holesailClientOpts.has(id)) {
state.holesailClientOpts.delete(id);
}
if (state.holesailClientInfos.has(id)) {
state.holesailClientInfos.delete(id);
}
cleanedCount++;
}
// Validate timeout map - remove entries for connections that no longer exist
const timeoutKeysToRemove = [];
for (const [key, timeout] of state.holesailClientTimeouts.entries()) {
if (!state.holesails.has(key)) {
timeoutKeysToRemove.push(key);
}
}
for (const key of timeoutKeysToRemove) {
logDebug('ResourceValidation', `Clearing orphaned timeout for: ${key}`);
const timeout = state.holesailClientTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
}
state.holesailClientTimeouts.delete(key);
cleanedCount++;
}
// Log summary
if (cleanedCount > 0) {
logInfo('ResourceValidation', `Cleaned up ${cleanedCount} stale resource(s)`);
logDebug('ResourceValidation', `State map sizes: holesails=${state.holesails.size}, tlsServers=${state.tlsServers.size}, httpServers=${state.httpServers.size}, children=${state.holesailChildren.size}, clientChildren=${state.holesailClientChildren.size}`);
} else {
logDebug('ResourceValidation', 'No stale resources found');
}
} catch (err) {
logWarn('ResourceValidation', `Error during resource validation: ${err.message}`);
}
}
/**
* Start periodic resource validation
*/
function startResourceValidation() {
if (validationInterval) {
clearInterval(validationInterval);
}
// Run initial validation after a short delay
setTimeout(() => {
validateStateMaps();
}, secondsToMs(60)); // Wait 1 minute before first validation
// Schedule periodic validation
validationInterval = setInterval(() => {
validateStateMaps();
}, VALIDATION_INTERVAL_MS);
logInfo('ResourceValidation', `Started periodic resource validation (interval: ${VALIDATION_INTERVAL_MS}ms)`);
}
/**
* Stop periodic resource validation
*/
function stopResourceValidation() {
if (validationInterval) {
clearInterval(validationInterval);
validationInterval = null;
logInfo('ResourceValidation', 'Stopped periodic resource validation');
}
}
module.exports = {
validateStateMaps,
startResourceValidation,
stopResourceValidation
};