Files
p2ns/includes/admin/admin-backend/routes/settings.js
T
2026-05-27 22:43:41 -04:00

612 lines
23 KiB
JavaScript

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');
const { resetPluginStorage } = require('../../../../scripts/reset-plugin-storage');
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') {
// 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 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;
}
}
// Clean plugin HyperDB generated storage (same behavior as --clean)
let pluginResetSummary = null;
try {
pluginResetSummary = await resetPluginStorage({
logger: (level, message) => {
if (level === 'WARN') logWarn('Admin', message);
else if (level === 'ERROR') logError('Admin', message);
else logInfo('Admin', message);
}
});
logInfo('Admin', `Reset plugin spec/db directories for ${pluginResetSummary.resetCandidates} plugin(s)`);
} catch (pluginErr) {
logWarn('Admin', `Failed to reset plugin spec/db directories: ${pluginErr.message}`);
}
// 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 and plugin storage cleaned. Shutting down for reinitialization...',
storageDir: storageDir,
pluginResetSummary,
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 - DNS + plugin 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 };