feat: Add P2P Domain Conflicts management

Implement comprehensive P2P domain conflict resolution allowing users to choose between local claim hashes and consensus-resolved hashes for domains where they have local claims but another claimant won consensus.

Key features:
- P2P Domain Conflicts UI tab in Local DNS section
- Hash preference toggle (local vs resolved) with automatic client restart
- Extended selector_cache.json to store hashPreferences alongside versionPreferences
- DNS cache invalidation for immediate preference application
- REST API endpoints for conflict detection and preference management
- Automatic Holesail client restart when hash preferences change
- Complete documentation updates across README, API docs, and glossary

Resolves conflicts between local claims and consensus resolution by giving users control over which hash their domain resolves to, with seamless client management ensuring immediate effect.
This commit is contained in:
Raven Scott
2025-12-26 16:36:19 -05:00
parent 480f99ae28
commit 01acfb766c
22 changed files with 1193 additions and 65 deletions
+23 -5
View File
@@ -12,23 +12,41 @@ async function loadSelectorCache() {
try {
if (await fs.access(selectorCacheFile).then(() => true).catch(() => false)) {
const data = JSON.parse(await fs.readFile(selectorCacheFile, 'utf8'));
state.versionPreferences = new Map(Object.entries(data));
logInfo('Admin', 'Loaded version preferences from selector_cache.json');
// Handle both old format (flat object) and new format (nested object)
if (data.versionPreferences) {
// New nested format
state.versionPreferences = new Map(Object.entries(data.versionPreferences));
state.hashPreferences = new Map(Object.entries(data.hashPreferences || {}));
logInfo('Admin', 'Loaded version and hash preferences from selector_cache.json');
} else {
// Old flat format - migrate to new format
state.versionPreferences = new Map(Object.entries(data));
state.hashPreferences = new Map();
logInfo('Admin', 'Loaded version preferences from selector_cache.json (migrating to new format)');
// Save in new format
await saveSelectorCache();
}
} else {
state.versionPreferences = new Map();
logInfo('Admin', 'No selector_cache.json found, initializing empty version preferences');
state.hashPreferences = new Map();
logInfo('Admin', 'No selector_cache.json found, initializing empty preferences');
}
} catch (err) {
logError('Admin', `Failed to load selector_cache.json: ${err.message}`);
state.versionPreferences = new Map();
state.hashPreferences = new Map();
}
}
async function saveSelectorCache() {
try {
const data = Object.fromEntries(state.versionPreferences);
const data = {
versionPreferences: Object.fromEntries(state.versionPreferences),
hashPreferences: Object.fromEntries(state.hashPreferences)
};
await fs.writeFile(selectorCacheFile, JSON.stringify(data, null, 2));
logDebug('Admin', 'Saved version preferences to selector_cache.json');
logDebug('Admin', 'Saved version and hash preferences to selector_cache.json');
} catch (err) {
logError('Admin', `Failed to save selector_cache.json: ${err.message}`);
}
@@ -817,6 +817,189 @@ async function handleHolesailRoutes(req, res) {
return true;
}
if (method === 'POST' && urlPath === '/api/restart-holesail-clients-for-domain') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { domain } = JSON.parse(body);
logInfo('Admin', `Restarting all Holesail connections for domain ${domain} due to hash preference change`);
// Find all active Holesail connections for this domain
const keysToClose = [];
for (const key of state.holesails.keys()) {
const [connectionDomain, port] = key.split(':');
if (connectionDomain === domain) {
keysToClose.push(key);
}
}
// Also find admin-managed clients for this domain
const clientIdsToRestart = [];
for (const [id, opts] of state.holesailClientOpts) {
if (opts.domain === domain) {
clientIdsToRestart.push(id);
}
}
logInfo('Admin', `Found ${keysToClose.length} active connections and ${clientIdsToRestart.length} admin clients for domain ${domain}`);
// Close all active DNS-triggered connections for this domain
const closePromises = keysToClose.map(async (key) => {
try {
logDebug('Admin', `Closing Holesail connection for ${key}`);
const holesail = state.holesails.get(key);
if (holesail) {
if (holesail instanceof dgram.Socket) {
await new Promise((resolve, reject) => {
holesail.close((err) => {
if (err) {
logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`);
reject(err);
} else {
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
resolve();
}
});
setTimeout(() => {
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
try {
holesail.close();
resolve();
} catch (e) {
reject(e);
}
}, 2000);
});
} else {
holesail.close();
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
}
}
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
} catch (err) {
logError('Admin', `Failed to close Holesail connection ${key}: ${err.message}`);
}
});
// Restart admin-managed clients
const restartPromises = clientIdsToRestart.map(async (id) => {
try {
logDebug('Admin', `Restarting admin-managed Holesail client ${id} for domain ${domain}`);
const child = state.holesailClientChildren.get(id);
const opts = state.holesailClientOpts.get(id);
if (!opts) {
logWarn('Admin', `Client options not found for ${id}`);
return;
}
// Get the new hash for the domain
const { getHashForDomain } = require('../../../core/core');
const newHash = await getHashForDomain(domain);
const key = `${opts.domain}:${opts.port}`;
// Terminate existing child process
let exitPromise;
if (child) {
logDebug('Admin', `Terminating existing child process for client ${id}`);
exitPromise = new Promise((resolve) => {
child.on('exit', () => {
logDebug('Admin', `Child process exited for client ${id}`);
resolve();
});
child.on('error', (err) => {
logWarn('Admin', `Error during child process termination for ${id}: ${err.message}`);
resolve();
});
});
child.kill('SIGTERM');
// Force kill after 5 seconds
setTimeout(() => {
if (!child.killed) {
logWarn('Admin', `Force killing child process for client ${id}`);
child.kill('SIGKILL');
}
}, 5000);
await exitPromise;
}
// Clean up old state
state.holesailClientChildren.delete(id);
state.holesailClientInfos.delete(id);
state.holesailChildStartTimes.delete(id);
// Start new client with updated hash
logInfo('Admin', `Starting new admin-managed Holesail client for ${domain} with hash ${newHash}`);
const { startHolesailClient } = require('../admin-holesail');
await startHolesailClient(domain, newHash, opts.ip, opts.port);
// Wait a moment for the connection to establish
await new Promise(resolve => setTimeout(resolve, 200));
// Verify the new connection is active
const newKey = `${domain}:${opts.port}`;
if (state.holesails.has(newKey)) {
logInfo('Admin', `Successfully restarted admin-managed Holesail client for ${domain} - new connection active`);
} else {
logWarn('Admin', `Admin-managed Holesail client restart for ${domain} completed but connection not yet active`);
}
} catch (err) {
logError('Admin', `Failed to restart admin-managed Holesail client ${id}: ${err.message}`);
}
});
await Promise.all([...closePromises, ...restartPromises]);
// Create new DNS-triggered connections if needed
const { getHashForDomain } = require('../../../core/core');
const { startHolesailClient: startNetworkingHolesailClient } = require('../../../networking/holesail');
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
const newHash = await getHashForDomain(domain);
// Ensure domain has an IP assigned
let localIP = state.domainToIPMap[domain];
if (!localIP) {
logInfo('Admin', `Assigning IP for domain ${domain} during restart`);
localIP = await createInterfaceForDomain(domain);
}
if (localIP && newHash) {
logInfo('Admin', `Creating new DNS-triggered Holesail client for ${domain} with hash ${newHash} on IP ${localIP}`);
try {
await startNetworkingHolesailClient(domain, newHash, localIP, state.internalPort);
logInfo('Admin', `Successfully created new DNS-triggered Holesail client for ${domain}`);
} catch (err) {
logError('Admin', `Failed to create new DNS-triggered Holesail client for ${domain}: ${err.message}`);
}
} else {
logWarn('Admin', `Cannot create DNS-triggered client for ${domain}: localIP=${localIP}, newHash=${newHash}`);
}
// Final verification - ensure new connections are established
await new Promise(resolve => setTimeout(resolve, 300));
// Check that we have active connections for this domain
const finalConnections = Array.from(state.holesails.keys()).filter(key => key.startsWith(`${domain}:`));
logInfo('Admin', `Final verification: ${finalConnections.length} active connections for domain ${domain}`);
broadcast({ type: 'update-holesail-clients' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to restart Holesail clients for domain: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
@@ -4,6 +4,8 @@ const state = require('../../../infrastructure/state');
const { logError, logWarn, logInfo } = require('../../../infrastructure/logger');
const { saveSelectorCache } = require('../cache');
const { broadcast } = require('../websocket');
const { getConsensusState, getLocalClaimHash, getAllEntries } = require('../../../core/core');
const { getPersistentPublicKey } = require('../../../infrastructure/utils');
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
@@ -168,6 +170,108 @@ async function handleLocalDnsRoutes(req, res) {
return true;
}
if (method === 'GET' && urlPath === '/api/p2p-domain-conflicts') {
try {
const localWriter = getPersistentPublicKey();
if (!localWriter) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ conflicts: [] }));
return true;
}
const allEntries = await getAllEntries(state.dnsPass, false); // Don't use cache for fresh conflict detection
const domainClaimants = new Map();
// Collect all claimants for each domain
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 conflicts = [];
for (const [domain, claimants] of domainClaimants) {
// Check if user has a local claim
if (claimants.has(localWriter)) {
const consensusState = await getConsensusState(domain);
// Check if consensus is resolved but user is not the resolved claimant
if (consensusState.status === 'resolved' && consensusState.resolvedClaimant !== localWriter) {
const localHash = await getLocalClaimHash(domain, localWriter);
conflicts.push({
domain,
localHash,
resolvedHash: consensusState.hash,
resolvedClaimant: consensusState.resolvedClaimant,
localClaimant: localWriter,
consensusStatus: consensusState.status,
hashPreference: state.hashPreferences.get(domain) || 'resolved' // Default to resolved
});
}
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ conflicts }));
} catch (err) {
logError('Admin', `Failed to fetch P2P domain conflicts: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch P2P domain conflicts' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/update-hash-preference') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { domain, preference } = JSON.parse(body);
if (preference !== 'local' && preference !== 'resolved') {
res.writeHead(400);
res.end('Invalid preference, must be "local" or "resolved"');
return;
}
state.hashPreferences.set(domain, preference);
await saveSelectorCache();
broadcast({ type: 'update-local-dns' });
logInfo('Admin', `Updated hash preference for ${domain} to ${preference}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to update hash preference: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/clear-dns-cache') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { domain } = JSON.parse(body);
const { clearDNSCacheForDomain } = require('../../../networking/dns');
clearDNSCacheForDomain(domain);
logInfo('Admin', `Cleared DNS cache for domain ${domain}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to clear DNS cache: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}