const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const state = require('../../../infrastructure/state'); const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState, invalidateEntriesCache, removeOwnClaimAndVotes } = require('../../../core/core'); const { addDomain } = require('../../../core/domains'); const { validateDomainAddition, validateDomainRemoval } = require('../../../infrastructure/validation'); const { atomicDomainCleanup } = require('../../../core/domain_cleanup'); const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces'); const { logDebug, logError, logInfo } = require('../../../infrastructure/logger'); const { trackRequest } = require('../../../maintenance/metrics'); const { createErrorResponse } = require('../../../infrastructure/error_handler'); const { broadcast } = require('../websocket'); const { getPersistentPublicKey } = require('../../../infrastructure/utils'); const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json'; const filterSettingsFile = './cache/filterSettings.json'; async function handleDomainsRoutes(req, res) { const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname; const method = req.method; const url = new URL(req.url, `https://${req.headers.host}`); if (method === 'GET' && urlPath === '/api/resolved-domains') { try { // Use fresh data (bypass cache) to ensure latest consensus state const allEntries = await getAllEntries(state.dnsPass, false); const domainClaimants = new Map(); for (const entry of allEntries) { if (entry.key.startsWith('claim:')) { const parts = entry.key.split(':'); if (parts.length === 3) { const domain = parts[1]; const claimant = parts[2]; if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set()); domainClaimants.get(domain).add(claimant); } } } const localWriter = getPersistentPublicKey(); const domains = new Set(domainClaimants.keys()); const resolved = []; for (const domain of domains) { const hash = await getHashForDomain(domain) || 'none'; const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false; let isOwner = false; let consensusState = null; try { consensusState = await getConsensusState(domain); isOwner = localWriter ? consensusState.resolvedClaimant === localWriter : false; } catch (err) { logDebug('Admin', `Error checking ownership/consensus for ${domain}: ${err.message}`); } // Determine consensus status, including conflict detection let consensusStatus = null; if (consensusState) { if (isLocal && !isOwner && consensusState.status === 'resolved') { // Conflict: user has local claim but another claimant won consensusStatus = 'conflict'; } else { consensusStatus = consensusState.status; } } resolved.push({ domain, hash, isLocal, isOwner, consensusState, consensusStatus }); } let internalDomains = ['p2ns.admin']; try { const { getInternalDomains } = require('../../../plugins/plugin-handler'); internalDomains = await getInternalDomains(); } catch (err) { // Fallback if plugin system not available } // Only add internal domains that aren't already in the resolved list for (const d of internalDomains) { if (!resolved.some(r => r.domain === d)) { resolved.push({ domain: d, hash: 'internal', isLocal: true, isOwner: true, consensusStatus: 'internal' }); } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(resolved.sort((a, b) => a.domain.localeCompare(b.domain)))); } catch (err) { logError('Admin', `Failed to fetch resolved domains: ${err.message}`); res.writeHead(500); res.end(JSON.stringify({ error: 'Failed to fetch domains' })); } return true; } if (method === 'POST' && urlPath === '/api/add-domain') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const data = JSON.parse(body); const validation = validateDomainAddition(data); if (!validation.valid) { res.writeHead(400); res.end(validation.error || 'Invalid input'); return; } // Extract SSL flag - handle both boolean true and string "true" const ssl = data.ssl === true || data.ssl === 'true' || data.ssl === 1; await addDomain(validation.domain, validation.hash, ssl); // Invalidate cache to ensure fresh consensus check invalidateEntriesCache(); // Recalculate consensus for the new domain await doAutoVotes(); // Small delay to allow consensus to settle await new Promise(resolve => setTimeout(resolve, 200)); // Trigger consensus recalculation request to notify peers if (state.sendConsensusRequest) { setTimeout(() => { state.sendConsensusRequest(validation.domain); }, 500); } let domains = []; if (await fsp.access(domainsFile).then(() => true).catch(() => false)) { const parsed = JSON.parse(await fsp.readFile(domainsFile, 'utf8')); if (!Array.isArray(parsed)) { logError('Admin', `Domains file does not contain an array, resetting to empty array`); domains = []; } else { domains = parsed; } } const existingIndex = domains.findIndex(d => d.domain === validation.domain); if (existingIndex !== -1) { domains[existingIndex].hash = validation.hash; domains[existingIndex].ssl = ssl; // Update SSL flag } else { domains.push({ domain: validation.domain, hash: validation.hash, ssl: ssl }); } await fsp.writeFile(domainsFile, JSON.stringify(domains, null, 2)); // Note: IP assignment now happens automatically after consensus resolution // See assignAllIPs() function for automatic IP assignment logic logDebug('Admin', `Domain ${validation.domain} added - IP will be assigned automatically after consensus resolution`); // Broadcast multiple update types to ensure frontend refreshes broadcast({ type: 'update-database' }); broadcast({ type: 'update-local-dns' }); // Also update local DNS tab to show conflicts res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { trackRequest('/api/add-domain', false); const errorResponse = createErrorResponse(err, 500); res.writeHead(errorResponse.statusCode, errorResponse.headers); res.end(errorResponse.body); } }); return true; } if (method === 'POST' && urlPath === '/api/remove-domain') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const data = JSON.parse(body); const validation = validateDomainRemoval(data); if (!validation.valid) { res.writeHead(400); res.end(validation.error || 'Invalid input'); return; } const domain = validation.domain; // Authorization check: verify peer has claim const localWriter = getPersistentPublicKey(); if (!localWriter) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Peer not initialized'); return; } // Check if peer has a claim for this domain const allEntries = await getAllEntries(); const claimKey = `claim:${domain}:${localWriter}`; const hasClaim = allEntries.some(entry => entry.key === claimKey); if (!hasClaim) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('You do not have a claim for this domain'); return; } // Check if peer is the resolved claimant const consensusState = await getConsensusState(domain); // Only do full cleanup if: // 1. Consensus is resolved // 2. User is the resolved claimant // 3. Resolved claimant is not null/undefined const isResolvedClaimant = consensusState.status === 'resolved' && consensusState.resolvedClaimant && consensusState.resolvedClaimant === localWriter; if (isResolvedClaimant) { // Full removal: user is the resolved claimant logInfo('Admin', `User is resolved claimant for ${domain}, performing full cleanup`); await atomicDomainCleanup(domain); // Broadcast full removal request (triggers other peers to clean up their claims) if (state.sendRemovalRequest) { state.sendRemovalRequest(domain); } } else { // Partial removal: user has claim but isn't the resolved claimant (conflict scenario) // This includes cases where: // - Consensus is not resolved // - User is not the resolved claimant (conflict scenario) // - Resolved claimant is null/undefined logInfo('Admin', `User is NOT resolved claimant for ${domain} (status=${consensusState.status}, resolvedClaimant=${consensusState.resolvedClaimant}), removing only own claim and votes`); await removeOwnClaimAndVotes(domain, localWriter); // Broadcast conflict claim removal notification (does NOT trigger other peers to remove) if (state.sendConflictClaimRemoval) { state.sendConflictClaimRemoval(domain); } } // Cleanup hash preferences if domain was removed try { const { cleanupHashPreferences } = require('../cache'); await cleanupHashPreferences(); } catch (err) { logWarn('Admin', `Failed to cleanup hash preferences after domain removal: ${err.message}`); } trackRequest('/api/remove-domain', true); broadcast({ type: 'update-database' }); broadcast({ type: 'update-holesail-clients' }); broadcast({ type: 'update-local-dns' }); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); } catch (err) { trackRequest('/api/remove-domain', false); const errorResponse = createErrorResponse(err, 500); res.writeHead(errorResponse.statusCode, errorResponse.headers); res.end(errorResponse.body); } }); return true; } // POST /api/reset-claims-and-entries - Reset all claims and entries, request peers to provide domains if (method === 'POST' && urlPath === '/api/reset-claims-and-entries') { try { trackRequest('/api/reset-claims-and-entries', true); logInfo('Admin', 'Resetting all claims and entries, requesting peers to provide domains'); const pass = state.dnsPass; if (!pass) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'DNS pass not initialized' })); return true; } // Get all entries const allEntries = await getAllEntries(pass, false); // Don't use cache // Filter for claims and votes const entriesToRemove = allEntries.filter(entry => entry.key.startsWith('claim:') || entry.key.startsWith('vote:') ); logInfo('Admin', `Removing ${entriesToRemove.length} claim and vote entries`); // Remove all claim and vote entries let removedCount = 0; let errorCount = 0; for (const entry of entriesToRemove) { try { const { dnsPassRemove } = require('../../../core/dns-pass-queue'); await dnsPassRemove(pass, entry.key); removedCount++; } catch (err) { logError('Admin', `Error removing entry ${entry.key}: ${err.message}`); errorCount++; } } // Invalidate cache invalidateEntriesCache(); // Send message to all peers requesting them to provide their domains let peersNotified = 0; if (state.peerChannels && state.peerChannels.size > 0) { for (const channels of state.peerChannels.values()) { try { if (channels.requestMessage && channels.requestChannel && !channels.requestChannel.destroyed) { channels.requestMessage.send('request_domains'); peersNotified++; } } catch (err) { logError('Admin', `Error sending request_domains message to peer: ${err.message}`); } } logInfo('Admin', `Sent request_domains message to ${peersNotified} peer(s)`); } // Broadcast updates broadcast({ type: 'update-database' }); broadcast({ type: 'update-stats' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, message: 'Claims and entries reset successfully', removed: removedCount, errors: errorCount, peersNotified: peersNotified })); return true; } catch (err) { logError('Admin', `Failed to reset claims and entries: ${err.message}`); trackRequest('/api/reset-claims-and-entries', false); const errorResponse = createErrorResponse(err, 500); res.writeHead(errorResponse.statusCode, errorResponse.headers); res.end(errorResponse.body); return true; } } // Domain filter settings endpoints if (method === 'GET' && urlPath === '/api/domain-filter-settings') { try { let settings = { consensusStatus: 'all', hashType: 'holesail', ownership: 'all' }; if (fs.existsSync(filterSettingsFile)) { const data = fs.readFileSync(filterSettingsFile, 'utf8'); settings = { ...settings, ...JSON.parse(data) }; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(settings)); return true; } catch (err) { logError('Admin', `Error loading domain filter settings: ${err.message}`); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ consensusStatus: 'all', hashType: 'holesail', ownership: 'all' })); return true; } } if (method === 'POST' && urlPath === '/api/save-domain-filter-settings') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const settings = JSON.parse(body); // Validate settings const validConsensusValues = ['all', 'resolved', 'internal', 'conflict', 'tie', 'insufficient_quorum', 'no_claims', 'error', 'unknown']; const validHashTypeValues = ['all', 'internal', 'holesail']; const validOwnershipValues = ['all', 'local', 'remote']; if (!validConsensusValues.includes(settings.consensusStatus) || !validHashTypeValues.includes(settings.hashType) || !validOwnershipValues.includes(settings.ownership)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid filter settings' })); return; } // Ensure cache directory exists const cacheDir = path.dirname(filterSettingsFile); if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } // Save settings fs.writeFileSync(filterSettingsFile, JSON.stringify(settings, null, 2)); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (err) { logError('Admin', `Error saving domain filter settings: ${err.message}`); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to save filter settings' })); } }); return true; } return false; } module.exports = { handleDomainsRoutes };