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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user