397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
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 };
|
|
|