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; } // POST /api/admin/clean-dns-storage - Clean DNS Pass storage and reset initialization if (method === 'POST' && urlPath === '/api/admin/clean-dns-storage') { try { const { logInfo, logWarn, logError } = require('../../../infrastructure/logger'); // Safety check: warn if there are active connections if (state.connectedPeers && state.connectedPeers.size > 0) { logWarn('Admin', `Cleaning storage while ${state.connectedPeers.size} peers are connected - this may disrupt the network`); } // Safety check: warn if this is a master node with initialized DNS pass if (state.isMaster && state.dnsPass) { logWarn('Admin', 'Cleaning storage on a master node that has initialized DNS pass - other nodes may be affected'); } logWarn('Admin', 'Cleaning DNS Pass storage as requested by user'); // Get storage directory const storageDir = process.env.STORAGE_DIR || './my-storage'; // Clean the storage directory (same logic as --clean flag) try { await fs.rm(storageDir, { recursive: true, force: true }); logInfo('Admin', `Cleaned DNS Pass storage directory: ${storageDir}`); } catch (err) { // Ignore if directory doesn't exist if (err.code !== 'ENOENT') { throw err; } } // Reset state variables that depend on the storage state.dnsPass = null; state.consecutiveInviteFailures = 0; // Reset failure counter // Note: We don't reset the keypair or other persistent state, only the DNS Pass data logInfo('Admin', 'DNS Pass storage cleaned and state reset successfully'); // Send success response before shutting down res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, message: 'DNS Pass storage cleaned. Shutting down for reinitialization...', storageDir: storageDir, shutdownInitiated: true, restartInstructions: 'Process will shut down. Restart manually with: sudo node p2ns.js' + (state.isMaster ? ' --master' : '') })); // Trigger graceful shutdown after sending response // Add a longer delay to ensure storage cleanup is complete and state is reset setTimeout(() => { logInfo('Admin', 'Initiating graceful shutdown after DNS storage cleanup...'); logInfo('Admin', '🔄 SYSTEM IS SHUTTING DOWN - Storage has been cleaned and will reinitialize on restart'); process.emit('SIGTERM'); }, 3000); // Give more time for response to be sent and cleanup to complete } catch (err) { logError('Admin', `Failed to clean DNS storage: ${err.message}`); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `Failed to clean DNS storage: ${err.message}` })); } return true; } return false; } module.exports = { handleSettingsRoutes };