const fs = require('fs').promises; const state = require('../../infrastructure/state'); const { logError, logWarn } = require('../../infrastructure/logger'); const { getAvailableIPsForSubnet } = require('../../networking/virtual_interfaces'); const { settingsMetadata, restartRequiredSettings, liveReloadableSettings, envWhitelist, applyLiveSettings } = require('../settings'); const { broadcast } = require('../websocket'); async function handleSettingsRoutes(req, res) { const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname; const method = req.method; if (method === 'GET' && urlPath === '/api/settings') { try { const settings = {}; const metadata = {}; envWhitelist.forEach(key => { const value = process.env[key] || ''; settings[key] = value; if (settingsMetadata[key]) { metadata[key] = { ...settingsMetadata[key], currentValue: value }; } }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ settings, metadata })); } catch (err) { logError('Admin', `Failed to fetch settings: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch settings' })); } return true; } if (method === 'GET' && urlPath === '/api/subnets') { try { let subnets = []; if (process.env.SUBNETS) { try { subnets = JSON.parse(process.env.SUBNETS); if (!Array.isArray(subnets)) { subnets = []; } } catch (err) { logWarn('Admin', `Failed to parse SUBNETS: ${err.message}`); subnets = []; } } if (subnets.length === 0) { const subnetBase = process.env.SUBNET_BASE || '192.168.3'; const baseParts = subnetBase.split('.'); if (baseParts.length === 3) { subnets = [{ base: `${subnetBase}.0`, cidr: 24, startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10), name: 'Default Subnet' }]; } } const subnetInfo = subnets.map((subnet, index) => { const available = getAvailableIPsForSubnet(subnet); const used = Array.from(state.domainToIPMap.values()).filter(ip => { const ipParts = ip.split('.'); const subnetParts = subnet.base.split('.'); return ipParts[0] === subnetParts[0] && ipParts[1] === subnetParts[1] && ipParts[2] === subnetParts[2]; }).length; return { ...subnet, index, available, used, remaining: Math.max(0, available - used) }; }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ subnets: subnetInfo })); } catch (err) { logError('Admin', `Failed to fetch subnets: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch subnets' })); } return true; } if (method === 'POST' && urlPath === '/api/subnets') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { subnets } = JSON.parse(body); if (!Array.isArray(subnets)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'subnets must be an array' })); return; } const errors = []; subnets.forEach((subnet, index) => { if (!subnet || typeof subnet !== 'object') { errors.push(`subnets[${index}]: must be an object`); return; } if (!subnet.base || typeof subnet.base !== 'string') { errors.push(`subnets[${index}]: base is required and must be a string`); } else if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(subnet.base)) { errors.push(`subnets[${index}]: base must be a valid IPv4 address`); } const cidr = parseInt(subnet.cidr, 10); if (isNaN(cidr) || cidr < 1 || cidr > 32) { errors.push(`subnets[${index}]: cidr must be between 1 and 32`); } const startIndex = parseInt(subnet.startIndex || process.env.INITIAL_IP_INDEX || '2', 10); const maxIPs = Math.pow(2, 32 - cidr) - 2; if (isNaN(startIndex) || startIndex < 1 || startIndex > Math.min(254, maxIPs)) { errors.push(`subnets[${index}]: startIndex must be between 1 and ${Math.min(254, maxIPs)}`); } if (!subnet.name || typeof subnet.name !== 'string') { subnet.name = `Subnet ${index + 1}`; } }); if (errors.length > 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Validation failed', errors })); return; } process.env.SUBNETS = JSON.stringify(subnets); const envContent = envWhitelist.map(key => { if (key === 'SUBNETS') { return `${key}=${JSON.stringify(subnets)}`; } return `${key}=${process.env[key] || ''}`; }).join('\n'); await fs.writeFile('.env', envContent); state.subnets = subnets; state.currentSubnetIndex = 0; state.subnetIPCounters.clear(); subnets.forEach((subnet, index) => { state.subnetIPCounters.set(index, subnet.startIndex || parseInt(process.env.INITIAL_IP_INDEX || '2', 10)); }); broadcast({ type: 'update-settings' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'Subnets updated. Restart required to fully apply changes.', restartRequired: true })); } catch (err) { logError('Admin', `Failed to update subnets: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: err.message })); } }); return true; } if (method === 'POST' && urlPath === '/api/update-settings') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { settings } = JSON.parse(body); const errors = []; for (const [key, value] of Object.entries(settings)) { if (!envWhitelist.includes(key)) { errors.push(`Setting ${key} is not whitelisted`); continue; } const meta = settingsMetadata[key]; if (meta) { if (meta.type === 'number') { // Use parseFloat to support both integers and floating point numbers // parseFloat works for integers too (e.g., parseFloat("5") returns 5) const numValue = parseFloat(value); if (isNaN(numValue)) { errors.push(`${meta.label}: must be a number`); continue; } if (meta.min !== undefined && numValue < meta.min) { errors.push(`${meta.label}: must be at least ${meta.min}`); continue; } if (meta.max !== undefined && numValue > meta.max) { errors.push(`${meta.label}: must be at most ${meta.max}`); continue; } process.env[key] = numValue.toString(); } else if (meta.type === 'checkbox') { process.env[key] = (value === true || value === 'true' || value === '1') ? 'true' : 'false'; } else if (key === 'SUBNETS') { try { const subnets = typeof value === 'string' ? JSON.parse(value) : value; if (!Array.isArray(subnets)) { errors.push('SUBNETS must be an array'); continue; } process.env[key] = JSON.stringify(subnets); } catch (err) { errors.push(`SUBNETS: invalid JSON - ${err.message}`); continue; } } else { process.env[key] = value; } } else { if (key === 'SUBNETS') { try { const subnets = typeof value === 'string' ? JSON.parse(value) : value; if (!Array.isArray(subnets)) { errors.push('SUBNETS must be an array'); continue; } process.env[key] = JSON.stringify(subnets); } catch (err) { errors.push(`SUBNETS: invalid JSON - ${err.message}`); continue; } } else { process.env[key] = value; } } } if (errors.length > 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Validation failed', errors })); return; } const envContent = envWhitelist.map(key => { if (key === 'SUBNETS') { return `${key}=${process.env[key] || '[]'}`; } return `${key}=${process.env[key] || ''}`; }).join('\n'); await fs.writeFile('.env', envContent); const restartRequired = Object.keys(settings).some(key => restartRequiredSettings.includes(key)); const liveSettings = {}; for (const [key, value] of Object.entries(settings)) { if (liveReloadableSettings.includes(key)) { liveSettings[key] = value; } } if (Object.keys(liveSettings).length > 0) { await applyLiveSettings(liveSettings); } broadcast({ type: 'update-settings' }); if (restartRequired) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'Settings saved. Some settings require restart to take effect.', restartRequired: true, restartRequiredSettings: Object.keys(settings).filter(k => restartRequiredSettings.includes(k)) })); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'Settings saved and applied successfully.', restartRequired: false })); } } catch (err) { logError('Admin', `Failed to update settings: ${err.message}`); res.writeHead(500); res.end(err.message); } }); return true; } return false; } module.exports = { handleSettingsRoutes };