const fs = require('fs').promises; const state = require('../../infrastructure/state'); const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState, removeOwnClaimAndVotes, invalidateEntriesCache } = 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 } = 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'; 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 fs.access(domainsFile).then(() => true).catch(() => false)) { const parsed = JSON.parse(await fs.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 fs.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; } return false; } module.exports = { handleDomainsRoutes };