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;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,18 @@ function initializeApp() {
|
||||
btn.className = 'px-4 py-2 theme-button-info rounded transition-colors';
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch data for the sub-tab if it has a corresponding config
|
||||
// Map sub-tab IDs to config keys
|
||||
const configKeyMap = {
|
||||
'records': 'local-dns',
|
||||
'conflicts': 'dns-conflicts',
|
||||
'p2p-conflicts': 'p2p-domain-conflicts'
|
||||
};
|
||||
const configKey = configKeyMap[subTabId] || subTabId;
|
||||
if (window.tabs && window.tabs[configKey] && window.genericFetch) {
|
||||
window.genericFetch(configKey, true);
|
||||
}
|
||||
|
||||
// Fetch data for the sub-tab if needed
|
||||
if (mainTabId === 'host') {
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<div class="flex gap-2 mb-4 flex-shrink-0">
|
||||
<button onclick="showSubTab('local-dns', 'records')" id="local-dns-subtab-records" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">DNS Records</button>
|
||||
<button onclick="showSubTab('local-dns', 'conflicts')" id="local-dns-subtab-conflicts" class="px-4 py-2 theme-button-info rounded transition-colors">DNS Conflicts</button>
|
||||
<button onclick="showSubTab('local-dns', 'p2p-conflicts')" id="local-dns-subtab-p2p-conflicts" class="px-4 py-2 theme-button-info rounded transition-colors">P2P Conflicts</button>
|
||||
</div>
|
||||
|
||||
<!-- DNS Records Sub-tab -->
|
||||
@@ -147,6 +148,32 @@
|
||||
</div>
|
||||
<div id="dnsConflictsPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||
</div>
|
||||
|
||||
<!-- P2P Domain Conflicts Sub-tab -->
|
||||
<div id="local-dns-p2p-conflicts" class="sub-tab-content hidden flex flex-col flex-1 min-h-0">
|
||||
<div class="mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
<h3 class="text-xl font-bold">P2P Domain Conflicts</h3>
|
||||
<button onclick="openInfoModal('p2p-domain-conflicts')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
</div>
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<input id="search-p2p-domain-conflicts" type="text" placeholder="Search conflicts..." class="w-full p-3 rounded-lg theme-input focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterP2pDomainConflicts()">
|
||||
</div>
|
||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
||||
<table class="w-full theme-table rounded-lg">
|
||||
<thead class="theme-table thead sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">Local Hash</th>
|
||||
<th class="p-3 text-left">Resolved Hash</th>
|
||||
<th class="p-3 text-left">Hash Preference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="p2pDomainConflictsTable"></tbody>
|
||||
</table>
|
||||
<div id="p2pDomainConflictsScrollSentinel" class="scroll-sentinel"></div>
|
||||
</div>
|
||||
<div id="p2pDomainConflictsPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="entries" class="tab-content hidden flex flex-col" style="height: calc(100vh - 250px); max-height: calc(100vh - 250px);">
|
||||
|
||||
@@ -315,6 +315,37 @@ window.tabs = {
|
||||
return data.conflicts || [];
|
||||
}
|
||||
},
|
||||
'p2p-domain-conflicts': {
|
||||
api: '/api/p2p-domain-conflicts',
|
||||
searchId: 'search-p2p-domain-conflicts',
|
||||
dataKey: 'p2pDomainConflictsData',
|
||||
filteredKey: 'filteredP2pDomainConflicts',
|
||||
containerId: 'p2pDomainConflictsTable',
|
||||
paginationId: 'p2pDomainConflictsPagination',
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.localHash.toLowerCase().includes(query) || item.resolvedHash.toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">${item.domain}</td>
|
||||
<td class="p-3 break-all">${item.localHash || 'N/A'}</td>
|
||||
<td class="p-3 break-all">${item.resolvedHash || 'N/A'}</td>
|
||||
<td class="p-3">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<span class="mr-2">${item.hashPreference === 'local' ? 'Local' : 'Resolved'}</span>
|
||||
<input type="checkbox" ${item.hashPreference === 'local' ? 'checked' : ''} onchange="toggleHashPreference('${item.domain}', this.checked)" class="sr-only peer">
|
||||
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:bg-primary">
|
||||
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
</td>`;
|
||||
return tr;
|
||||
},
|
||||
postFetch: (data) => {
|
||||
return data.conflicts || [];
|
||||
}
|
||||
},
|
||||
'host-servers': {
|
||||
api: '/api/holesail-servers',
|
||||
searchId: 'search-holesail',
|
||||
|
||||
@@ -319,6 +319,7 @@ function filterCerts() { genericFilter('certs'); }
|
||||
function filterInterfaces() { genericFilter('interfaces'); }
|
||||
function filterLocalDNS() { genericFilter('local-dns'); }
|
||||
function filterDnsConflicts() { genericFilter('dns-conflicts'); }
|
||||
function filterP2pDomainConflicts() { genericFilter('p2p-domain-conflicts'); }
|
||||
function filterHolesailServers() { genericFilter('host-servers'); }
|
||||
function filterHolesailClients() { genericFilter('host-clients'); }
|
||||
|
||||
@@ -334,6 +335,7 @@ window.filterCerts = filterCerts;
|
||||
window.filterInterfaces = filterInterfaces;
|
||||
window.filterLocalDNS = filterLocalDNS;
|
||||
window.filterDnsConflicts = filterDnsConflicts;
|
||||
window.filterP2pDomainConflicts = filterP2pDomainConflicts;
|
||||
window.filterHolesailServers = filterHolesailServers;
|
||||
window.filterHolesailClients = filterHolesailClients;
|
||||
|
||||
|
||||
@@ -241,6 +241,58 @@ async function toggleVersionPreference(domain, isPublic) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHashPreference(domain, useLocal) {
|
||||
const newPreference = useLocal ? 'local' : 'resolved';
|
||||
|
||||
// Immediately update UI to show pending state
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Switching hash preference for ${domain}...`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Update the preference
|
||||
const response = await fetch('/api/update-hash-preference', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, preference: newPreference })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
// Step 2: Clear DNS cache for immediate effect
|
||||
await fetch('/api/clear-dns-cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
// Step 3: Wait for Holesail clients to fully restart
|
||||
await fetch('/api/restart-holesail-clients-for-domain', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
// Step 4: Verify the new connections are ready (small delay for startup)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Success - update UI
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Hash preference for ${domain} set to ${newPreference}`);
|
||||
}
|
||||
if (window.genericFetch) {
|
||||
window.genericFetch('p2p-domain-conflicts', true);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to update hash preference:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to update hash preference: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.openLocalDnsModal = openLocalDnsModal;
|
||||
window.updateLocalForm = updateLocalForm;
|
||||
window.submitLocalDns = submitLocalDns;
|
||||
|
||||
@@ -26,6 +26,12 @@ function initializeApp() {
|
||||
} else {
|
||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||
}
|
||||
if (tabId === 'local-dns') {
|
||||
// Initialize local-dns sub-tabs to show records by default
|
||||
if (window.showSubTab) {
|
||||
window.showSubTab('local-dns', 'records');
|
||||
}
|
||||
}
|
||||
if (tabId === 'logs') {
|
||||
if (window.renderLogs) window.renderLogs();
|
||||
}
|
||||
@@ -42,6 +48,41 @@ function initializeApp() {
|
||||
}
|
||||
window.showTab = showTab;
|
||||
|
||||
// showSubTab function for switching between sub-tabs within a main tab
|
||||
function showSubTab(parentTabId, subTabId) {
|
||||
const parentEl = document.getElementById(parentTabId);
|
||||
if (!parentEl) return;
|
||||
|
||||
// Hide all sub-tabs within the parent tab
|
||||
parentEl.querySelectorAll('.sub-tab-content').forEach(el => el.classList.add('hidden'));
|
||||
|
||||
// Show the selected sub-tab
|
||||
const subTabEl = document.getElementById(`${parentTabId}-${subTabId}`);
|
||||
if (subTabEl) subTabEl.classList.remove('hidden');
|
||||
|
||||
// Update button states
|
||||
parentEl.querySelectorAll(`[id^="${parentTabId}-subtab-"]`).forEach(btn => {
|
||||
if (btn.id === `${parentTabId}-subtab-${subTabId}`) {
|
||||
btn.className = btn.className.replace('bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300', 'bg-primary text-white');
|
||||
} else {
|
||||
btn.className = btn.className.replace('bg-primary text-white', 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300');
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch data for the sub-tab if it has a corresponding config
|
||||
// Map sub-tab IDs to config keys
|
||||
const configKeyMap = {
|
||||
'records': 'local-dns',
|
||||
'conflicts': 'dns-conflicts',
|
||||
'p2p-conflicts': 'p2p-domain-conflicts'
|
||||
};
|
||||
const configKey = configKeyMap[subTabId] || subTabId;
|
||||
if (window.tabs && window.tabs[configKey] && window.genericFetch) {
|
||||
window.genericFetch(configKey, true);
|
||||
}
|
||||
}
|
||||
window.showSubTab = showSubTab;
|
||||
|
||||
// Filter settings function
|
||||
function filterSettings() {
|
||||
const query = document.getElementById('search-settings')?.value.toLowerCase() || '';
|
||||
|
||||
+23
-5
@@ -9,23 +9,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}`);
|
||||
}
|
||||
|
||||
+75
-39
@@ -54,51 +54,87 @@
|
||||
|
||||
<div id="local-dns" class="tab-content hidden">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
Custom Local DNS Records
|
||||
<button onclick="openInfoModal('local-dns')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||
Local DNS
|
||||
</h2>
|
||||
<div class="mb-6">
|
||||
<input id="search-local-dns" type="text" placeholder="Search records..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterLocalDNS()">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button onclick="showSubTab('local-dns', 'records')" id="local-dns-subtab-records" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">DNS Records</button>
|
||||
<button onclick="showSubTab('local-dns', 'conflicts')" id="local-dns-subtab-conflicts" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600">DNS Conflicts</button>
|
||||
<button onclick="showSubTab('local-dns', 'p2p-conflicts')" id="local-dns-subtab-p2p-conflicts" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600">P2P Conflicts</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Name</th>
|
||||
<th class="p-3 text-left">Type</th>
|
||||
<th class="p-3 text-left">Value</th>
|
||||
<th class="p-3 text-left">TTL</th>
|
||||
<th class="p-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="localDnsTable"></tbody>
|
||||
</table>
|
||||
|
||||
<!-- DNS Records Sub-tab -->
|
||||
<div id="local-dns-records" class="sub-tab-content">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h3 class="text-xl font-bold">Custom Local DNS Records</h3>
|
||||
<button onclick="openInfoModal('local-dns')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<input id="search-local-dns" type="text" placeholder="Search records..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterLocalDNS()">
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Name</th>
|
||||
<th class="p-3 text-left">Type</th>
|
||||
<th class="p-3 text-left">Value</th>
|
||||
<th class="p-3 text-left">TTL</th>
|
||||
<th class="p-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="localDnsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="localDnsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||
<button onclick="openLocalDnsModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add Record</button>
|
||||
</div>
|
||||
<div id="localDnsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||
<button onclick="openLocalDnsModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add Record</button>
|
||||
|
||||
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
|
||||
DNS Conflict Selector
|
||||
<button onclick="openInfoModal('dns-conflicts')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||
</h2>
|
||||
<div class="mb-6">
|
||||
<input id="search-dns-conflicts" type="text" placeholder="Search conflicts..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterDnsConflicts()">
|
||||
|
||||
<!-- DNS Conflicts Sub-tab -->
|
||||
<div id="local-dns-conflicts" class="sub-tab-content hidden">
|
||||
<div class="mb-6">
|
||||
<input id="search-dns-conflicts" type="text" placeholder="Search conflicts..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterDnsConflicts()">
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">Public IP</th>
|
||||
<th class="p-3 text-left">Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dnsConflictsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="dnsConflictsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">Public IP</th>
|
||||
<th class="p-3 text-left">Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dnsConflictsTable"></tbody>
|
||||
</table>
|
||||
|
||||
<!-- P2P Domain Conflicts Sub-tab -->
|
||||
<div id="local-dns-p2p-conflicts" class="sub-tab-content hidden">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h3 class="text-xl font-bold">P2P Domain Conflicts</h3>
|
||||
<button onclick="openInfoModal('p2p-domain-conflicts')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<input id="search-p2p-domain-conflicts" type="text" placeholder="Search conflicts..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterP2pDomainConflicts()">
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">Local Hash</th>
|
||||
<th class="p-3 text-left">Resolved Hash</th>
|
||||
<th class="p-3 text-left">Hash Preference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="p2pDomainConflictsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="p2pDomainConflictsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||
</div>
|
||||
<div id="dnsConflictsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="entries" class="tab-content hidden">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
Autopass Entries
|
||||
|
||||
@@ -498,6 +498,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/admin-backend/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,115 @@ async function handleLocalDnsRoutes(req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'GET' && urlPath === '/api/p2p-domain-conflicts') {
|
||||
try {
|
||||
const localWriter = getPersistentPublicKey();
|
||||
if (!localWriter) {
|
||||
logInfo('Admin', 'No local writer found for P2P domain conflicts');
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logInfo('Admin', `Found ${domainClaimants.size} domains with claims for P2P conflicts check`);
|
||||
|
||||
const conflicts = [];
|
||||
for (const [domain, claimants] of domainClaimants) {
|
||||
// Check if user has a local claim
|
||||
if (claimants.has(localWriter)) {
|
||||
const consensusState = await getConsensusState(domain);
|
||||
logDebug('Admin', `Domain ${domain}: status=${consensusState.status}, resolvedClaimant=${consensusState.resolvedClaimant}, localWriter=${localWriter}`);
|
||||
|
||||
// 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
|
||||
});
|
||||
logInfo('Admin', `Found P2P conflict for domain ${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logInfo('Admin', `Returning ${conflicts.length} P2P domain conflicts`);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,37 @@ window.tabs = {
|
||||
return data.conflicts || [];
|
||||
}
|
||||
},
|
||||
'p2p-domain-conflicts': {
|
||||
api: '/api/p2p-domain-conflicts',
|
||||
searchId: 'search-p2p-domain-conflicts',
|
||||
dataKey: 'p2pDomainConflictsData',
|
||||
filteredKey: 'filteredP2pDomainConflicts',
|
||||
containerId: 'p2pDomainConflictsTable',
|
||||
paginationId: 'p2pDomainConflictsPagination',
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.localHash.toLowerCase().includes(query) || item.resolvedHash.toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">${item.domain}</td>
|
||||
<td class="p-3 break-all">${item.localHash || 'N/A'}</td>
|
||||
<td class="p-3 break-all">${item.resolvedHash || 'N/A'}</td>
|
||||
<td class="p-3">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<span class="mr-2">${item.hashPreference === 'local' ? 'Local' : 'Resolved'}</span>
|
||||
<input type="checkbox" ${item.hashPreference === 'local' ? 'checked' : ''} onchange="toggleHashPreference('${item.domain}', this.checked)" class="sr-only peer">
|
||||
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:bg-primary">
|
||||
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
</td>`;
|
||||
return tr;
|
||||
},
|
||||
postFetch: (data) => {
|
||||
return data.conflicts || [];
|
||||
}
|
||||
},
|
||||
'host-servers': {
|
||||
api: '/api/holesail-servers',
|
||||
searchId: 'search-holesail',
|
||||
|
||||
@@ -140,6 +140,7 @@ function filterCerts() { genericFilter('certs'); }
|
||||
function filterInterfaces() { genericFilter('interfaces'); }
|
||||
function filterLocalDNS() { genericFilter('local-dns'); }
|
||||
function filterDnsConflicts() { genericFilter('dns-conflicts'); }
|
||||
function filterP2pDomainConflicts() { genericFilter('p2p-domain-conflicts'); }
|
||||
function filterHolesailServers() { genericFilter('host-servers'); }
|
||||
function filterHolesailClients() { genericFilter('host-clients'); }
|
||||
|
||||
@@ -155,6 +156,7 @@ window.filterCerts = filterCerts;
|
||||
window.filterInterfaces = filterInterfaces;
|
||||
window.filterLocalDNS = filterLocalDNS;
|
||||
window.filterDnsConflicts = filterDnsConflicts;
|
||||
window.filterP2pDomainConflicts = filterP2pDomainConflicts;
|
||||
window.filterHolesailServers = filterHolesailServers;
|
||||
window.filterHolesailClients = filterHolesailClients;
|
||||
|
||||
|
||||
@@ -232,6 +232,70 @@ async function toggleVersionPreference(domain, isPublic) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHashPreference(domain, useLocal) {
|
||||
const originalPreference = state.hashPreferences.get(domain);
|
||||
const newPreference = useLocal ? 'local' : 'resolved';
|
||||
|
||||
// Immediately update UI to show pending state
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Switching hash preference for ${domain}...`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Update the preference
|
||||
const response = await fetch('/api/update-hash-preference', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, newPreference })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
// Step 2: Clear DNS cache for immediate effect
|
||||
await fetch('/api/clear-dns-cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
// Step 3: Wait for Holesail clients to fully restart
|
||||
await fetch('/api/restart-holesail-clients-for-domain', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
// Step 4: Verify the new connections are ready (small delay for startup)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Success - update UI
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Hash preference for ${domain} set to ${newPreference}`);
|
||||
}
|
||||
if (window.genericFetch) {
|
||||
window.genericFetch('p2p-domain-conflicts', true);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Revert preference on error
|
||||
try {
|
||||
await fetch('/api/update-hash-preference', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, preference: originalPreference })
|
||||
});
|
||||
} catch (revertErr) {
|
||||
console.error('Failed to revert hash preference:', revertErr);
|
||||
}
|
||||
|
||||
console.error('Failed to update hash preference:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to update hash preference: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.openLocalDnsModal = openLocalDnsModal;
|
||||
window.updateLocalForm = updateLocalForm;
|
||||
window.submitLocalDns = submitLocalDns;
|
||||
|
||||
Reference in New Issue
Block a user