Cleanup - Bug Fixes

This commit is contained in:
Raven Scott
2026-05-30 22:27:42 -04:00
parent 229271fbcf
commit 4b9eb916e9
61 changed files with 733 additions and 6944 deletions
+14
View File
@@ -139,3 +139,17 @@ MASTER_PROACTIVE_INVITE_DELAY=2000
# Gap between serialized Autopass ops in ms (default: 50) # Gap between serialized Autopass ops in ms (default: 50)
# DNS_PASS_OP_GAP_MS=50 # DNS_PASS_OP_GAP_MS=50
# ============================================================================
# Shutdown / Cleanup
# ============================================================================
# SHUTDOWN_MODE=fast — shorter timeouts, skip port-release waits (dev/restart)
# SHUTDOWN_MODE=fast
# SHUTDOWN_SERVER_TIMEOUT_MS=5000
# SHUTDOWN_SETTLE_MS=2000
# SHUTDOWN_REPLICATION_SETTLE_MS=5000
# SHUTDOWN_HOLESAIL_CHILD_GRACE_MS=10000
# SHUTDOWN_HOLESAIL_CHILD_KILL_MS=5000
# SHUTDOWN_INVITE_TIMEOUT_MS=5000
# SHUTDOWN_HOLESAIL_UDP_CLOSE_MS=5000
# SHUTDOWN_SKIP_PORT_WAIT=true
+48 -62
View File
@@ -5,6 +5,7 @@ const ca = require('../../../security/certificate_authority');
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces'); const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
const { logDebug, logError } = require('../../../infrastructure/logger'); const { logDebug, logError } = require('../../../infrastructure/logger');
const { broadcast } = require('../websocket'); const { broadcast } = require('../websocket');
const { readJsonBody, sendError } = require('../../../infrastructure/http-utils');
const certsDir = process.env.CERTS_DIR || './certs'; const certsDir = process.env.CERTS_DIR || './certs';
@@ -75,78 +76,63 @@ async function handleCertsRoutes(req, res) {
} }
if (method === 'POST' && urlPath === '/api/generate-cert') { if (method === 'POST' && urlPath === '/api/generate-cert') {
let body = ''; try {
req.on('data', chunk => { body += chunk; }); const data = await readJsonBody(req);
req.on('end', async () => {
try { if (!state.domainToIPMap.has(data.domain)) {
const data = JSON.parse(body); await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to generate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
} }
});
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to generate cert: ${err.message}`);
sendError(res, err, 500);
}
return true; return true;
} }
if (method === 'POST' && urlPath === '/api/delete-cert') { if (method === 'POST' && urlPath === '/api/delete-cert') {
let body = ''; try {
req.on('data', chunk => { body += chunk; }); const data = await readJsonBody(req);
req.on('end', async () => { const domainDir = pathModule.join(certsDir, data.domain);
try { await fs.rm(domainDir, { recursive: true, force: true });
const data = JSON.parse(body); broadcast({ type: 'update-certs' });
const domainDir = pathModule.join(certsDir, data.domain); res.writeHead(200, { 'Content-Type': 'text/plain' });
await fs.rm(domainDir, { recursive: true, force: true }); res.end('OK');
broadcast({ type: 'update-certs' }); } catch (err) {
res.writeHead(200, { 'Content-Type': 'text/plain' }); logError('Admin', `Failed to delete cert: ${err.message}`);
res.end('OK'); sendError(res, err, 500);
} catch (err) { }
logError('Admin', `Failed to delete cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true; return true;
} }
if (method === 'POST' && urlPath === '/api/regenerate-cert') { if (method === 'POST' && urlPath === '/api/regenerate-cert') {
let body = ''; try {
req.on('data', chunk => { body += chunk; }); const data = await readJsonBody(req);
req.on('end', async () => { const domainDir = pathModule.join(certsDir, data.domain);
try { await fs.rm(domainDir, { recursive: true, force: true });
const data = JSON.parse(body);
const domainDir = pathModule.join(certsDir, data.domain); if (!state.domainToIPMap.has(data.domain)) {
await fs.rm(domainDir, { recursive: true, force: true }); await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
} }
});
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate cert: ${err.message}`);
sendError(res, err, 500);
}
return true; return true;
} }
@@ -25,7 +25,9 @@ async function handleConsensusRoutes(req, res) {
} catch (err) { } catch (err) {
logError('Consensus', `Failed to get consensus metrics: ${err.message}`); logError('Consensus', `Failed to get consensus metrics: ${err.message}`);
trackRequest('/api/consensus/metrics', false); trackRequest('/api/consensus/metrics', false);
createErrorResponse(res, 500, 'Failed to get consensus metrics', err.message); const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
return true; return true;
} }
} }
@@ -46,7 +48,9 @@ async function handleConsensusRoutes(req, res) {
} catch (err) { } catch (err) {
logError('Consensus', `Failed to get consensus state: ${err.message}`); logError('Consensus', `Failed to get consensus state: ${err.message}`);
trackRequest('/api/consensus/:domain', false); trackRequest('/api/consensus/:domain', false);
createErrorResponse(res, 500, 'Failed to get consensus state', err.message); const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
return true; return true;
} }
} }
@@ -73,7 +77,9 @@ async function handleConsensusRoutes(req, res) {
} catch (err) { } catch (err) {
logError('Consensus', `Failed to recalculate consensus: ${err.message}`); logError('Consensus', `Failed to recalculate consensus: ${err.message}`);
trackRequest('/api/consensus/recalculate', false); trackRequest('/api/consensus/recalculate', false);
createErrorResponse(res, 500, 'Failed to recalculate consensus', err.message); const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
return true; return true;
} }
} }
@@ -109,7 +115,9 @@ async function handleConsensusRoutes(req, res) {
} catch (err) { } catch (err) {
logError('Consensus', `Failed to recalculate consensus for domain: ${err.message}`); logError('Consensus', `Failed to recalculate consensus for domain: ${err.message}`);
trackRequest('/api/consensus/recalculate/:domain', false); trackRequest('/api/consensus/recalculate/:domain', false);
createErrorResponse(res, 500, 'Failed to recalculate consensus for domain', err.message); const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
return true; return true;
} }
} }
@@ -3,6 +3,7 @@ const pathModule = require('path');
const { logDebug, logError } = require('../../../infrastructure/logger'); const { logDebug, logError } = require('../../../infrastructure/logger');
const ADMIN_FRONTEND_DIR = pathModule.join(__dirname, '..', '..', 'admin-frontend'); const ADMIN_FRONTEND_DIR = pathModule.join(__dirname, '..', '..', 'admin-frontend');
const BROWSER_UTILS_PATH = pathModule.join(__dirname, '..', '..', '..', 'shared', 'browser-utils.js');
const TAILWIND_CSS = pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css'); const TAILWIND_CSS = pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css');
const MIME_TYPES = { const MIME_TYPES = {
@@ -37,6 +38,18 @@ async function serveAdminFrontendAsset(urlPath, res) {
} }
const relativePath = urlPath.replace(/^\//, ''); const relativePath = urlPath.replace(/^\//, '');
if (relativePath === 'utils.js') {
try {
await serveFile(BROWSER_UTILS_PATH, res);
return true;
} catch (err) {
logError('Admin', `Failed to serve utils.js: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Failed to load asset');
return true;
}
}
const filePath = pathModule.join(ADMIN_FRONTEND_DIR, relativePath); const filePath = pathModule.join(ADMIN_FRONTEND_DIR, relativePath);
if (!isPathInsideDir(filePath, ADMIN_FRONTEND_DIR)) { if (!isPathInsideDir(filePath, ADMIN_FRONTEND_DIR)) {
return false; return false;
+12 -6
View File
@@ -580,21 +580,24 @@ window.tabs = {
sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }), sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }),
filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query), filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query),
renderItem: (item) => { renderItem: (item) => {
const escAttr = (value) => String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const itemIdAttr = escAttr(item.id);
const itemNameAttr = escAttr(item.opts.name || item.id);
const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id); const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id);
const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id); const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id);
const restartBtn = isPendingRestart const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>` ? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailServer('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`; : `<button onclick="restartHolesailServer('${itemIdAttr}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>` ? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailServer('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`; : `<button onclick="deleteHolesailServer('${itemIdAttr}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = item.opts.udp ? 'UDP' : 'TCP'; const protocol = item.opts.udp ? 'UDP' : 'TCP';
const url = item.info.url || 'N/A'; const url = item.info.url || 'N/A';
const truncatedUrl = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(url, 40) : (url.length > 40 ? url.substring(0, 37) + '...' : url); const truncatedUrl = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(url, 40) : (url.length > 40 ? url.substring(0, 37) + '...' : url);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700'; tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = ` tr.innerHTML = `
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.name || item.id}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td> <td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${itemIdAttr}', '${itemNameAttr}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
<td class="p-3">${item.opts.port}</td> <td class="p-3">${item.opts.port}</td>
<td class="p-3">${item.opts.host || '0.0.0.0'}</td> <td class="p-3">${item.opts.host || '0.0.0.0'}</td>
<td class="p-3" title="${url}">${truncatedUrl}</td> <td class="p-3" title="${url}">${truncatedUrl}</td>
@@ -617,21 +620,24 @@ window.tabs = {
sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }), sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query), filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query),
renderItem: (item) => { renderItem: (item) => {
const escAttr = (value) => String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const itemIdAttr = escAttr(item.id);
const logLabelAttr = escAttr(`${item.opts.domain}:${item.opts.port}`);
const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id); const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id);
const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id); const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id);
const restartBtn = isPendingRestart const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>` ? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailClient('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`; : `<button onclick="restartHolesailClient('${itemIdAttr}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>` ? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailClient('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`; : `<button onclick="deleteHolesailClient('${itemIdAttr}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = (item.opts.protocol || 'tcp').toUpperCase(); const protocol = (item.opts.protocol || 'tcp').toUpperCase();
const key = item.opts.key || ''; const key = item.opts.key || '';
const truncatedKey = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(key, 30) : (key.length > 30 ? key.substring(0, 27) + '...' : key); const truncatedKey = window.sdk?.utils?.dom?.truncate ? window.sdk.utils.dom.truncate(key, 30) : (key.length > 30 ? key.substring(0, 27) + '...' : key);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700'; tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = ` tr.innerHTML = `
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.domain}:${item.opts.port}')" title="${item.opts.domain}">${item.opts.domain}</td> <td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${itemIdAttr}', '${logLabelAttr}')" title="${item.opts.domain}">${item.opts.domain}</td>
<td class="p-3" title="${key}">${truncatedKey}</td> <td class="p-3" title="${key}">${truncatedKey}</td>
<td class="p-3">${item.opts.port}</td> <td class="p-3">${item.opts.port}</td>
<td class="p-3">${protocol}</td> <td class="p-3">${protocol}</td>
+13 -28
View File
@@ -467,7 +467,7 @@ function displayDiagnosticResult(tool, result) {
} }
if (result.output) { if (result.output) {
content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${escapeHtml(result.output)}</pre>`; content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${window.escapeHtml(result.output)}</pre>`;
} }
if (result.results && Array.isArray(result.results)) { if (result.results && Array.isArray(result.results)) {
@@ -530,13 +530,6 @@ function displayBandwidthStats(result) {
`).join(''); `).join('');
} }
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Cancel stream // Cancel stream
function cancelStream(resultId) { function cancelStream(resultId) {
console.log('Cancelling stream:', resultId); console.log('Cancelling stream:', resultId);
@@ -897,14 +890,6 @@ function shortPeerId(peerId) {
return peerId.slice(0, 8) + '…' + peerId.slice(-6); return peerId.slice(0, 8) + '…' + peerId.slice(-6);
} }
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function ratioClass(ok, total) { function ratioClass(ok, total) {
if (total === 0) return 'text-gray-400'; if (total === 0) return 'text-gray-400';
return ok === total ? 'text-green-400' : 'text-yellow-400'; return ok === total ? 'text-green-400' : 'text-yellow-400';
@@ -968,7 +953,7 @@ function displayInviteDiagnostics(diagnostics) {
${statusItems.map(item => ` ${statusItems.map(item => `
<div class="bg-gray-700 rounded px-2 py-1"> <div class="bg-gray-700 rounded px-2 py-1">
<div class="text-gray-400 text-xs">${item.label}</div> <div class="text-gray-400 text-xs">${item.label}</div>
<div class="font-semibold ${item.color} truncate" title="${escapeHtml(item.value)}">${escapeHtml(item.value)}</div> <div class="font-semibold ${item.color} truncate" title="${window.escapeHtml(item.value)}">${window.escapeHtml(item.value)}</div>
</div> </div>
`).join('')} `).join('')}
</div> </div>
@@ -978,10 +963,10 @@ function displayInviteDiagnostics(diagnostics) {
contentDiv.innerHTML += ` contentDiv.innerHTML += `
<div class="bg-gray-700 rounded p-2 text-xs text-gray-300"> <div class="bg-gray-700 rounded p-2 text-xs text-gray-300">
<span class="text-gray-400">Invite wire:</span> <span class="text-gray-400">Invite wire:</span>
<code class="text-indigo-300">${escapeHtml(protocol.inviteWire || 'invite.deliver')}</code> <code class="text-indigo-300">${window.escapeHtml(protocol.inviteWire || 'invite.deliver')}</code>
<span class="text-gray-500 mx-1">·</span> <span class="text-gray-500 mx-1">·</span>
<span class="text-gray-400">Lifecycle:</span> <span class="text-gray-400">Lifecycle:</span>
<code class="text-gray-200">${escapeHtml(protocol.lifecycleChannel || 'p2ns.core-request')}</code> <code class="text-gray-200">${window.escapeHtml(protocol.lifecycleChannel || 'p2ns.core-request')}</code>
${protocol.legacyInviteChannel === false ? '<span class="ml-2 text-gray-500">(no p2ns.core-invite channel)</span>' : ''} ${protocol.legacyInviteChannel === false ? '<span class="ml-2 text-gray-500">(no p2ns.core-invite channel)</span>' : ''}
</div> </div>
`; `;
@@ -1051,10 +1036,10 @@ function displayInviteDiagnostics(diagnostics) {
if (peerEntries.length > 0) { if (peerEntries.length > 0) {
const formatRemote = (remote) => { const formatRemote = (remote) => {
if (!remote) return '<span class="text-gray-500">—</span>'; if (!remote) return '<span class="text-gray-500">—</span>';
if (!remote.ok) return `<span class="text-gray-500">${escapeHtml(remote.error || 'no response')}</span>`; if (!remote.ok) return `<span class="text-gray-500">${window.escapeHtml(remote.error || 'no response')}</span>`;
const parts = [remote.nodeType, remote.dnsPassInitialized ? 'dnsPass' : 'no dnsPass']; const parts = [remote.nodeType, remote.dnsPassInitialized ? 'dnsPass' : 'no dnsPass'];
if (remote.canProvideInvite) parts.push('can invite'); if (remote.canProvideInvite) parts.push('can invite');
return `<span class="text-indigo-300">${escapeHtml(parts.join(' · '))}</span>`; return `<span class="text-indigo-300">${window.escapeHtml(parts.join(' · '))}</span>`;
}; };
const rows = peerEntries.map(([peerId, p]) => { const rows = peerEntries.map(([peerId, p]) => {
@@ -1070,7 +1055,7 @@ function displayInviteDiagnostics(diagnostics) {
const rpcMark = rpcOk ? '<span class="text-green-400">✓</span>' : (p.rpc?.attached ? '<span class="text-yellow-400">○</span>' : '<span class="text-red-400">✗</span>'); const rpcMark = rpcOk ? '<span class="text-green-400">✓</span>' : (p.rpc?.attached ? '<span class="text-yellow-400">○</span>' : '<span class="text-red-400">✗</span>');
return ` return `
<tr class="border-t border-gray-600"> <tr class="border-t border-gray-600">
<td class="py-1 pr-2 font-mono text-gray-300" title="${escapeHtml(peerId)}">${escapeHtml(shortPeerId(peerId))}</td> <td class="py-1 pr-2 font-mono text-gray-300" title="${window.escapeHtml(peerId)}">${window.escapeHtml(shortPeerId(peerId))}</td>
<td class="py-1 text-center">${connOk ? '<span class="text-green-400">✓</span>' : '<span class="text-red-400">✗</span>'}</td> <td class="py-1 text-center">${connOk ? '<span class="text-green-400">✓</span>' : '<span class="text-red-400">✗</span>'}</td>
<td class="py-1 text-center">${reqMark}</td> <td class="py-1 text-center">${reqMark}</td>
<td class="py-1 text-center">${rpcMark}</td> <td class="py-1 text-center">${rpcMark}</td>
@@ -1106,8 +1091,8 @@ function displayInviteDiagnostics(diagnostics) {
if (diagnostics.pendingInviteAcks?.length > 0) { if (diagnostics.pendingInviteAcks?.length > 0) {
const ackRows = diagnostics.pendingInviteAcks.map((a) => ` const ackRows = diagnostics.pendingInviteAcks.map((a) => `
<li class="font-mono text-gray-300">${escapeHtml(shortPeerId(a.peerId))} <li class="font-mono text-gray-300">${window.escapeHtml(shortPeerId(a.peerId))}
${a.inviteId ? `<span class="text-gray-500">id=${escapeHtml(String(a.inviteId).slice(0, 12))}…</span>` : ''} ${a.inviteId ? `<span class="text-gray-500">id=${window.escapeHtml(String(a.inviteId).slice(0, 12))}…</span>` : ''}
<span class="text-gray-500">retries=${a.retryCount ?? 0}</span> <span class="text-gray-500">retries=${a.retryCount ?? 0}</span>
</li> </li>
`).join(''); `).join('');
@@ -1121,7 +1106,7 @@ function displayInviteDiagnostics(diagnostics) {
if (diagnostics.pendingMasterInviteQueue?.length > 0) { if (diagnostics.pendingMasterInviteQueue?.length > 0) {
const qRows = diagnostics.pendingMasterInviteQueue.map((q) => ` const qRows = diagnostics.pendingMasterInviteQueue.map((q) => `
<li class="font-mono text-gray-300">${escapeHtml(shortPeerId(q.peerId))} <li class="font-mono text-gray-300">${window.escapeHtml(shortPeerId(q.peerId))}
<span class="text-gray-500">age=${Math.round((q.ageMs || 0) / 1000)}s retries=${q.retryCount ?? 0}</span> <span class="text-gray-500">age=${Math.round((q.ageMs || 0) / 1000)}s retries=${q.retryCount ?? 0}</span>
</li> </li>
`).join(''); `).join('');
@@ -1152,7 +1137,7 @@ function displayInviteDiagnostics(diagnostics) {
<details class="bg-gray-700 rounded p-2 text-xs"> <details class="bg-gray-700 rounded p-2 text-xs">
<summary class="text-gray-400 cursor-pointer">Connection issues (${diagnostics.connectionIssues.length})</summary> <summary class="text-gray-400 cursor-pointer">Connection issues (${diagnostics.connectionIssues.length})</summary>
<ul class="mt-1 text-orange-300 space-y-0.5 font-mono"> <ul class="mt-1 text-orange-300 space-y-0.5 font-mono">
${diagnostics.connectionIssues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join('')} ${diagnostics.connectionIssues.map((issue) => `<li>${window.escapeHtml(issue)}</li>`).join('')}
</ul> </ul>
</details> </details>
`; `;
@@ -1164,7 +1149,7 @@ function displayInviteDiagnostics(diagnostics) {
<summary class="text-gray-400 cursor-pointer">RPC methods (${protocol.rpcMethods.length})</summary> <summary class="text-gray-400 cursor-pointer">RPC methods (${protocol.rpcMethods.length})</summary>
<p class="mt-1 text-gray-500">Registered on p2ns.core-request-rpc</p> <p class="mt-1 text-gray-500">Registered on p2ns.core-request-rpc</p>
<ul class="mt-1 text-indigo-300 font-mono columns-2 gap-x-4"> <ul class="mt-1 text-indigo-300 font-mono columns-2 gap-x-4">
${protocol.rpcMethods.map((m) => `<li>${escapeHtml(m)}</li>`).join('')} ${protocol.rpcMethods.map((m) => `<li>${window.escapeHtml(m)}</li>`).join('')}
</ul> </ul>
</details> </details>
`; `;
@@ -1175,7 +1160,7 @@ function displayInviteDiagnostics(diagnostics) {
<div class="bg-blue-900 border border-blue-600 rounded p-2"> <div class="bg-blue-900 border border-blue-600 rounded p-2">
<div class="text-xs text-blue-400 mb-1">Recommendations</div> <div class="text-xs text-blue-400 mb-1">Recommendations</div>
<ul class="text-xs text-blue-300 space-y-0.5"> <ul class="text-xs text-blue-300 space-y-0.5">
${diagnostics.recommendations.map((rec) => `<li>• ${escapeHtml(rec)}</li>`).join('')} ${diagnostics.recommendations.map((rec) => `<li>• ${window.escapeHtml(rec)}</li>`).join('')}
</ul> </ul>
</div> </div>
`; `;
+26 -35
View File
@@ -2,15 +2,6 @@
let pluginsData = []; let pluginsData = [];
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Fetch plugins from API // Fetch plugins from API
async function fetchPlugins() { async function fetchPlugins() {
try { try {
@@ -194,7 +185,7 @@ function renderPluginCard(plugin) {
class="admin-btn admin-btn--primary admin-btn--sm" class="admin-btn admin-btn--primary admin-btn--sm"
title="${action.description || action.label}" title="${action.description || action.label}"
> >
${action.icon ? `<i class="fas fa-${escapeHtml(action.icon)}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || action.name} ${action.icon ? `<i class="fas fa-${window.escapeHtml(action.icon)}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || action.name}
</button> </button>
`).join('')} `).join('')}
</div> </div>
@@ -241,18 +232,18 @@ function renderPluginCard(plugin) {
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-4 mb-2"> <div class="flex items-center gap-4 mb-2">
<h3 class="text-xl font-bold theme-text-primary flex items-center gap-2"> <h3 class="text-xl font-bold theme-text-primary flex items-center gap-2">
${plugin.icon ? `<i class="fa-solid fa-${escapeHtml(plugin.icon)}"></i>` : ''} ${plugin.icon ? `<i class="fa-solid fa-${window.escapeHtml(plugin.icon)}"></i>` : ''}
${escapeHtml(plugin.name)} ${window.escapeHtml(plugin.name)}
${isLoading ? '<span class="ml-2 text-sm"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></span>' : ''} ${isLoading ? '<span class="ml-2 text-sm"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></span>' : ''}
</h3> </h3>
<div class="ml-2"> <div class="ml-2">
${statusBadge} ${statusBadge}
</div> </div>
</div> </div>
<p class="text-sm theme-text-secondary">${escapeHtml(plugin.description || 'No description')}</p> <p class="text-sm theme-text-secondary">${window.escapeHtml(plugin.description || 'No description')}</p>
<div class="flex items-center gap-4 mt-2 text-xs theme-text-tertiary"> <div class="flex items-center gap-4 mt-2 text-xs theme-text-tertiary">
<span>v${escapeHtml(plugin.version)}</span> <span>v${window.escapeHtml(plugin.version)}</span>
${plugin.author ? `<span>by ${escapeHtml(plugin.author)}</span>` : ''} ${plugin.author ? `<span>by ${window.escapeHtml(plugin.author)}</span>` : ''}
</div> </div>
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''} ${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
</div> </div>
@@ -334,7 +325,7 @@ function renderPluginCard(plugin) {
</div> </div>
<div class="mt-4 text-xs theme-text-tertiary"> <div class="mt-4 text-xs theme-text-tertiary">
<span>Domain: <code class="theme-glass px-1 rounded">${escapeHtml(plugin.domain)}</code></span> <span>Domain: <code class="theme-glass px-1 rounded">${window.escapeHtml(plugin.domain)}</code></span>
</div> </div>
</div> </div>
`; `;
@@ -359,17 +350,17 @@ function renderSettingInput(domain, key, setting) {
class="w-4 h-4 text-primary theme-glass rounded focus:ring-primary" class="w-4 h-4 text-primary theme-glass rounded focus:ring-primary"
/> />
<label for="${inputId}" class="text-sm theme-text-primary"> <label for="${inputId}" class="text-sm theme-text-primary">
${escapeHtml(setting.label || key)} ${window.escapeHtml(setting.label || key)}
</label> </label>
</div> </div>
${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${escapeHtml(setting.description)}</p>` : ''} ${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${window.escapeHtml(setting.description)}</p>` : ''}
`; `;
case 'number': case 'number':
return ` return `
<div> <div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1"> <label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)} ${window.escapeHtml(setting.label || key)}
</label> </label>
<input <input
type="number" type="number"
@@ -379,7 +370,7 @@ function renderSettingInput(domain, key, setting) {
value="${currentValue}" value="${currentValue}"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary" class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
/> />
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''} ${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div> </div>
`; `;
@@ -387,13 +378,13 @@ function renderSettingInput(domain, key, setting) {
const optionsHtml = (setting.options || []).map(opt => { const optionsHtml = (setting.options || []).map(opt => {
const value = typeof opt === 'object' ? opt.value : opt; const value = typeof opt === 'object' ? opt.value : opt;
const label = typeof opt === 'object' ? opt.label : opt; const label = typeof opt === 'object' ? opt.label : opt;
return `<option value="${escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${escapeHtml(label)}</option>`; return `<option value="${window.escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${window.escapeHtml(label)}</option>`;
}).join(''); }).join('');
return ` return `
<div> <div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1"> <label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)} ${window.escapeHtml(setting.label || key)}
</label> </label>
<select <select
id="${inputId}" id="${inputId}"
@@ -403,7 +394,7 @@ function renderSettingInput(domain, key, setting) {
> >
${optionsHtml} ${optionsHtml}
</select> </select>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''} ${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div> </div>
`; `;
@@ -411,7 +402,7 @@ function renderSettingInput(domain, key, setting) {
return ` return `
<div> <div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1"> <label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)} ${window.escapeHtml(setting.label || key)}
</label> </label>
<textarea <textarea
id="${inputId}" id="${inputId}"
@@ -419,8 +410,8 @@ function renderSettingInput(domain, key, setting) {
data-setting-key="${key}" data-setting-key="${key}"
rows="3" rows="3"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary" class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
>${escapeHtml(currentValue)}</textarea> >${window.escapeHtml(currentValue)}</textarea>
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''} ${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div> </div>
`; `;
@@ -428,17 +419,17 @@ function renderSettingInput(domain, key, setting) {
return ` return `
<div> <div>
<label for="${inputId}" class="block text-sm theme-text-primary mb-1"> <label for="${inputId}" class="block text-sm theme-text-primary mb-1">
${escapeHtml(setting.label || key)} ${window.escapeHtml(setting.label || key)}
</label> </label>
<input <input
type="text" type="text"
id="${inputId}" id="${inputId}"
data-plugin-domain="${domain}" data-plugin-domain="${domain}"
data-setting-key="${key}" data-setting-key="${key}"
value="${escapeHtml(currentValue)}" value="${window.escapeHtml(currentValue)}"
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary" class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
/> />
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''} ${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(setting.description)}</p>` : ''}
</div> </div>
`; `;
} }
@@ -467,16 +458,16 @@ async function collectActionParameters(action) {
const inputId = `plugin-action-param-${index}`; const inputId = `plugin-action-param-${index}`;
const type = param.type || 'string'; const type = param.type || 'string';
const required = param.required ? 'required' : ''; const required = param.required ? 'required' : '';
const placeholder = param.placeholder ? `placeholder="${escapeHtml(param.placeholder)}"` : ''; const placeholder = param.placeholder ? `placeholder="${window.escapeHtml(param.placeholder)}"` : '';
const defaultValue = param.default !== undefined ? String(param.default) : ''; const defaultValue = param.default !== undefined ? String(param.default) : '';
const description = param.description const description = param.description
? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(param.description)}</p>` ? `<p class="text-xs theme-text-tertiary mt-1">${window.escapeHtml(param.description)}</p>`
: ''; : '';
if (type === 'boolean') { if (type === 'boolean') {
return ` return `
<label class="block text-sm theme-text-primary mb-3"> <label class="block text-sm theme-text-primary mb-3">
<span class="block mb-1">${escapeHtml(param.label || param.name)}</span> <span class="block mb-1">${window.escapeHtml(param.label || param.name)}</span>
<select id="${inputId}" class="w-full p-2 theme-input rounded"> <select id="${inputId}" class="w-full p-2 theme-input rounded">
<option value="false" ${defaultValue === 'false' ? 'selected' : ''}>False</option> <option value="false" ${defaultValue === 'false' ? 'selected' : ''}>False</option>
<option value="true" ${defaultValue === 'true' ? 'selected' : ''}>True</option> <option value="true" ${defaultValue === 'true' ? 'selected' : ''}>True</option>
@@ -488,10 +479,10 @@ async function collectActionParameters(action) {
return ` return `
<label class="block text-sm theme-text-primary mb-3"> <label class="block text-sm theme-text-primary mb-3">
<span class="block mb-1">${escapeHtml(param.label || param.name)}${param.required ? ' *' : ''}</span> <span class="block mb-1">${window.escapeHtml(param.label || param.name)}${param.required ? ' *' : ''}</span>
<input id="${inputId}" type="${type === 'number' ? 'number' : 'text'}" <input id="${inputId}" type="${type === 'number' ? 'number' : 'text'}"
class="w-full p-2 theme-input rounded" class="w-full p-2 theme-input rounded"
value="${escapeHtml(defaultValue)}" value="${window.escapeHtml(defaultValue)}"
${placeholder} ${placeholder}
${required} /> ${required} />
${description} ${description}
@@ -501,7 +492,7 @@ async function collectActionParameters(action) {
const confirmed = await window.ConfirmationModal.show({ const confirmed = await window.ConfirmationModal.show({
title: action.label || action.name || 'Run Action', title: action.label || action.name || 'Run Action',
message: `<div><p class="theme-text-secondary mb-3">${escapeHtml(action.description || 'Provide action parameters.')}</p><div>${formFields}</div></div>`, message: `<div><p class="theme-text-secondary mb-3">${window.escapeHtml(action.description || 'Provide action parameters.')}</p><div>${formFields}</div></div>`,
type: 'info', type: 'info',
confirmText: 'Run Action', confirmText: 'Run Action',
cancelText: 'Cancel', cancelText: 'Cancel',
-296
View File
@@ -1,296 +0,0 @@
/**
* P2NS Admin Panel - Utilities
* Consolidated utilities matching the P2NS Plugin SDK
*/
// Initialize sdk global if not present
window.sdk = window.sdk || {};
window.sdk.utils = window.sdk.utils || {};
/**
* Formatting Utilities
*/
window.sdk.utils.format = {
/**
* Format a timestamp to human-readable relative time
*/
formatTimestamp(timestamp) {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) {
return `${Math.floor(diff / 1000)}s ago`;
} else if (diff < 3600000) {
return `${Math.floor(diff / 60000)}m ago`;
} else if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}h ago`;
} else {
return date.toLocaleString();
}
},
/**
* Format a peer ID to shortened version
*/
formatPeerId(peerId, short = true) {
if (!peerId) return 'N/A';
if (!short) return peerId;
if (peerId.length <= 16) return peerId;
return `${peerId.slice(0, 8)}...${peerId.slice(-8)}`;
},
/**
* Format a hash to shortened version
*/
formatHash(hash) {
if (!hash) return 'N/A';
if (hash.length <= 20) return hash;
return `${hash.slice(0, 10)}...${hash.slice(-10)}`;
},
/**
* Format duration (milliseconds to readable string)
*/
formatDuration(ms) {
if (!ms || ms === 0) return '-';
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(2)}m`;
return `${(ms / 3600000).toFixed(2)}h`;
},
/**
* Format uptime (milliseconds to readable string)
*/
formatUptime(ms) {
if (!ms || ms === 0) return '0s';
const days = Math.floor(ms / 86400000);
const hours = Math.floor((ms % 86400000) / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
},
/**
* Format bytes to human readable string
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
if (!bytes) return 'N/A';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
/**
* Format number with commas
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
},
/**
* Format CPU usage
*/
formatCPUUsage(cpuUsage, uptime) {
if (!cpuUsage) return 'N/A';
if (typeof cpuUsage.percentage === 'number') {
return `${cpuUsage.percentage.toFixed(2)}%`;
}
if (typeof cpuUsage.user === 'number' && typeof cpuUsage.system === 'number') {
if (uptime && uptime > 0) {
const uptimeMicroseconds = uptime * 1000;
const totalCpuMicroseconds = cpuUsage.user + cpuUsage.system;
const cpuPercent = (totalCpuMicroseconds / uptimeMicroseconds) * 100;
return `${cpuPercent.toFixed(2)}%`;
}
return `${(cpuUsage.user / 1000).toFixed(2)}ms user, ${(cpuUsage.system / 1000).toFixed(2)}ms system`;
}
return 'N/A';
},
/**
* Format memory usage
*/
formatMemoryUsage(memoryUsage) {
if (!memoryUsage) return 'N/A';
const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2);
const heapUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(2);
const heapTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(2);
return `${rssMB} MB RSS (${heapUsedMB}/${heapTotalMB} MB heap)`;
},
/**
* Format Holesail hash for display
*/
formatHolesailHash(hash) {
if (!hash) return 'none';
if (hash.startsWith('hs://')) return hash;
return `hs://${hash}`;
}
};
/**
* DOM and UI Utilities
*/
window.sdk.utils.dom = {
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
async copyToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
throw new Error('Clipboard API not available');
} catch (err) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
}
},
truncate(text, maxLength = 40) {
if (!text || text === 'N/A') return 'N/A';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
};
/**
* Status and Badge Utilities
*/
window.sdk.utils.status = {
renderStatusBadge(state) {
const badges = {
'running': { class: 'bg-green-500', icon: '✓', text: 'Running' },
'stopped': { class: 'bg-gray-500', icon: '○', text: 'Stopped' },
'starting': { class: 'bg-yellow-500', icon: '⟳', text: 'Starting' },
'error': { class: 'bg-red-500', icon: '✗', text: 'Error' }
};
const badge = badges[state] || badges['stopped'];
return `<span class="px-2 py-1 ${badge.class} text-xs font-semibold rounded-full flex items-center gap-1 w-fit" style="color: var(--text-primary);" title="${badge.text}">
<span>${badge.icon}</span>
<span>${badge.text}</span>
</span>`;
},
getConsensusBadgeClass(status) {
switch (status) {
case 'resolved': return 'status-badge resolved';
case 'conflict': return 'status-badge conflict';
case 'insufficient_quorum': return 'status-badge insufficient_quorum';
case 'tie': return 'status-badge tie';
case 'no_claims': return 'status-badge no_claims';
case 'error': return 'status-badge error';
default: return 'status-badge no_claims';
}
},
/**
* Get display text for a consensus status
*/
getConsensusText(status) {
switch (status) {
case 'resolved': return 'Resolved';
case 'conflict': return 'Conflict';
case 'insufficient_quorum': return 'Insufficient Quorum';
case 'tie': return 'Tie';
case 'no_claims': return 'No Claims';
case 'error': return 'Error';
default: return 'Unknown';
}
},
/**
* Get hex color for a consensus status
*/
getConsensusColor(status) {
switch (status) {
case 'resolved': return '#10b981';
case 'conflict': return '#ef4444';
case 'insufficient_quorum': return '#eab308';
case 'tie': return '#f97316';
case 'no_claims': return '#6b7280';
case 'error': return '#ef4444';
default: return '#6b7280';
}
}
};
/**
* Legacy compatibility layers
*/
window.renderStatusBadge = window.sdk.utils.status.renderStatusBadge;
window.truncateUrl = (url, maxLength) => window.sdk.utils.dom.truncate(url, maxLength);
window.escapeHtml = window.sdk.utils.dom.escapeHtml;
window.formatUptime = window.sdk.utils.format.formatUptime;
window.formatDuration = window.sdk.utils.format.formatDuration;
window.formatCPUUsage = window.sdk.utils.format.formatCPUUsage;
window.formatMemoryUsage = window.sdk.utils.format.formatMemoryUsage;
// Default chart colors if not defined
window.chartColors = window.chartColors || {
primary: 'rgb(59, 130, 246)',
success: 'rgb(34, 197, 94)',
warning: 'rgb(234, 179, 8)',
danger: 'rgb(239, 68, 68)',
info: 'rgb(59, 130, 246)',
gray: 'rgb(107, 114, 128)',
dark: 'rgb(17, 24, 39)'
};
window.darkModeColors = window.darkModeColors || {
primary: 'rgb(96, 165, 250)',
success: 'rgb(74, 222, 128)',
warning: 'rgb(250, 204, 21)',
danger: 'rgb(248, 113, 113)',
info: 'rgb(96, 165, 250)',
gray: 'rgb(156, 163, 175)',
dark: 'rgb(243, 244, 246)'
};
window.getChartColors = () => {
return document.documentElement.classList.contains('dark') ? window.darkModeColors : window.chartColors;
};
@@ -171,6 +171,8 @@ function connectWebSocket() {
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) { if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-clients'); window.genericRenderPaginated('host-clients');
} }
}).catch((err) => {
if (window.showNotification) window.showNotification(`Failed to refresh holesail clients: ${err.message}`, 'error');
}); });
} }
return; return;
@@ -198,6 +200,8 @@ function connectWebSocket() {
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) { if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-servers'); window.genericRenderPaginated('host-servers');
} }
}).catch((err) => {
if (window.showNotification) window.showNotification(`Failed to refresh holesail servers: ${err.message}`, 'error');
}); });
} }
return; return;
-137
View File
@@ -1,137 +0,0 @@
// Main admin entry point - loads all modules and initializes the application
// Load order: config -> state -> utils -> core -> notifications -> ws-client -> ui modules -> main init
// Initialize when DOM is ready
function initializeApp() {
// showTab function - must be defined after all modules are loaded
function showTab(tabId) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
const tabEl = document.getElementById(tabId);
if (tabEl) tabEl.classList.remove('hidden');
window.activeTab = tabId;
if (window.tabs && window.tabs[tabId] && window.genericFetch) {
window.genericFetch(tabId, true);
}
if (tabId === 'host') {
if (window.genericFetch) {
window.genericFetch('host-servers', true);
window.genericFetch('host-clients', true);
}
if (!window.wsConnected) {
if (window.startPollingFallback) window.startPollingFallback();
} else {
if (window.stopPollingFallback) window.stopPollingFallback();
}
} 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();
}
if (tabId === 'stats') {
if (window.renderStats) window.renderStats();
if (!window.statsUpdateInterval && window.startStatsUpdates) {
window.startStatsUpdates();
}
} else {
if (window.stopStatsUpdates) window.stopStatsUpdates();
}
// Note: fetchSubnets() is now called from renderSettings() after the subnet configurator HTML is created
// This ensures the DOM elements exist before attempting to populate them
}
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() || '';
const container = document.getElementById('settingsContainer');
if (!container) return;
const categories = container.querySelectorAll('.settings-category');
categories.forEach(category => {
const categoryTitle = category.querySelector('h3')?.textContent.toLowerCase() || '';
const items = category.querySelectorAll('.settings-item');
let categoryVisible = categoryTitle.includes(query);
items.forEach(item => {
const label = item.querySelector('label')?.textContent.toLowerCase() || '';
const description = item.querySelector('.settings-description')?.textContent.toLowerCase() || '';
const matches = label.includes(query) || description.includes(query);
item.style.display = matches ? '' : 'none';
if (matches) categoryVisible = true;
});
category.style.display = categoryVisible ? '' : 'none';
});
}
window.filterSettings = filterSettings;
// Initial load
const hash = location.hash.substring(1);
const tabId = hash && document.getElementById(hash) ? hash : 'domains';
showTab(tabId);
if (window.startStatusUpdates) window.startStatusUpdates();
// Pre-load local-dns data so it's available when the tab is accessed
if (window.genericFetch) {
window.genericFetch('local-dns', false);
}
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
// Handle hash changes
window.addEventListener('hashchange', () => {
const tabId = location.hash.substring(1);
if (tabId && document.getElementById(tabId) && window.showTab) {
window.showTab(tabId);
}
});
-167
View File
@@ -1,167 +0,0 @@
const fs = require('fs').promises;
const state = require('../infrastructure/state');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
const selectorCacheFile = process.env.SELECTOR_CACHE_FILE || './cache/selector_cache.json';
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
async function loadSelectorCache() {
try {
if (await fs.access(selectorCacheFile).then(() => true).catch(() => false)) {
const data = JSON.parse(await fs.readFile(selectorCacheFile, 'utf8'));
// 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();
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 = {
versionPreferences: Object.fromEntries(state.versionPreferences),
hashPreferences: Object.fromEntries(state.hashPreferences)
};
await fs.writeFile(selectorCacheFile, JSON.stringify(data, null, 2));
logDebug('Admin', 'Saved version and hash preferences to selector_cache.json');
} catch (err) {
logError('Admin', `Failed to save selector_cache.json: ${err.message}`);
}
}
async function loadLocalDnsRecords() {
try {
if (await fs.access(localDnsFile).then(() => true).catch(() => false)) {
const parsed = JSON.parse(await fs.readFile(localDnsFile, 'utf8'));
// Ensure parsed result is an array
if (!Array.isArray(parsed)) {
logError('Admin', `Local DNS records file does not contain an array, resetting to empty array`);
state.localDnsRecords = [];
return;
}
// Ensure each record has a 'type' and 'class' field
state.localDnsRecords = parsed.map(record => ({
...record,
class: record.class || 'IN',
type: record.type || 'A' // Default to A if type is missing
}));
} else {
state.localDnsRecords = [];
}
} catch (err) {
logError('Admin', `Failed to load local DNS records: ${err.message}`);
state.localDnsRecords = [];
}
}
async function cleanupHashPreferences() {
try {
// Lazy require to avoid circular dependencies
let getConsensusState, getLocalClaimHash, getAllEntries, getPersistentPublicKey;
try {
const coreModule = require('../core/core');
getConsensusState = coreModule.getConsensusState;
getLocalClaimHash = coreModule.getLocalClaimHash;
getAllEntries = coreModule.getAllEntries;
const utilsModule = require('../infrastructure/utils');
getPersistentPublicKey = utilsModule.getPersistentPublicKey;
} catch (requireErr) {
logError('Admin', `Failed to require core modules for hash preferences cleanup: ${requireErr.message}`);
return;
}
const localWriter = getPersistentPublicKey();
if (!localWriter) {
logDebug('Admin', 'No local writer available for hash preferences cleanup');
return;
}
const allEntries = await getAllEntries(state.dnsPass, false); // Use fresh data, bypass cache
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);
}
}
}
// Check each hash preference to see if it's still valid
const domainsToRemove = [];
for (const [domain, preference] of state.hashPreferences) {
// Check if user still has a local claim for this domain
if (!domainClaimants.has(domain) || !domainClaimants.get(domain).has(localWriter)) {
logInfo('Admin', `Removing hash preference for ${domain}: user no longer has local claim`);
domainsToRemove.push(domain);
continue;
}
// Check if consensus is still resolved to a different claimant
const consensusState = await getConsensusState(domain);
if (consensusState.status !== 'resolved' || consensusState.resolvedClaimant === localWriter) {
logInfo('Admin', `Removing hash preference for ${domain}: conflict no longer exists (status=${consensusState.status}, resolvedClaimant=${consensusState.resolvedClaimant})`);
domainsToRemove.push(domain);
continue;
}
// Verify local hash still exists
const localHash = await getLocalClaimHash(domain, localWriter);
if (!localHash) {
logInfo('Admin', `Removing hash preference for ${domain}: local claim hash not found`);
domainsToRemove.push(domain);
continue;
}
}
// Remove invalid preferences
if (domainsToRemove.length > 0) {
for (const domain of domainsToRemove) {
state.hashPreferences.delete(domain);
}
await saveSelectorCache();
logInfo('Admin', `Cleaned up ${domainsToRemove.length} invalid hash preference(s) from selector_cache.json`);
} else {
logDebug('Admin', 'All hash preferences are valid');
}
} catch (err) {
logError('Admin', `Failed to cleanup hash preferences: ${err.message}`);
}
}
// Initialize on load
loadSelectorCache();
loadLocalDnsRecords();
module.exports = {
loadSelectorCache,
saveSelectorCache,
loadLocalDnsRecords,
cleanupHashPreferences
};
-172
View File
@@ -1,172 +0,0 @@
const fs = require('fs').promises;
const pathModule = require('path');
const child_process = require('child_process');
const state = require('../infrastructure/state');
const { logDebug, logError, logInfo } = require('../infrastructure/logger');
const { createInterfaceForDomain } = require('../networking/virtual_interfaces');
const { ensurePortFree } = require('./port-management');
const { startHolesailClient } = require('./admin-holesail');
const { broadcast } = require('./websocket');
const { isSecureHolesailKey } = require('../infrastructure/utils');
const holesailClientsFile = process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json';
function getHolesailClientStatus(id, opts, info = {}) {
if (info.state === 'error') {
return 'error';
}
const child = state.holesailClientChildren.get(id);
if (child && !child.killed) {
return 'running';
}
const key = `${opts.domain}:${opts.port}`;
if (state.holesails.has(key)) {
return 'running';
}
if (info.state === 'starting') {
return 'starting';
}
return 'stopped';
}
async function loadHolesailClients() {
state.holesailClientChildren = new Map();
state.holesailClientOpts = new Map();
state.holesailClientInfos = new Map();
const file = holesailClientsFile;
try {
if (await fs.access(file).then(() => true).catch(() => false)) {
const data = JSON.parse(await fs.readFile(file, 'utf8'));
const promises = (data.clients || []).map(async (s) => {
const id = s.id;
const opts = s.opts;
// Check if already exists in state.holesails (from domain init path)
const runtimeKey = `${opts.domain}:${opts.port}`;
if (state.holesails.has(runtimeKey)) {
logDebug('Holesail', `Skipping restore for ${id} - client already exists for ${runtimeKey}`);
return;
}
// Check global activeClientKeys to prevent double-bind
if (state.activeClientKeys && state.activeClientKeys.has(runtimeKey)) {
logDebug('Holesail', `Skipping restore for ${id} - client already starting for ${runtimeKey}`);
return;
}
try {
if (!state.domainToIPMap.has(opts.domain)) {
await createInterfaceForDomain(opts.domain);
logDebug('Holesail', `Assigned IP to ${opts.domain} for client ${id}: ${state.domainToIPMap.get(opts.domain)}`);
}
const ip = state.domainToIPMap.get(opts.domain);
const portFree = await ensurePortFree(ip, opts.port);
if (!portFree) {
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
}
// Mark as active before spawning to prevent race with other paths
if (state.activeClientKeys) {
state.activeClientKeys.add(runtimeKey);
}
await startForkedHolesailClient(id, opts);
logInfo('Holesail', `Restored client ${id} for domain ${opts.domain} on port ${opts.port}`);
} catch (err) {
logError('Holesail', `Failed to restore client ${id} for ${opts.domain}:${opts.port}: ${err.message}`);
// Clean up activeClientKeys on failure
if (state.activeClientKeys) {
state.activeClientKeys.delete(runtimeKey);
}
}
});
await Promise.all(promises);
broadcast({ type: 'update-holesail-clients' });
} else {
logInfo('Holesail', 'No holesail_clients.json found, skipping restore');
}
} catch (err) {
logError('Holesail', `Failed to load holesail_clients.json: ${err.message}`);
}
}
async function startForkedHolesailClient(id, opts) {
return new Promise((resolve, reject) => {
if (!state.domainToIPMap.has(opts.domain)) {
reject(new Error(`No IP assigned for domain ${opts.domain}`));
return;
}
const ip = state.domainToIPMap.get(opts.domain);
const secure = isSecureHolesailKey(opts.key);
logDebug('Holesail', `Creating forked Holesail client for ${opts.domain} with secure=${secure} (key starts with: ${opts.key.substring(0, 10)}...)`);
const childOpts = {
client: true,
key: opts.key,
port: opts.port,
host: ip,
secure: secure,
log: false,
protocol: opts.protocol || 'tcp'
};
const child = child_process.fork(pathModule.join(__dirname, '..', 'networking', 'holesail_child.js'));
child.on('error', (err) => {
logError('Holesail', `Child error for client ${id}: ${err.message}`);
reject(err);
});
child.on('exit', (code) => {
logInfo('Holesail', `Child exited for client ${id} with code ${code}`);
// Remove all event listeners to prevent leaks
child.removeAllListeners();
// Clean up activeClientKeys on child exit
const runtimeKey = `${opts.domain}:${opts.port}`;
if (state.activeClientKeys) {
state.activeClientKeys.delete(runtimeKey);
}
state.holesailClientChildren.delete(id);
state.holesailClientInfos.delete(id);
state.holesailChildStartTimes.delete(id);
broadcast({ type: 'update-holesail-clients' });
});
child.on('message', async (msg) => {
if (msg.type === 'ready') {
try {
state.holesailClientInfos.set(id, msg.info);
await startHolesailClient(opts.domain, opts.key, ip, opts.port, true, opts.protocol);
state.holesailClientChildren.set(id, child);
state.holesailClientOpts.set(id, opts);
state.holesailChildStartTimes.set(id, Date.now());
broadcast({ type: 'update-holesail-clients' });
resolve({ id, info: msg.info });
} catch (err) {
logError('Holesail', `Failed to start Holesail client for ${id}: ${err.message}`);
reject(err);
}
} else if (msg.type === 'log') {
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
} else if (msg.type === 'error') {
logError('Holesail', `Child error message for client ${id}: ${msg.message}`);
reject(new Error(msg.message));
}
});
child.send({ type: 'start', opts: childOpts });
});
}
async function saveHolesailClients() {
const file = holesailClientsFile;
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => ({ id, opts }));
try {
await fs.writeFile(file, JSON.stringify({ clients }, null, 2));
logDebug('Holesail', 'Saved holesail_clients.json');
} catch (err) {
logError('Holesail', `Failed to save holesail_clients.json: ${err.message}`);
}
}
module.exports = {
loadHolesailClients,
startForkedHolesailClient,
saveHolesailClients,
getHolesailClientStatus
};
-107
View File
@@ -1,107 +0,0 @@
const fs = require('fs').promises;
const pathModule = require('path');
const child_process = require('child_process');
const crypto = require('crypto');
const z32 = require('z32');
const libKeys = require('hyper-cmd-lib-keys');
const state = require('../infrastructure/state');
const { logDebug, logError, logInfo } = require('../infrastructure/logger');
const { trackHolesailEvent } = require('../maintenance/metrics');
const { broadcast } = require('./websocket');
const holesailServersFile = process.env.HOLESAIL_SERVERS_FILE || './cache/holesail_servers.json';
async function loadHolesailServers() {
state.holesailChildren = new Map();
state.holesailOpts = new Map();
state.holesailInfos = new Map();
const file = holesailServersFile;
try {
if (await fs.access(file).then(() => true).catch(() => false)) {
const data = JSON.parse(await fs.readFile(file, 'utf8'));
const promises = (data.servers || []).map(async (s) => {
const id = s.id;
const opts = s.opts;
try {
logDebug('Admin', `Starting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
await startHolesailServer(id, opts);
logInfo('Holesail', `Restored server ${id} (${opts.name || 'unnamed'}) on port ${opts.port}`);
} catch (err) {
logError('Holesail', `Failed to restore server ${id} on port ${opts.port}: ${err.message}`);
}
});
await Promise.all(promises);
} else {
logInfo('Holesail', 'No holesail_servers.json found, skipping restore');
}
} catch (err) {
logError('Holesail', `Failed to load holesail_servers.json: ${err.message}`);
}
}
function startHolesailServer(id, opts) {
return new Promise((resolve, reject) => {
if (!opts.key) {
if (opts.secure) {
opts.key = libKeys.randomBytes(32).toString('hex');
} else {
opts.key = z32.encode(crypto.randomBytes(32));
}
}
const child = child_process.fork(pathModule.join(__dirname, '..', 'networking', 'holesail_child.js'));
child.on('error', (err) => {
logError('Holesail', `Child error for server ${id}: ${err.message}`);
reject(err);
});
child.on('exit', (code) => {
logInfo('Holesail', `Child exited for server ${id} with code ${code}`);
// Remove all event listeners to prevent leaks
child.removeAllListeners();
const opts = state.holesailOpts.get(id);
const protocol = opts?.udp ? 'udp' : 'tcp';
trackHolesailEvent('server', 'stop', protocol, null);
state.holesailChildren.delete(id);
state.holesailInfos.delete(id);
state.holesailChildStartTimes.delete(id);
broadcast({ type: 'update-holesail' });
broadcast({ type: 'update-stats' });
});
child.on('message', (msg) => {
if (msg.type === 'ready') {
state.holesailInfos.set(id, msg.info);
const protocol = opts.udp ? 'udp' : 'tcp';
trackHolesailEvent('server', 'start', protocol, null);
broadcast({ type: 'update-holesail' });
broadcast({ type: 'update-stats' });
resolve({ id, info: msg.info });
} else if (msg.type === 'log') {
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
} else if (msg.type === 'error') {
logError('Holesail', `Child error message for server ${id}: ${msg.message}`);
reject(new Error(msg.message));
}
});
child.send({ type: 'start', opts: { server: true, ...opts, log: false } });
state.holesailChildren.set(id, child);
state.holesailOpts.set(id, opts);
state.holesailChildStartTimes.set(id, Date.now());
});
}
async function saveHolesailServers() {
const file = holesailServersFile;
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => ({ id, opts }));
try {
await fs.writeFile(file, JSON.stringify({ servers }, null, 2));
logDebug('Holesail', 'Saved holesail_servers.json');
} catch (err) {
logError('Holesail', `Failed to save holesail_servers.json: ${err.message}`);
}
}
module.exports = {
loadHolesailServers,
startHolesailServer,
saveHolesailServers
};
-846
View File
@@ -1,846 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P2NS Admin Panel</title>
<link rel="stylesheet" href="/tailwind.css">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white min-h-screen transition-colors duration-300">
<div class="container mx-auto p-6 max-w-7xl">
<h1 class="text-4xl font-extrabold text-center mb-8">P2NS Admin Panel</h1>
<nav class="flex justify-center mb-8 space-x-4 flex-wrap">
<button onclick="location.hash = 'domains';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Domains</button>
<button onclick="location.hash = 'host';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Host</button>
<button onclick="location.hash = 'local-dns';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Local DNS</button>
<button onclick="location.hash = 'entries';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Entries</button>
<button onclick="location.hash = 'peers';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Peers</button>
<button onclick="location.hash = 'certs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Certificates</button>
<button onclick="location.hash = 'interfaces';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Interfaces</button>
<button onclick="location.hash = 'logs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Logs</button>
<button onclick="location.hash = 'stats';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Stats</button>
<button onclick="location.hash = 'settings';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Settings</button>
</nav>
<div id="domains" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Domains
<button onclick="openInfoModal('domains')" 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-domains" type="text" placeholder="Search domains..." 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="filterDomains()">
</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">Hash</th>
<th class="p-3 text-left">Actions</th>
</tr>
</thead>
<tbody id="domainsTable"></tbody>
</table>
</div>
<div id="domainsPagination" class="flex justify-center mt-4 space-x-2"></div>
<button onclick="openAddModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add Domain</button>
</div>
<div id="local-dns" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Local DNS
</h2>
<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>
<!-- 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>
<!-- 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>
<!-- 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>
<div id="entries" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Autopass Entries
<button onclick="openInfoModal('entries')" 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-entries" type="text" placeholder="Search entries..." 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="filterEntries()">
</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">Key</th>
<th class="p-3 text-left">Value</th>
</tr>
</thead>
<tbody id="entriesTable"></tbody>
</table>
</div>
<div id="entriesPagination" class="flex justify-center mt-4 space-x-2"></div>
</div>
<div id="peers" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Connected Peers <span id="peers-count" class="text-lg"></span>
<button onclick="openInfoModal('peers')" 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-peers" type="text" placeholder="Search peers..." 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="filterPeers()">
</div>
<ul id="peersList" class="space-y-3"></ul>
<div id="peersPagination" class="flex justify-center mt-4 space-x-2"></div>
</div>
<div id="certs" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Domain Certificates
<button onclick="openInfoModal('certs')" 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-certs" type="text" placeholder="Search certificates..." 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="filterCerts()">
</div>
<ul id="certsList" class="space-y-3"></ul>
<div id="certsPagination" class="flex justify-center mt-4 space-x-2"></div>
<div class="mt-4">
<input id="cert-domain" placeholder="Domain for Cert" class="p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<button onclick="generateCert()" class="ml-2 px-4 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Generate Cert</button>
</div>
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
CA Management
<button onclick="openInfoModal('ca-management')" 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>
<button onclick="regenerateCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover mr-4">Regenerate Root CA</button>
<button onclick="installCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Install Root CA</button>
</div>
<div id="interfaces" class="tab-content hidden flex flex-col overflow-hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
Virtual Interfaces
<button onclick="openInfoModal('interfaces')" 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 flex-shrink-0">
<input id="search-interfaces" type="text" placeholder="Search interfaces..." 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="filterInterfaces()">
</div>
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="overflow-x-auto overflow-y-auto flex-1">
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
<thead class="bg-gray-200 dark:bg-gray-700 sticky top-0">
<tr>
<th class="p-3 text-left">Domain</th>
<th class="p-3 text-left">IP</th>
</tr>
</thead>
<tbody id="interfacesTable"></tbody>
</table>
</div>
<div id="interfacesPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
</div>
</div>
<div id="logs" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Logs
<button onclick="openInfoModal('logs')" 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 id="terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
</div>
<div id="host" class="tab-content hidden">
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
Holesail Servers
<button onclick="openInfoModal('holesail-servers')" 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-holesail" type="text" placeholder="Search servers..." 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="filterHolesailServers()">
</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/ID</th>
<th class="p-3 text-left">Port</th>
<th class="p-3 text-left">Host</th>
<th class="p-3 text-left">URL</th>
<th class="p-3 text-left">Protocol</th>
<th class="p-3 text-left">Status</th>
<th class="p-3 text-left">Actions</th>
</tr>
</thead>
<tbody id="holesailTable"></tbody>
</table>
</div>
<div id="holesailPagination" class="flex justify-center mt-4 space-x-2"></div>
<button onclick="openCreateHolesailModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create Server</button>
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
Holesail Clients
<button onclick="openInfoModal('holesail-clients')" 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-holesail-clients" type="text" placeholder="Search clients..." 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="filterHolesailClients()">
</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">Key</th>
<th class="p-3 text-left">Port</th>
<th class="p-3 text-left">Protocol</th>
<th class="p-3 text-left">Status</th>
<th class="p-3 text-left">Actions</th>
</tr>
</thead>
<tbody id="holesailClientsTable"></tbody>
</table>
</div>
<div id="holesailClientsPagination" class="flex justify-center mt-4 space-x-2"></div>
<button onclick="openCreateClientModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create Client</button>
</div>
<div id="settings" class="tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold flex items-center gap-2">
Settings
<button onclick="openInfoModal('settings')" 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>
<button onclick="saveSettings()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Save Settings</button>
</div>
<div class="mb-6">
<input id="search-settings" type="text" placeholder="Search settings..." 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="filterSettings()">
</div>
<div id="settingsContainer" class="space-y-6"></div>
</div>
<div id="stats" class="tab-content hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center gap-2">
Statistics & Metrics
<button onclick="openInfoModal('stats')" 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="flex items-center space-x-4">
<label class="flex items-center">
<input type="checkbox" id="auto-refresh-stats" checked class="mr-2">
<span>Auto-refresh:</span>
</label>
<select id="refresh-interval-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
<option value="1000">Realtime (1s)</option>
<option value="2000" selected>Fast (2s)</option>
<option value="5000">Normal (5s)</option>
<option value="10000">Slow (10s)</option>
<option value="30000">Very Slow (30s)</option>
</select>
<select id="time-range-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
<option value="1">Last 1 minute</option>
<option value="5" selected>Last 5 minutes</option>
<option value="15">Last 15 minutes</option>
<option value="30">Last 30 minutes</option>
<option value="60">Last 1 hour</option>
<option value="360">Last 6 hours</option>
<option value="1440">Last 24 hours</option>
<option value="2880">Last 48 hours</option>
</select>
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
</div>
</div>
<!-- System Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">System Uptime</h3>
<p id="uptime-display" class="text-2xl font-bold whitespace-nowrap">-</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Node Type</h3>
<p id="node-type-display" class="text-2xl font-bold mb-1 whitespace-nowrap">-</p>
<p id="connection-status" class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">-</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Connected Peers</h3>
<p id="peers-current" class="text-2xl font-bold whitespace-nowrap">-</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Total Domains</h3>
<p id="domains-current" class="text-2xl font-bold whitespace-nowrap">-</p>
</div>
</div>
<!-- DNS Statistics Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">DNS Statistics</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Queries</p>
<p id="dns-queries-total" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Success Rate</p>
<p id="dns-success-rate" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Response Time</p>
<p id="dns-avg-response" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">P2P Rate</p>
<p id="dns-p2p-rate" class="text-2xl font-bold">-</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<canvas id="dns-queries-chart"></canvas>
</div>
<div>
<canvas id="dns-types-chart"></canvas>
</div>
</div>
<div class="mt-4">
<h4 class="text-lg font-semibold mb-2">Top Queried Domains</h4>
<div id="top-domains-list" class="space-y-2"></div>
</div>
</div>
<!-- Network & Peers Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Network & Peers</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Connected</p>
<p id="peers-connected-count" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Connections</p>
<p id="peers-total-connections" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Duration</p>
<p id="peers-avg-duration" class="text-2xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="peer-events-chart"></canvas>
</div>
</div>
<!-- Holesail Statistics Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Holesail Statistics</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Active Connections</p>
<p id="holesail-active" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Clients Started</p>
<p id="holesail-clients-started" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Servers Started</p>
<p id="holesail-servers-started" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Duration</p>
<p id="holesail-avg-duration" class="text-2xl font-bold">-</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<canvas id="holesail-events-chart"></canvas>
</div>
<div>
<canvas id="holesail-protocol-chart"></canvas>
</div>
</div>
</div>
<!-- Holesail Children Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Holesail Children</h3>
<div id="holesail-children-container" class="space-y-4">
<p class="text-gray-500 dark:text-gray-400 text-center">Loading children data...</p>
</div>
</div>
<!-- API & Requests Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">API & Requests</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Requests</p>
<p id="requests-total" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Success Rate</p>
<p id="requests-success-rate" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Response Time</p>
<p id="requests-avg-response" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Failed Requests</p>
<p id="requests-failed" class="text-2xl font-bold">-</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<canvas id="requests-timeline-chart"></canvas>
</div>
<div>
<canvas id="endpoint-usage-chart"></canvas>
</div>
</div>
</div>
<!-- Resource Usage Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Resource Usage</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sockets</p>
<p id="resources-sockets" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Active Servers</p>
<p id="resources-servers" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Holesail Connections</p>
<p id="resources-holesails" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Peer Channels</p>
<p id="resources-channels" class="text-2xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="resources-timeline-chart"></canvas>
</div>
</div>
<!-- Domain Management Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Domain Management</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Added</p>
<p id="domains-added" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Removed</p>
<p id="domains-removed" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Net Change</p>
<p id="domains-net" class="text-2xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="domain-events-chart"></canvas>
</div>
</div>
<!-- Process Usage Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 class="text-xl font-bold mb-4">Process Usage Statistics</h3>
<!-- Process Info Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Node.js Version</h4>
<p id="process-node-version" class="text-lg font-bold">-</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Platform</h4>
<p id="process-platform" class="text-lg font-bold">-</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Architecture</h4>
<p id="process-arch" class="text-lg font-bold">-</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Process Uptime</h4>
<p id="process-uptime" class="text-lg font-bold">-</p>
</div>
</div>
<!-- Memory Usage -->
<div class="mb-6">
<h4 class="text-lg font-semibold mb-4">Memory Usage</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Heap Used</p>
<p id="process-memory-heap-used" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Heap Total</p>
<p id="process-memory-heap-total" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">RSS</p>
<p id="process-memory-rss" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">External</p>
<p id="process-memory-external" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Array Buffers</p>
<p id="process-memory-array-buffers" class="text-xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="process-memory-chart"></canvas>
</div>
</div>
<!-- CPU Usage -->
<div class="mb-6">
<h4 class="text-lg font-semibold mb-4">CPU Usage</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Current CPU %</p>
<p id="process-cpu-current" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Average CPU %</p>
<p id="process-cpu-avg" class="text-2xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Active Handles</p>
<p id="process-handles-active" class="text-2xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="process-cpu-chart"></canvas>
</div>
</div>
<!-- System Resources -->
<div class="mb-6">
<h4 class="text-lg font-semibold mb-4">System Resources</h4>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Memory</p>
<p id="process-system-total-memory" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Free Memory</p>
<p id="process-system-free-memory" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Used Memory %</p>
<p id="process-system-used-percent" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Load Average</p>
<p id="process-system-load-avg" class="text-xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="process-system-chart"></canvas>
</div>
</div>
<!-- Event Loop Performance -->
<div class="mb-6">
<h4 class="text-lg font-semibold mb-4">Event Loop Performance</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Current Lag</p>
<p id="process-eventloop-current" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Average Lag</p>
<p id="process-eventloop-avg" class="text-xl font-bold">-</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Max Lag</p>
<p id="process-eventloop-max" class="text-xl font-bold">-</p>
</div>
</div>
<div>
<canvas id="process-eventloop-chart"></canvas>
</div>
</div>
</div>
</div>
<dialog id="addDomainModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Add New Domain</h3>
<input id="modal-domain" placeholder="Domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="modal-hash" placeholder="Hash" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<label class="flex items-center mb-4">
<input id="modal-ssl" type="checkbox" class="mr-2">
<span>This Holesail Connection Uses SSL/TLS</span>
</label>
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('addDomainModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button onclick="submitAddDomain()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
</div>
</dialog>
<dialog id="localDnsModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<h3 id="local-dns-title" class="text-xl font-bold mb-4">Add Local DNS Record</h3>
<input id="local-name" placeholder="Name (domain)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<select id="local-type" onchange="updateLocalForm()" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option value="A">A</option>
<option value="AAAA">AAAA</option>
<option value="CNAME">CNAME</option>
<option value="MX">MX</option>
<option value="TXT">TXT</option>
<option value="SRV">SRV</option>
<option value="SOA">SOA</option>
<option value="CAA">CAA</option>
<option value="NS">NS</option>
<option value="PTR">PTR</option>
<option value="OTHER">OTHER</option>
</select>
<div id="local-value-fields" class="mb-4"></div>
<input id="local-ttl" type="number" placeholder="TTL" value="3600" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('localDnsModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button id="local-submit" onclick="submitLocalDns()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
</div>
</dialog>
<dialog id="certDetailsModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Certificate Details</h3>
<button onclick="document.getElementById('certDetailsModal').close()" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-3xl leading-none font-bold">&times;</button>
</div>
<div class="flex-1 overflow-y-auto mb-4">
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<pre id="cert-details-content" class="whitespace-pre-wrap break-all text-sm font-mono text-gray-800 dark:text-gray-200 leading-relaxed"></pre>
</div>
</div>
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button onclick="copyCertDetails()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors">Copy</button>
<button onclick="document.getElementById('certDetailsModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors">Close</button>
</div>
</dialog>
<!-- Confirmation modal is created dynamically by confirmation-modal.js -->
<dialog id="createHolesailModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Create Holesail Server</h3>
<input id="holesail-name" placeholder="Name (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="holesail-port" type="number" placeholder="Port (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="holesail-host" placeholder="Host (default 0.0.0.0)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="holesail-key" placeholder="Key (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="holesail-domain" placeholder="Assign to domain (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<label class="flex items-center mb-4"><input id="holesail-secure" type="checkbox" class="mr-2"> Secure</label>
<label class="flex items-center mb-4"><input id="holesail-udp" type="checkbox" checked class="mr-2"> UDP</label>
<select id="holesail-log" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option value="false">No Logs</option>
<option value="0">Debug</option>
<option value="1" selected>Info</option>
<option value="2">Warn</option>
<option value="3">Error</option>
</select>
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('createHolesailModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button id="create-holesail-btn" onclick="submitCreateHolesail()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create</button>
</div>
</dialog>
<dialog id="createClientModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-xl font-bold mb-4">Create Holesail Client</h3>
<select id="client-domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"></select>
<input id="client-key" placeholder="Key (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<input id="client-port" type="number" placeholder="Local Port (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('createClientModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button id="create-client-btn" onclick="submitCreateClient()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create</button>
</div>
</dialog>
<dialog id="holesailLogModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl">
<h3 id="holesail-log-title" class="text-xl font-bold mb-4">Holesail Logs</h3>
<div id="holesail-terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
<div class="flex justify-end mt-4">
<button onclick="document.getElementById('holesailLogModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
</div>
</dialog>
<dialog id="infoModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-start mb-4">
<h3 id="info-modal-title" class="text-2xl font-bold"></h3>
<button onclick="document.getElementById('infoModal').close()" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none">&times;</button>
</div>
<div id="info-modal-content" class="mb-6 space-y-4"></div>
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('infoModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
</div>
</dialog>
<dialog id="subnetModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<h3 id="subnet-modal-title" class="text-xl font-bold mb-4">Add Subnet</h3>
<div class="mb-4">
<label for="subnet-name" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Subnet Name</label>
<input id="subnet-name" placeholder="e.g., Primary Subnet" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this subnet</p>
</div>
<div class="mb-4">
<label for="subnet-base" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Base IP Address</label>
<input id="subnet-base" placeholder="e.g., 192.168.3.0" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The network address of the subnet (typically ends in .0)</p>
</div>
<div class="mb-4">
<label for="subnet-cidr" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">CIDR Notation</label>
<input id="subnet-cidr" type="number" min="1" max="32" placeholder="24" value="24" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Subnet mask in CIDR notation (1-32). /24 = 255.255.255.0 (254 usable IPs)</p>
</div>
<div class="mb-4">
<label for="subnet-startIndex" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Start IP Index</label>
<input id="subnet-startIndex" type="number" min="1" max="254" placeholder="2" value="2" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">First usable IP address index (1-254). IPs will be assigned starting from this number</p>
</div>
<div class="flex justify-end space-x-2">
<button onclick="document.getElementById('subnetModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
<button id="subnet-submit" onclick="submitSubnet()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
</div>
</dialog>
</div>
<div class="fixed top-4 right-4 flex items-center gap-2">
<div id="status-indicator" class="px-4 py-2 bg-blue-500 text-white rounded-lg shadow-md"></div>
<button id="refresh-button" onclick="handleRefresh()" class="px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-md transition-colors" title="Gracefully stop the process">
<i class="fas fa-redo"></i>
</button>
</div>
<div id="notifications" class="admin-toast-stack" aria-live="polite" aria-relevant="additions"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.js"></script>
<!-- Load modules in order: config -> state -> utils -> core -> notifications -> ws-client -> ui modules -> main -->
<script src="ui/config.js"></script>
<script src="ui/state.js"></script>
<script src="utils.js"></script>
<script src="ui/core.js"></script>
<script src="ui/notifications.js"></script>
<script src="ui/confirmation-modal.js"></script>
<script src="ws-client.js"></script>
<script src="ui/domains.js"></script>
<script src="ui/certs.js"></script>
<script src="ui/interfaces.js"></script>
<script src="ui/local-dns.js"></script>
<!-- Note: holesail, settings, logs, stats modules still need to be extracted from original -->
<script src="admin.js"></script>
<script>
function handleHashChange() {
var tabId = location.hash.substring(1);
if (tabId && document.getElementById(tabId) && typeof showTab === 'function') {
showTab(tabId);
}
}
window.addEventListener('hashchange', handleHashChange);
window.addEventListener('load', function() {
// Wait for admin.js to load
if (typeof showTab === 'function') {
var tabId = location.hash.substring(1);
if (tabId && document.getElementById(tabId)) {
showTab(tabId);
} else {
showTab('domains');
}
} else {
// Retry after a short delay if script hasn't loaded
setTimeout(function() {
if (typeof showTab === 'function') {
var tabId = location.hash.substring(1);
if (tabId && document.getElementById(tabId)) {
showTab(tabId);
} else {
showTab('domains');
}
}
}, 100);
}
});
</script>
</body>
</html>
-136
View File
@@ -1,136 +0,0 @@
const net = require('net');
const dgram = require('dgram');
const { cleanupInterfaces, freePort } = require('../maintenance/cleanup');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
async function waitForPortRelease(host, port, maxAttempts = 10, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (err) => {
server.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`TCP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, host);
});
await new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4');
socket.once('error', (err) => {
socket.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`UDP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
socket.once('listening', () => {
socket.close();
resolve(true);
});
socket.bind(port, host);
});
logDebug('Admin', `Port ${port} on ${host} is now free (attempt ${attempt})`);
return true;
} catch (err) {
logDebug('Admin', `Port ${port} on ${host} still in use (attempt ${attempt}): ${err.message}`);
if (attempt === maxAttempts) {
logWarn('Admin', `Port ${port} on ${host} still in use after ${maxAttempts} attempts`);
return false;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
async function checkPortAvailability(host, port) {
const tcpPromise = new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (err) => {
server.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`TCP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
server.once('listening', () => {
server.close(() => {
resolve(true);
});
});
server.listen(port, host);
});
const udpPromise = new Promise((resolve, reject) => {
const socket = dgram.createSocket('udp4');
socket.once('error', (err) => {
socket.close();
if (err.code === 'EADDRINUSE') {
reject(new Error(`UDP port ${port} on ${host} is already in use`));
} else {
reject(err);
}
});
socket.once('listening', () => {
socket.close();
resolve(true);
});
socket.bind(port, host);
});
try {
await Promise.all([tcpPromise, udpPromise]);
return true;
} catch (err) {
throw err;
}
}
async function ensurePortFree(host, port) {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await checkPortAvailability(host, port);
logInfo('Admin', `Port ${port} on ${host} is free`);
return true;
} catch (err) {
logWarn('Admin', `Port ${port} on ${host} in use (attempt ${attempt}/${maxAttempts}): ${err.message}. Attempting to free it.`);
const freed = await freePort(host, port);
if (!freed) {
logError('Admin', `Failed to free port ${port} on ${host} on attempt ${attempt}`);
if (attempt === maxAttempts) {
logError('Admin', `Port ${port} on ${host} could not be freed after ${maxAttempts} attempts`);
return false;
}
}
logInfo('Admin', `Freed port ${port} on ${host}. Waiting for release...`);
const released = await waitForPortRelease(host, port, 10, 1000);
if (!released) {
logWarn('Admin', `Port ${port} on ${host} still not released after waiting on attempt ${attempt}`);
if (attempt === maxAttempts) {
logError('Admin', `Port ${port} on ${host} could not be released after ${maxAttempts} attempts`);
return false;
}
} else {
logInfo('Admin', `Port ${port} on ${host} successfully released on attempt ${attempt}`);
return true;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
return false;
}
module.exports = {
waitForPortRelease,
checkPortAvailability,
ensurePortFree
};
-157
View File
@@ -1,157 +0,0 @@
const fs = require('fs').promises;
const pathModule = require('path');
const state = require('../../infrastructure/state');
const ca = require('../../security/certificate_authority');
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
const { logDebug, logError } = require('../../infrastructure/logger');
const { broadcast } = require('../websocket');
const certsDir = process.env.CERTS_DIR || './certs';
async function handleCertsRoutes(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/certs') {
try {
const certDomains = await fs.readdir(certsDir);
const filteredDomains = [];
for (const file of certDomains) {
if ((await fs.stat(pathModule.join(certsDir, file))).isDirectory()) {
filteredDomains.push(file);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(filteredDomains));
} catch (err) {
logError('Admin', `Failed to fetch certs: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch certs' }));
}
return true;
}
if (method === 'GET' && urlPath.startsWith('/api/cert-details')) {
const domain = url.searchParams.get('domain');
try {
const certPath = pathModule.join(certsDir, domain, 'cert.pem');
const certContent = await fs.readFile(certPath, 'utf8');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(certContent);
} catch (err) {
logError('Admin', `Failed to fetch cert details: ${err.message}`);
res.writeHead(500);
res.end('Failed to fetch cert details');
}
return true;
}
if (method === 'POST' && urlPath === '/api/regenerate-ca') {
try {
ca.regenerateRootCA();
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate CA: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
if (method === 'POST' && urlPath === '/api/install-ca') {
try {
ca.installRootCA();
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to install CA: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
if (method === 'POST' && urlPath === '/api/generate-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to generate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/delete-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const domainDir = pathModule.join(certsDir, data.domain);
await fs.rm(domainDir, { recursive: true, force: true });
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to delete cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/regenerate-cert') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const domainDir = pathModule.join(certsDir, data.domain);
await fs.rm(domainDir, { recursive: true, force: true });
if (!state.domainToIPMap.has(data.domain)) {
await createInterfaceForDomain(data.domain);
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
}
const ip = state.domainToIPMap.get(data.domain);
ca.getOrCreateDomainCert(data.domain, ip);
broadcast({ type: 'update-certs' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to regenerate cert: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleCertsRoutes };
-256
View File
@@ -1,256 +0,0 @@
const fs = require('fs').promises;
const state = require('../../infrastructure/state');
const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState, removeOwnClaimAndVotes, invalidateEntriesCache } = require('../../core/core');
const { addDomain } = require('../../core/domains');
const { validateDomainAddition, validateDomainRemoval } = require('../../infrastructure/validation');
const { atomicDomainCleanup } = require('../../core/domain_cleanup');
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
const { logDebug, logError } = require('../../infrastructure/logger');
const { trackRequest } = require('../../maintenance/metrics');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { broadcast } = require('../websocket');
const { getPersistentPublicKey } = require('../../infrastructure/utils');
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
async function handleDomainsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
const url = new URL(req.url, `https://${req.headers.host}`);
if (method === 'GET' && urlPath === '/api/resolved-domains') {
try {
// Use fresh data (bypass cache) to ensure latest consensus state
const allEntries = await getAllEntries(state.dnsPass, false);
const domainClaimants = new Map();
for (const entry of allEntries) {
if (entry.key.startsWith('claim:')) {
const parts = entry.key.split(':');
if (parts.length === 3) {
const domain = parts[1];
const claimant = parts[2];
if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set());
domainClaimants.get(domain).add(claimant);
}
}
}
const localWriter = getPersistentPublicKey();
const domains = new Set(domainClaimants.keys());
const resolved = [];
for (const domain of domains) {
const hash = await getHashForDomain(domain) || 'none';
const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false;
let isOwner = false;
let consensusState = null;
try {
consensusState = await getConsensusState(domain);
isOwner = localWriter ? consensusState.resolvedClaimant === localWriter : false;
} catch (err) {
logDebug('Admin', `Error checking ownership/consensus for ${domain}: ${err.message}`);
}
// Determine consensus status, including conflict detection
let consensusStatus = null;
if (consensusState) {
if (isLocal && !isOwner && consensusState.status === 'resolved') {
// Conflict: user has local claim but another claimant won
consensusStatus = 'conflict';
} else {
consensusStatus = consensusState.status;
}
}
resolved.push({ domain, hash, isLocal, isOwner, consensusState, consensusStatus });
}
let internalDomains = ['p2ns.admin'];
try {
const { getInternalDomains } = require('../../plugins/plugin-handler');
internalDomains = await getInternalDomains();
} catch (err) {
// Fallback if plugin system not available
}
// Only add internal domains that aren't already in the resolved list
for (const d of internalDomains) {
if (!resolved.some(r => r.domain === d)) {
resolved.push({ domain: d, hash: 'internal', isLocal: true, isOwner: true, consensusStatus: 'internal' });
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(resolved.sort((a, b) => a.domain.localeCompare(b.domain))));
} catch (err) {
logError('Admin', `Failed to fetch resolved domains: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch domains' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/add-domain') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const validation = validateDomainAddition(data);
if (!validation.valid) {
res.writeHead(400);
res.end(validation.error || 'Invalid input');
return;
}
// Extract SSL flag - handle both boolean true and string "true"
const ssl = data.ssl === true || data.ssl === 'true' || data.ssl === 1;
await addDomain(validation.domain, validation.hash, ssl);
// Invalidate cache to ensure fresh consensus check
invalidateEntriesCache();
// Recalculate consensus for the new domain
await doAutoVotes();
// Small delay to allow consensus to settle
await new Promise(resolve => setTimeout(resolve, 200));
// Trigger consensus recalculation request to notify peers
if (state.sendConsensusRequest) {
setTimeout(() => {
state.sendConsensusRequest(validation.domain);
}, 500);
}
let domains = [];
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
if (!Array.isArray(parsed)) {
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
domains = [];
} else {
domains = parsed;
}
}
const existingIndex = domains.findIndex(d => d.domain === validation.domain);
if (existingIndex !== -1) {
domains[existingIndex].hash = validation.hash;
domains[existingIndex].ssl = ssl; // Update SSL flag
} else {
domains.push({ domain: validation.domain, hash: validation.hash, ssl: ssl });
}
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
// Note: IP assignment now happens automatically after consensus resolution
// See assignAllIPs() function for automatic IP assignment logic
logDebug('Admin', `Domain ${validation.domain} added - IP will be assigned automatically after consensus resolution`);
// Broadcast multiple update types to ensure frontend refreshes
broadcast({ type: 'update-database' });
broadcast({ type: 'update-local-dns' }); // Also update local DNS tab to show conflicts
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
trackRequest('/api/add-domain', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/remove-domain') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const validation = validateDomainRemoval(data);
if (!validation.valid) {
res.writeHead(400);
res.end(validation.error || 'Invalid input');
return;
}
const domain = validation.domain;
// Authorization check: verify peer has claim
const localWriter = getPersistentPublicKey();
if (!localWriter) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Peer not initialized');
return;
}
// Check if peer has a claim for this domain
const allEntries = await getAllEntries();
const claimKey = `claim:${domain}:${localWriter}`;
const hasClaim = allEntries.some(entry => entry.key === claimKey);
if (!hasClaim) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('You do not have a claim for this domain');
return;
}
// Check if peer is the resolved claimant
const consensusState = await getConsensusState(domain);
// Only do full cleanup if:
// 1. Consensus is resolved
// 2. User is the resolved claimant
// 3. Resolved claimant is not null/undefined
const isResolvedClaimant = consensusState.status === 'resolved' &&
consensusState.resolvedClaimant &&
consensusState.resolvedClaimant === localWriter;
if (isResolvedClaimant) {
// Full removal: user is the resolved claimant
logInfo('Admin', `User is resolved claimant for ${domain}, performing full cleanup`);
await atomicDomainCleanup(domain);
// Broadcast full removal request (triggers other peers to clean up their claims)
if (state.sendRemovalRequest) {
state.sendRemovalRequest(domain);
}
} else {
// Partial removal: user has claim but isn't the resolved claimant (conflict scenario)
// This includes cases where:
// - Consensus is not resolved
// - User is not the resolved claimant (conflict scenario)
// - Resolved claimant is null/undefined
logInfo('Admin', `User is NOT resolved claimant for ${domain} (status=${consensusState.status}, resolvedClaimant=${consensusState.resolvedClaimant}), removing only own claim and votes`);
await removeOwnClaimAndVotes(domain, localWriter);
// Broadcast conflict claim removal notification (does NOT trigger other peers to remove)
if (state.sendConflictClaimRemoval) {
state.sendConflictClaimRemoval(domain);
}
}
// Cleanup hash preferences if domain was removed
try {
const { cleanupHashPreferences } = require('../cache');
await cleanupHashPreferences();
} catch (err) {
logWarn('Admin', `Failed to cleanup hash preferences after domain removal: ${err.message}`);
}
trackRequest('/api/remove-domain', true);
broadcast({ type: 'update-database' });
broadcast({ type: 'update-holesail-clients' });
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
trackRequest('/api/remove-domain', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
});
return true;
}
return false;
}
module.exports = { handleDomainsRoutes };
-57
View File
@@ -1,57 +0,0 @@
const { getAllEntries, removeAllRecords } = require('../../core/core');
const { logError, logInfo } = require('../../infrastructure/logger');
const { trackRequest } = require('../../maintenance/metrics');
// Get broadcast function if available
let broadcast;
try {
broadcast = require('../admin-backend/websocket').broadcast;
} catch (e) {
// Fallback if websocket module not available
broadcast = () => {};
}
async function handleEntriesRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/entries') {
try {
const entries = await getAllEntries();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(entries));
} catch (err) {
logError('Admin', `Failed to fetch entries: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch entries' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/remove-all-records') {
try {
const result = await removeAllRecords();
trackRequest('/api/remove-all-records', true);
broadcast({ type: 'update-database' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Removed ${result.removed} records from the network`,
removed: result.removed,
errors: result.errors
}));
logInfo('Admin', `Removed all records: ${result.removed} removed, ${result.errors} errors`);
} catch (err) {
trackRequest('/api/remove-all-records', false);
logError('Admin', `Failed to remove all records: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to remove all records', message: err.message }));
}
return true;
}
return false;
}
module.exports = { handleEntriesRoutes };
-677
View File
@@ -1,677 +0,0 @@
const fs = require('fs').promises;
const dgram = require('dgram');
const crypto = require('crypto');
const state = require('../../infrastructure/state');
const { addDomain } = require('../../core/domains');
const { validateHolesailClient } = require('../../infrastructure/validation');
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
const { startForkedHolesailClient, saveHolesailClients, getHolesailClientStatus } = require('../holesail-clients');
const { ensurePortFree } = require('../port-management');
const { broadcast } = require('../websocket');
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
async function handleHolesailRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/holesail-servers') {
try {
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => {
const child = state.holesailChildren.get(id);
const info = state.holesailInfos.get(id) || {};
const status = child && !child.killed ? 'running' : 'stopped';
return { id, opts, info: { ...info, state: status } };
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(servers));
} catch (err) {
logError('Admin', `Failed to fetch Holesail servers: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch Holesail servers' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/holesail-clients') {
try {
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
const info = state.holesailClientInfos.get(id) || {};
const status = getHolesailClientStatus(id, opts, info);
return { id, opts, info: { ...info, state: status } };
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(clients));
} catch (err) {
logError('Admin', `Failed to fetch Holesail clients: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch Holesail clients' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-create') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const opts = { ...data };
const domain = opts.domain;
delete opts.domain;
const id = crypto.randomBytes(16).toString('hex');
logDebug('Admin', `Creating Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
const { id: createdId, info } = await startHolesailServer(id, opts);
if (domain) {
const hash = info.url;
await addDomain(domain, hash);
if (!state.domainToIPMap.has(domain)) {
await createInterfaceForDomain(domain);
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
}
let domains = [];
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
if (!Array.isArray(parsed)) {
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
domains = [];
} else {
domains = parsed;
}
}
const existingIndex = domains.findIndex(d => d.domain === domain);
if (existingIndex !== -1) {
domains[existingIndex].hash = hash;
} else {
domains.push({ domain, hash });
}
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
logInfo('Admin', `Automatically added domain ${domain} with hash ${hash} to P2P network and domains.json`);
}
await saveHolesailServers();
broadcast({ type: 'update-holesail' });
broadcast({ type: 'update-database' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: createdId }));
} catch (err) {
logError('Admin', `Failed to create Holesail server: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-delete') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { id } = JSON.parse(body);
const child = state.holesailChildren.get(id);
const opts = state.holesailOpts.get(id);
if (child) {
child.kill('SIGTERM');
await new Promise(resolve => {
child.on('exit', () => resolve());
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
resolve();
}, 3000);
});
state.holesailChildren.delete(id);
state.holesailChildStartTimes.delete(id);
logInfo('Admin', `Closed Holesail server child process ${id}`);
}
state.holesailOpts.delete(id);
state.holesailInfos.delete(id);
await saveHolesailServers();
broadcast({ type: 'update-holesail' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to delete Holesail server: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-restart') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { id } = JSON.parse(body);
const child = state.holesailChildren.get(id);
const opts = state.holesailOpts.get(id);
if (!opts) {
throw new Error('Server not found');
}
let exitPromise;
if (child) {
logDebug('Admin', `Terminating existing Holesail server child process ${id}`);
exitPromise = new Promise((resolve) => {
child.once('exit', resolve);
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
resolve();
}, 3000);
});
child.kill('SIGTERM');
await exitPromise;
logInfo('Admin', `Closed Holesail server child process ${id}`);
}
state.holesailInfos.delete(id);
broadcast({ type: 'update-holesail' });
logDebug('Admin', `Restarting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
const { id: createdId, info } = await startHolesailServer(id, opts);
if (opts.domain) {
const hash = info.url.replace('hs://', '');
await addDomain(opts.domain, hash);
if (!state.domainToIPMap.has(opts.domain)) {
await createInterfaceForDomain(opts.domain);
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
}
let domains = [];
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
if (!Array.isArray(parsed)) {
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
domains = [];
} else {
domains = parsed;
}
}
const existingIndex = domains.findIndex(d => d.domain === opts.domain);
if (existingIndex !== -1) {
domains[existingIndex].hash = hash;
} else {
domains.push({ domain: opts.domain, hash });
}
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
logInfo('Admin', `Automatically added domain ${opts.domain} with hash ${hash} to P2P network and domains.json`);
}
await saveHolesailServers();
broadcast({ type: 'update-holesail' });
broadcast({ type: 'update-database' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to restart Holesail server: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-client-create') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const data = JSON.parse(body);
const validation = validateHolesailClient(data);
if (!validation.valid) {
res.writeHead(400);
res.end(validation.error || 'Invalid input');
return;
}
const { domain, key, port, protocol } = validation;
if (!state.domainToIPMap.has(domain)) {
await createInterfaceForDomain(domain);
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
}
const ip = state.domainToIPMap.get(domain);
const portFree = await ensurePortFree(ip, port);
if (!portFree) {
throw new Error(`Unable to ensure port ${port} free on ${ip}`);
}
const id = crypto.randomBytes(16).toString('hex');
state.holesailClientInfos.set(id, { state: 'starting' });
broadcast({ type: 'update-holesail-clients' });
await startForkedHolesailClient(id, { domain, key, port, protocol: protocol || 'tcp' });
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
await saveHolesailClients();
broadcast({ type: 'update-holesail-clients' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id }));
} catch (err) {
logError('Admin', `Failed to create Holesail client: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-client-delete') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { id } = JSON.parse(body);
const child = state.holesailClientChildren.get(id);
const opts = state.holesailClientOpts.get(id);
if (child) {
child.kill('SIGTERM');
await new Promise(resolve => {
child.on('exit', () => resolve());
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
resolve();
}, 3000);
});
state.holesailClientChildren.delete(id);
state.holesailChildStartTimes.delete(id);
logInfo('Admin', `Closed Holesail client ${id} for ${opts.domain}:${opts.port}`);
}
if (opts) {
const key = `${opts.domain}:${opts.port}`;
const holesail = state.holesails.get(key);
if (holesail) {
if (holesail instanceof dgram.Socket) {
await new Promise(resolve => {
holesail.close(() => {
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
resolve();
});
setTimeout(() => {
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
holesail.close();
resolve();
}, 5000);
});
} else {
await holesail.close();
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
}
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
}
const tlsServer = state.tlsServers.get(key);
if (tlsServer) {
await new Promise(resolve => {
tlsServer.close(resolve);
setTimeout(() => {
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
tlsServer.destroy ? tlsServer.destroy() : tlsServer.close();
resolve();
}, 5000);
});
state.tlsServers.delete(key);
logInfo('Admin', `Closed TLS server for ${key}`);
}
const httpServer = state.httpServers.get(key);
if (httpServer) {
await new Promise(resolve => {
httpServer.close(resolve);
setTimeout(() => {
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
httpServer.destroy ? httpServer.destroy() : httpServer.close();
resolve();
}, 5000);
});
state.httpServers.delete(key);
logInfo('Admin', `Closed HTTP server for ${key}`);
}
const ip = state.domainToIPMap.get(opts.domain);
if (ip && opts.port) {
const freed = await ensurePortFree(ip, opts.port);
if (!freed) {
logError('Admin', `Failed to ensure port ${opts.port} free on ${ip} for ${key}`);
} else {
logInfo('Admin', `Successfully ensured port ${opts.port} free on ${ip} for ${key}`);
}
}
}
state.holesailClientOpts.delete(id);
state.holesailClientInfos.delete(id);
await saveHolesailClients();
broadcast({ type: 'update-holesail-clients' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to delete Holesail client: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/holesail-client-restart') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { id } = JSON.parse(body);
logDebug('Admin', `Initiating restart for Holesail client ${id}`);
const child = state.holesailClientChildren.get(id);
const opts = state.holesailClientOpts.get(id);
if (!opts) {
throw new Error(`Client ${id} not found`);
}
const key = `${opts.domain}:${opts.port}`;
let exitPromise;
if (child) {
logDebug('Admin', `Terminating existing child process for client ${id}`);
exitPromise = new Promise((resolve) => {
child.once('exit', resolve);
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
resolve();
}, 3000);
});
child.kill('SIGTERM');
await exitPromise;
state.holesailClientChildren.delete(id);
state.holesailChildStartTimes.delete(id);
logInfo('Admin', `Closed Holesail client child process ${id}`);
}
const holesail = state.holesails.get(key);
if (holesail) {
logDebug('Admin', `Closing Holesail connection for ${key}`);
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 (err) {
reject(err);
}
}, 5000);
});
} else {
await holesail.close();
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
}
state.holesails.delete(key);
if (state.holesailStartTimes) {
state.holesailStartTimes.delete(key);
}
}
const tlsServer = state.tlsServers.get(key);
if (tlsServer) {
logDebug('Admin', `Closing TLS server for ${key}`);
await new Promise(resolve => {
tlsServer.close(resolve);
setTimeout(() => {
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
tlsServer.destroy ? tlsServer.destroy() : tlsServer.close();
resolve();
}, 5000);
});
state.tlsServers.delete(key);
logInfo('Admin', `Closed TLS server for ${key}`);
}
const httpServer = state.httpServers.get(key);
if (httpServer) {
logDebug('Admin', `Closing HTTP server for ${key}`);
await new Promise(resolve => {
httpServer.close(resolve);
setTimeout(() => {
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
httpServer.destroy ? httpServer.destroy() : httpServer.close();
resolve();
}, 5000);
});
state.httpServers.delete(key);
logInfo('Admin', `Closed HTTP server for ${key}`);
}
state.holesailClientInfos.set(id, { state: 'starting' });
broadcast({ type: 'update-holesail-clients' });
logInfo('Admin', `Holesail client ${id} stopped, preparing to restart`);
if (!state.domainToIPMap.has(opts.domain)) {
await createInterfaceForDomain(opts.domain);
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
}
const ip = state.domainToIPMap.get(opts.domain);
const portFree = await ensurePortFree(ip, opts.port);
if (!portFree) {
state.holesailClientInfos.set(id, { state: 'error', error: `Unable to free port ${opts.port} on ${ip}` });
broadcast({ type: 'update-holesail-clients' });
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
}
await startForkedHolesailClient(id, opts);
logInfo('Admin', `Successfully restarted Holesail client ${id} for ${opts.domain}:${opts.port}`);
await new Promise(resolve => setTimeout(resolve, 1000));
const isHolesailActive = state.holesails.has(key);
if (!isHolesailActive) {
logWarn('Admin', `Holesail client ${id} for ${key} started but not active in state.holesails. Attempting final restart.`);
state.holesailClientInfos.set(id, { state: 'starting' });
broadcast({ type: 'update-holesail-clients' });
await startForkedHolesailClient(id, opts);
}
const finalCheck = state.holesails.has(key);
if (!finalCheck) {
state.holesailClientInfos.set(id, { state: 'error', error: `Failed to start after final attempt` });
broadcast({ type: 'update-holesail-clients' });
throw new Error(`Holesail client ${id} for ${key} failed to start after final attempt`);
}
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
logDebug('Admin', `Verified Holesail client ${id} is active for ${key}`);
await saveHolesailClients();
broadcast({ type: 'update-holesail-clients' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
state.holesailClientInfos.set(id, { state: 'error', error: err.message });
broadcast({ type: 'update-holesail-clients' });
logError('Admin', `Failed to restart Holesail client: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
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;
}
module.exports = { handleHolesailRoutes };
-54
View File
@@ -1,54 +0,0 @@
const { checkRateLimit } = require('../../infrastructure/rate_limit');
const { trackRequest } = require('../../maintenance/metrics');
const { handleStaticRoutes } = require('./static');
const { handleDomainsRoutes } = require('./domains');
const { handleEntriesRoutes } = require('./entries');
const { handlePeersRoutes } = require('./peers');
const { handleCertsRoutes } = require('./certs');
const { handleInterfacesRoutes } = require('./interfaces');
const { handleLocalDnsRoutes } = require('./local-dns');
const { handleStatusRoutes } = require('./status');
const { handleStatsRoutes } = require('./stats');
const { handleHolesailRoutes } = require('./holesail');
const { handleSettingsRoutes } = require('./settings');
async function handleAdminRequest(req, res) {
const url = new URL(req.url, `https://${req.headers.host}`);
const urlPath = url.pathname;
const method = req.method;
// Check rate limit for API endpoints (GET requests and local IPs are exempt)
// Only rate limit POST requests, GET requests are safe and expected to be frequent
if (urlPath.startsWith('/api/') && method === 'POST') {
const rateLimitError = checkRateLimit(req);
if (rateLimitError) {
res.writeHead(rateLimitError.statusCode, rateLimitError.headers);
res.end(rateLimitError.body);
trackRequest(urlPath, false);
return;
}
}
// Attach urlPath to req for route handlers
req.urlPath = urlPath;
// Try each route handler in order
if (await handleStaticRoutes(req, res)) return;
if (await handleDomainsRoutes(req, res)) return;
if (await handleEntriesRoutes(req, res)) return;
if (await handlePeersRoutes(req, res)) return;
if (await handleCertsRoutes(req, res)) return;
if (await handleInterfacesRoutes(req, res)) return;
if (await handleLocalDnsRoutes(req, res)) return;
if (await handleStatusRoutes(req, res)) return;
if (await handleStatsRoutes(req, res)) return;
if (await handleHolesailRoutes(req, res)) return;
if (await handleSettingsRoutes(req, res)) return;
// No route matched
res.writeHead(404);
res.end('Not Found');
}
module.exports = { handleAdminRequest };
-70
View File
@@ -1,70 +0,0 @@
const { cleanupInterfaces } = require('../../maintenance/cleanup');
const { logError } = require('../../infrastructure/logger');
const { scheduleInterfacesBroadcast } = require('../admin-backend/interfaces-broadcast');
const { buildInterfacesResponse, removeOrphanedInterfaceIp } = require('../interfaces-data');
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (err) {
reject(new Error('Invalid JSON body'));
}
});
req.on('error', reject);
});
}
async function handleInterfacesRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/interfaces') {
try {
const payload = await buildInterfacesResponse();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
} catch (err) {
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch interfaces' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/interfaces/remove-ip') {
try {
const { ip } = await readJsonBody(req);
const removedIp = await removeOrphanedInterfaceIp(ip);
scheduleInterfacesBroadcast();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, ip: removedIp }));
} catch (err) {
logError('Admin', `Failed to remove orphaned IP: ${err.message}`);
res.writeHead(400);
res.end(JSON.stringify({ error: err.message }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
try {
await cleanupInterfaces();
scheduleInterfacesBroadcast();
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to cleanup interfaces: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
return true;
}
return false;
}
module.exports = { handleInterfacesRoutes };
-286
View File
@@ -1,286 +0,0 @@
const fs = require('fs').promises;
const dns = require('dns').promises;
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';
async function handleLocalDnsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/local-dns') {
try {
let records = state.localDnsRecords || [];
let conflicts = [];
const domains = new Set([...state.domainsWithBoth, ...state.versionPreferences.keys()]);
for (const domain of domains) {
let publicIP = state.publicIpForDomain[domain];
if (!publicIP && state.versionPreferences.has(domain)) {
try {
const ips = await dns.resolve4(domain);
publicIP = ips[0] || 'N/A';
state.publicIpForDomain[domain] = publicIP;
} catch (err) {
logWarn('Admin', `Failed to resolve public IP for ${domain}: ${err.message}`);
publicIP = 'N/A';
}
}
conflicts.push({
domain,
version: state.versionPreferences.get(domain) || 'p2p',
publicIP: publicIP || 'N/A'
});
}
records = records.map((rec, index) => ({ ...rec, index }));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ records, conflicts }));
} catch (err) {
logError('Admin', `Failed to fetch local DNS and conflicts: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch local DNS and conflicts' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/selector-cache') {
try {
const preferences = Object.fromEntries(state.versionPreferences);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(preferences));
} catch (err) {
logError('Admin', `Failed to fetch selector cache: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch selector cache' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/add-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const record = JSON.parse(body);
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
throw new Error('Missing or invalid required fields: name, type, ttl');
}
record.class = record.class || 'IN';
let records = state.localDnsRecords || [];
records.push(record);
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to add local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { index, record } = JSON.parse(body);
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
throw new Error('Missing or invalid required fields: name, type, ttl');
}
let records = state.localDnsRecords || [];
if (index >= 0 && index < records.length) {
record.class = record.class || 'IN';
records[index] = record;
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} else {
res.writeHead(400);
res.end('Invalid index');
}
} catch (err) {
logError('Admin', `Failed to update local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/delete-local-dns') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { index } = JSON.parse(body);
let records = state.localDnsRecords || [];
if (index >= 0 && index < records.length) {
records.splice(index, 1);
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
state.localDnsRecords = records;
broadcast({ type: 'update-local-dns' });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} else {
res.writeHead(400);
res.end('Invalid index');
}
} catch (err) {
logError('Admin', `Failed to delete local DNS record: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-version-preference') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { domain, version } = JSON.parse(body);
if (version !== 'p2p' && version !== 'public') {
res.writeHead(400);
res.end('Invalid version, must be "p2p" or "public"');
return;
}
state.versionPreferences.set(domain, version);
await saveSelectorCache();
broadcast({ type: 'update-local-dns' });
logInfo('Admin', `Updated version preference for ${domain} to ${version}`);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
logError('Admin', `Failed to update version preference: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
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;
}
module.exports = { handleLocalDnsRoutes };
-25
View File
@@ -1,25 +0,0 @@
const state = require('../../infrastructure/state');
const { logError } = require('../../infrastructure/logger');
async function handlePeersRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/peers') {
try {
const peers = Array.from(state.connectedPeers);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(peers));
} catch (err) {
logError('Admin', `Failed to fetch peers: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch peers' }));
}
return true;
}
return false;
}
module.exports = { handlePeersRoutes };
-298
View File
@@ -1,298 +0,0 @@
const fs = require('fs').promises;
const state = require('../../infrastructure/state');
const { logError, logWarn } = require('../../infrastructure/logger');
const { getAvailableIPsForSubnet } = require('../../networking/virtual_interfaces');
const { settingsMetadata, restartRequiredSettings, liveReloadableSettings, envWhitelist, applyLiveSettings } = require('../settings');
const { broadcast } = require('../websocket');
async function handleSettingsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/settings') {
try {
const settings = {};
const metadata = {};
envWhitelist.forEach(key => {
const value = process.env[key] || '';
settings[key] = value;
if (settingsMetadata[key]) {
metadata[key] = {
...settingsMetadata[key],
currentValue: value
};
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ settings, metadata }));
} catch (err) {
logError('Admin', `Failed to fetch settings: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch settings' }));
}
return true;
}
if (method === 'GET' && urlPath === '/api/subnets') {
try {
let subnets = [];
if (process.env.SUBNETS) {
try {
subnets = JSON.parse(process.env.SUBNETS);
if (!Array.isArray(subnets)) {
subnets = [];
}
} catch (err) {
logWarn('Admin', `Failed to parse SUBNETS: ${err.message}`);
subnets = [];
}
}
if (subnets.length === 0) {
const subnetBase = process.env.SUBNET_BASE || '192.168.3';
const baseParts = subnetBase.split('.');
if (baseParts.length === 3) {
subnets = [{
base: `${subnetBase}.0`,
cidr: 24,
startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10),
name: 'Default Subnet'
}];
}
}
const subnetInfo = subnets.map((subnet, index) => {
const available = getAvailableIPsForSubnet(subnet);
const used = Array.from(state.domainToIPMap.values()).filter(ip => {
const ipParts = ip.split('.');
const subnetParts = subnet.base.split('.');
return ipParts[0] === subnetParts[0] &&
ipParts[1] === subnetParts[1] &&
ipParts[2] === subnetParts[2];
}).length;
return {
...subnet,
index,
available,
used,
remaining: Math.max(0, available - used)
};
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ subnets: subnetInfo }));
} catch (err) {
logError('Admin', `Failed to fetch subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: 'Failed to fetch subnets' }));
}
return true;
}
if (method === 'POST' && urlPath === '/api/subnets') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { subnets } = JSON.parse(body);
if (!Array.isArray(subnets)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'subnets must be an array' }));
return;
}
const errors = [];
subnets.forEach((subnet, index) => {
if (!subnet || typeof subnet !== 'object') {
errors.push(`subnets[${index}]: must be an object`);
return;
}
if (!subnet.base || typeof subnet.base !== 'string') {
errors.push(`subnets[${index}]: base is required and must be a string`);
} else if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(subnet.base)) {
errors.push(`subnets[${index}]: base must be a valid IPv4 address`);
}
const cidr = parseInt(subnet.cidr, 10);
if (isNaN(cidr) || cidr < 1 || cidr > 32) {
errors.push(`subnets[${index}]: cidr must be between 1 and 32`);
}
const startIndex = parseInt(subnet.startIndex || process.env.INITIAL_IP_INDEX || '2', 10);
const maxIPs = Math.pow(2, 32 - cidr) - 2;
if (isNaN(startIndex) || startIndex < 1 || startIndex > Math.min(254, maxIPs)) {
errors.push(`subnets[${index}]: startIndex must be between 1 and ${Math.min(254, maxIPs)}`);
}
if (!subnet.name || typeof subnet.name !== 'string') {
subnet.name = `Subnet ${index + 1}`;
}
});
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
process.env.SUBNETS = JSON.stringify(subnets);
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${JSON.stringify(subnets)}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
state.subnets = subnets;
state.currentSubnetIndex = 0;
state.subnetIPCounters.clear();
subnets.forEach((subnet, index) => {
state.subnetIPCounters.set(index, subnet.startIndex || parseInt(process.env.INITIAL_IP_INDEX || '2', 10));
});
broadcast({ type: 'update-settings' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Subnets updated. Restart required to fully apply changes.',
restartRequired: true
}));
} catch (err) {
logError('Admin', `Failed to update subnets: ${err.message}`);
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
}
});
return true;
}
if (method === 'POST' && urlPath === '/api/update-settings') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { settings } = JSON.parse(body);
const errors = [];
for (const [key, value] of Object.entries(settings)) {
if (!envWhitelist.includes(key)) {
errors.push(`Setting ${key} is not whitelisted`);
continue;
}
const meta = settingsMetadata[key];
if (meta) {
if (meta.type === 'number') {
// Use parseFloat to support both integers and floating point numbers
// parseFloat works for integers too (e.g., parseFloat("5") returns 5)
const numValue = parseFloat(value);
if (isNaN(numValue)) {
errors.push(`${meta.label}: must be a number`);
continue;
}
if (meta.min !== undefined && numValue < meta.min) {
errors.push(`${meta.label}: must be at least ${meta.min}`);
continue;
}
if (meta.max !== undefined && numValue > meta.max) {
errors.push(`${meta.label}: must be at most ${meta.max}`);
continue;
}
process.env[key] = numValue.toString();
} else if (meta.type === 'checkbox') {
process.env[key] = (value === true || value === 'true' || value === '1') ? 'true' : 'false';
} else if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
} else {
if (key === 'SUBNETS') {
try {
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(subnets)) {
errors.push('SUBNETS must be an array');
continue;
}
process.env[key] = JSON.stringify(subnets);
} catch (err) {
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
continue;
}
} else {
process.env[key] = value;
}
}
}
if (errors.length > 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Validation failed', errors }));
return;
}
const envContent = envWhitelist.map(key => {
if (key === 'SUBNETS') {
return `${key}=${process.env[key] || '[]'}`;
}
return `${key}=${process.env[key] || ''}`;
}).join('\n');
await fs.writeFile('.env', envContent);
const restartRequired = Object.keys(settings).some(key => restartRequiredSettings.includes(key));
const liveSettings = {};
for (const [key, value] of Object.entries(settings)) {
if (liveReloadableSettings.includes(key)) {
liveSettings[key] = value;
}
}
if (Object.keys(liveSettings).length > 0) {
await applyLiveSettings(liveSettings);
}
broadcast({ type: 'update-settings' });
if (restartRequired) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved. Some settings require restart to take effect.',
restartRequired: true,
restartRequiredSettings: Object.keys(settings).filter(k => restartRequiredSettings.includes(k))
}));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Settings saved and applied successfully.',
restartRequired: false
}));
}
} catch (err) {
logError('Admin', `Failed to update settings: ${err.message}`);
res.writeHead(500);
res.end(err.message);
}
});
return true;
}
return false;
}
module.exports = { handleSettingsRoutes };
-79
View File
@@ -1,79 +0,0 @@
const fs = require('fs').promises;
const pathModule = require('path');
const { logDebug, logError } = require('../../infrastructure/logger');
async function handleStaticRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
const tabs = ['domains', 'host', 'local-dns', 'entries', 'peers', 'certs', 'interfaces', 'logs', 'settings', 'stats'];
if (method === 'GET' && urlPath.startsWith('/') && tabs.includes(urlPath.substring(1))) {
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
res.end();
return true;
}
if (method === 'GET' && urlPath === '/') {
logDebug('Admin', 'Serving admin panel HTML');
try {
const html = await fs.readFile(pathModule.join(__dirname, '..', 'index.html'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} catch (err) {
logError('Admin', `Failed to serve index.html: ${err.message}`);
res.writeHead(500);
res.end('Failed to load admin panel');
}
return true;
}
if (method === 'GET' && urlPath === '/tailwind.css') {
try {
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', 'css', 'tailwind.css'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(css);
} catch (err) {
logError('Admin', `Failed to serve tailwind.css: ${err.message}`);
res.writeHead(500);
res.end('Failed to load Tailwind CSS');
}
return true;
}
if (method === 'GET' && urlPath === '/styles.css') {
try {
const css = await fs.readFile(pathModule.join(__dirname, '..', 'styles.css'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(css);
} catch (err) {
logError('Admin', `Failed to serve styles.css: ${err.message}`);
res.writeHead(500);
res.end('Failed to load styles');
}
return true;
}
if (method === 'GET' && urlPath === '/admin.js') {
try {
const js = await fs.readFile(pathModule.join(__dirname, '..', 'admin.js'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/javascript' });
res.end(js);
} catch (err) {
logError('Admin', `Failed to serve admin.js: ${err.message}`);
res.writeHead(500);
res.end('Failed to load script');
}
return true;
}
if (urlPath === '/favicon.ico') {
res.writeHead(404);
res.end('Not Found');
return true;
}
return false;
}
module.exports = { handleStaticRoutes };
-309
View File
@@ -1,309 +0,0 @@
const dgram = require('dgram');
const state = require('../../infrastructure/state');
const { getMetrics, getHistoricalData, trackRequestWithTiming, trackRequest } = require('../../maintenance/metrics');
const { getHashForDomain } = require('../../core/core');
const { logDebug, logError } = require('../../infrastructure/logger');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { parseMinutesToMs } = require('../../infrastructure/utils');
const pidusage = require('pidusage');
async function handleStatsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/stats') {
try {
const startTime = Date.now();
const stats = getMetrics();
const holesailChildren = [];
const pidStatsPromises = [];
const pidToChildMap = new Map();
for (const [id, child] of state.holesailChildren.entries()) {
try {
const opts = state.holesailOpts.get(id) || {};
const info = state.holesailInfos.get(id) || {};
const startTime = state.holesailChildStartTimes.get(id);
const uptime = startTime ? Date.now() - startTime : 0;
const status = child && !child.killed ? 'running' : 'stopped';
const pid = child ? child.pid : null;
if (pid && child && !child.killed) {
pidStatsPromises.push(
pidusage(pid).then(stats => ({ id, type: 'server', stats })).catch(err => {
logDebug('Admin', `Failed to get stats for server child ${id} (PID ${pid}): ${err.message}`);
return { id, type: 'server', stats: null };
})
);
}
pidToChildMap.set(id, {
id,
type: 'server',
status,
pid,
uptime,
cpuUsage: null,
memoryUsage: null,
opts: {
port: opts.port,
host: opts.host || '0.0.0.0',
protocol: opts.udp ? 'udp' : 'tcp',
secure: opts.secure || false,
domain: opts.domain || null
},
info: info
});
} catch (err) {
logError('Admin', `Error collecting stats for server child ${id}: ${err.message}`);
}
}
for (const [id, child] of state.holesailClientChildren.entries()) {
try {
const opts = state.holesailClientOpts.get(id) || {};
const info = state.holesailClientInfos.get(id) || {};
const startTime = state.holesailChildStartTimes.get(id);
const uptime = startTime ? Date.now() - startTime : 0;
const status = child && !child.killed ? 'running' : 'stopped';
const pid = child ? child.pid : null;
if (pid && child && !child.killed) {
pidStatsPromises.push(
pidusage(pid).then(stats => ({ id, type: 'client', stats })).catch(err => {
logDebug('Admin', `Failed to get stats for client child ${id} (PID ${pid}): ${err.message}`);
return { id, type: 'client', stats: null };
})
);
}
pidToChildMap.set(id, {
id,
type: 'client',
status,
pid,
uptime,
cpuUsage: null,
memoryUsage: null,
opts: {
domain: opts.domain || null,
port: opts.port,
host: opts.host || null,
protocol: opts.protocol || 'tcp'
},
info: info
});
} catch (err) {
logError('Admin', `Error collecting stats for client child ${id}: ${err.message}`);
}
}
const pidStatsResults = await Promise.all(pidStatsPromises);
for (const result of pidStatsResults) {
if (result.stats) {
const childData = pidToChildMap.get(result.id);
if (childData) {
childData.cpuUsage = {
user: result.stats.cpu / 2,
system: result.stats.cpu / 2,
percentage: result.stats.cpu
};
childData.memoryUsage = {
rss: result.stats.memory,
heapUsed: result.stats.memory * 0.8,
heapTotal: result.stats.memory,
external: 0,
arrayBuffers: 0
};
}
}
}
for (const childData of pidToChildMap.values()) {
holesailChildren.push(childData);
}
const managedConnections = new Set();
for (const child of holesailChildren) {
if (child.opts && child.opts.domain && child.opts.port) {
managedConnections.add(`${child.opts.domain}:${child.opts.port}`);
}
}
const p2pDomainEntries = [];
const hashPromises = [];
for (const [key, holesail] of state.holesails.entries()) {
try {
if (managedConnections.has(key)) {
continue;
}
const keyParts = key.split(':');
if (keyParts.length !== 2) {
logDebug('Admin', `Skipping invalid holesail key format: ${key}`);
continue;
}
const domain = keyParts[0];
const port = parseInt(keyParts[1], 10);
if (isNaN(port)) {
logDebug('Admin', `Skipping holesail key with invalid port: ${key}`);
continue;
}
const ip = state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain];
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
const status = holesail && typeof holesail === 'object' ? 'running' : 'stopped';
let holesailInfo = null;
try {
if (holesail && typeof holesail === 'object' && holesail.info) {
holesailInfo = holesail.info;
}
} catch (e) {
// Info not available
}
p2pDomainEntries.push({
key,
domain,
port,
ip,
isPersistent,
status,
holesailInfo
});
hashPromises.push(
getHashForDomain(domain).catch(err => {
logDebug('Admin', `Could not get hash for domain ${domain}: ${err.message}`);
return null;
})
);
} catch (err) {
logError('Admin', `Error processing p2p domain connection ${key}: ${err.message}`);
}
}
const hashResults = await Promise.all(hashPromises);
let mainProcessStats = null;
try {
mainProcessStats = await pidusage(process.pid);
} catch (err) {
logDebug('Admin', `Failed to get main process stats for p2p connections: ${err.message}`);
}
const p2pConnectionCount = p2pDomainEntries.length || 1;
let perConnectionCpu = null;
let perConnectionMemory = null;
if (mainProcessStats) {
perConnectionCpu = {
user: mainProcessStats.cpu / (p2pConnectionCount * 2),
system: mainProcessStats.cpu / (p2pConnectionCount * 2),
percentage: mainProcessStats.cpu / p2pConnectionCount
};
perConnectionMemory = {
rss: Math.floor(mainProcessStats.memory / p2pConnectionCount),
heapUsed: Math.floor((mainProcessStats.memory * 0.8) / p2pConnectionCount),
heapTotal: Math.floor(mainProcessStats.memory / p2pConnectionCount),
external: 0,
arrayBuffers: 0
};
}
for (let i = 0; i < p2pDomainEntries.length; i++) {
try {
const entry = p2pDomainEntries[i];
const hash = hashResults[i];
const startTime = state.holesailStartTimes && state.holesailStartTimes.get(entry.key);
const uptime = startTime ? Date.now() - startTime : 0;
let timeRemaining = null;
if (!entry.isPersistent && process.env.FULL_PERSISTENCE !== 'true' && startTime) {
const timeoutDuration = parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5');
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, timeoutDuration - elapsed);
timeRemaining = remaining;
}
holesailChildren.push({
id: entry.key,
type: 'p2p-domain',
status: entry.status,
pid: process.pid,
uptime: uptime,
timeRemaining: timeRemaining,
cpuUsage: perConnectionCpu,
memoryUsage: perConnectionMemory,
opts: {
domain: entry.domain,
port: entry.port,
host: entry.ip || null,
protocol: 'tcp'
},
persistent: entry.isPersistent,
hash: hash || null,
info: entry.holesailInfo || null
});
} catch (err) {
logError('Admin', `Error creating stats entry for p2p domain connection ${p2pDomainEntries[i].key}: ${err.message}`);
}
}
stats.holesailChildren = holesailChildren;
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/stats', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(stats));
} catch (err) {
logError('Admin', `Error in /api/stats: ${err.message}`, err);
trackRequest('/api/stats', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
if (method === 'GET' && urlPath === '/api/stats/historical') {
try {
let minutes = 5;
if (req.url.includes('?')) {
const queryString = req.url.split('?')[1];
const params = new URLSearchParams(queryString);
const minutesParam = params.get('minutes');
if (minutesParam) {
minutes = parseInt(minutesParam, 10);
if (isNaN(minutes) || minutes < 1) minutes = 5;
if (minutes > 2880) minutes = 2880;
}
}
const startTime = Date.now();
const historical = getHistoricalData(minutes);
const responseTime = Date.now() - startTime;
trackRequestWithTiming('/api/stats/historical', true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(historical));
} catch (err) {
logError('Admin', `Error in /api/stats/historical: ${err.message}`, err);
trackRequest('/api/stats/historical', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handleStatsRoutes };
-148
View File
@@ -1,148 +0,0 @@
const url = require('url');
const state = require('../../infrastructure/state');
const { trackRequest } = require('../../maintenance/metrics');
const { createErrorResponse } = require('../../infrastructure/error_handler');
const { metrics } = require('../../maintenance/metrics');
async function handleStatusRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
if (method === 'GET' && urlPath === '/api/status') {
try {
const status = {
isMaster: state.isMaster,
isConnected: !!state.dnsPass,
peersCount: state.connectedPeers.size,
isShuttingDown: state.isShuttingDown || false
};
trackRequest('/api/status', true);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
} catch (err) {
trackRequest('/api/status', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
if (method === 'POST' && urlPath === '/api/shutdown') {
try {
const { logInfo } = require('../../infrastructure/logger');
// Send success response before shutting down
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Graceful shutdown initiated. Process will exit after cleanup completes.'
}));
// Trigger graceful shutdown after sending response
setTimeout(() => {
logInfo('Admin', 'Initiating graceful shutdown (without cleaning storage)...');
process.emit('SIGTERM');
}, 500); // Small delay to ensure response is sent
} catch (err) {
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
if (method === 'GET' && urlPath === '/api/health') {
try {
const query = url.parse(req.url, true).query;
const probeType = query.probe || 'liveness';
const dnsHealthy = !!state.dnsPass && state.dnsPass.opened !== false;
const proxyHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
const swarmHealthy = state.connectedPeers !== undefined;
const corestoreHealthy = state.dnsPass && state.dnsPass.base && state.dnsPass.base.writable !== undefined;
const hyperswarmHealthy = swarmHealthy;
const dnsServerHealthy = process.env.DISABLE_DNS_SERVER !== 'true';
const httpsServerHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
const allServicesHealthy = dnsHealthy && proxyHealthy && swarmHealthy && corestoreHealthy && hyperswarmHealthy;
const status = allServicesHealthy ? 'healthy' : 'degraded';
const health = {
status: status,
timestamp: new Date().toISOString(),
uptime: Date.now() - (metrics?.startTime || Date.now()),
probe: probeType,
services: {
dns: {
enabled: dnsServerHealthy,
healthy: dnsHealthy,
initialized: !!state.dnsPass,
details: {
passReady: state.dnsPass?.opened !== false,
domainsCount: state.domainToIPMap?.size || 0
}
},
proxy: {
enabled: httpsServerHealthy,
healthy: proxyHealthy,
details: {
httpsEnabled: process.env.DISABLE_PROXY_SERVER !== 'true',
httpEnabled: process.env.DISABLE_PROXY_SERVER !== 'true'
}
},
swarm: {
healthy: swarmHealthy,
details: {
connectedPeers: state.connectedPeers?.size || 0,
isMaster: state.isMaster || false
}
}
},
dependencies: {
corestore: {
healthy: corestoreHealthy,
details: {
initialized: !!state.dnsPass,
writable: state.dnsPass?.base?.writable || false
}
},
hyperswarm: {
healthy: hyperswarmHealthy,
details: {
connectedPeers: state.connectedPeers?.size || 0
}
}
}
};
if (probeType === 'readiness') {
const ready = allServicesHealthy && state.dnsPass && state.dnsPass.ready;
if (!ready) {
trackRequest('/api/health', false);
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ...health, status: 'not_ready' }));
return true;
}
}
const statusCode = allServicesHealthy ? 200 : 503;
trackRequest('/api/health', allServicesHealthy);
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(health));
} catch (err) {
trackRequest('/api/health', false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
return false;
}
module.exports = { handleStatusRoutes };
-364
View File
@@ -1,364 +0,0 @@
const { logError, logInfo } = require('../infrastructure/logger');
const { parseSecondsToMs } = require('../infrastructure/utils');
// Settings that require restart to take effect
const restartRequiredSettings = [
'DNS_PORT',
'HTTPS_PORT',
'HTTP_PORT',
'INTERNAL_PORT',
'STORAGE_DIR',
'DOMAINS_FILE',
'LOCAL_DNS_FILE',
'HOLESAIL_SERVERS_FILE',
'HOLESAIL_CLIENTS_FILE',
'SELECTOR_CACHE_FILE',
'CERTS_DIR',
'SUBNET_BASE',
'SUBNET_NAME',
'INITIAL_IP_INDEX',
'SUBNETS',
'TOPIC_SEED',
'DISABLE_DNS_SERVER',
'DISABLE_PROXY_SERVER'
];
// Settings that can be live-reloaded
const liveReloadableSettings = [
'LOG_LEVEL',
'RATE_LIMIT_MAX_REQUESTS',
'RATE_LIMIT_WINDOW_MS',
'DNS_POOL_SIZE',
'PUBLIC_DNS_SERVER',
'HOLESAIL_TIMEOUT',
'PORT_CHECK_TIMEOUT',
'ALLOW_ANY_WRITER_INVITES',
'FULL_PERSISTENCE',
'BACKUP_RETENTION'
];
const envWhitelist = [
'LOG_LEVEL',
'STORAGE_DIR',
'DISABLE_PROXY_SERVER',
'HTTPS_PORT',
'HTTP_PORT',
'TOPIC_SEED',
'DOMAINS_FILE',
'DISABLE_DNS_SERVER',
'DNS_PORT',
'CERTS_DIR',
'LOCAL_DNS_FILE',
'HOLESAIL_SERVERS_FILE',
'HOLESAIL_CLIENTS_FILE',
'PUBLIC_DNS_SERVER',
'SUBNET_NAME',
'INITIAL_IP_INDEX',
'SUBNET_BASE',
'SUBNETS',
'INTERNAL_PORT',
'HOLESAIL_TIMEOUT',
'PORT_CHECK_TIMEOUT',
'FULL_PERSISTENCE',
'ALLOW_ANY_WRITER_INVITES',
'SELECTOR_CACHE_FILE',
'RATE_LIMIT_MAX_REQUESTS',
'RATE_LIMIT_WINDOW_MS',
'DNS_POOL_SIZE',
'BACKUP_RETENTION'
];
/**
* Apply live settings changes without requiring restart
* @param {object} settings - Settings to apply
*/
async function applyLiveSettings(settings) {
try {
// Update logger if LOG_LEVEL changed (do this first so subsequent logs use new level)
if (settings.LOG_LEVEL !== undefined) {
const { updateLogLevel } = require('../infrastructure/logger');
updateLogLevel(parseInt(settings.LOG_LEVEL, 10));
// Use console.log here since logger was just updated
console.log(`[Admin] Log level updated to ${settings.LOG_LEVEL}`);
}
// Update rate limiter if rate limit settings changed
if (settings.RATE_LIMIT_MAX_REQUESTS !== undefined || settings.RATE_LIMIT_WINDOW_MS !== undefined) {
const { updateRateLimiter } = require('../infrastructure/rate_limit');
const maxRequests = settings.RATE_LIMIT_MAX_REQUESTS !== undefined
? parseInt(settings.RATE_LIMIT_MAX_REQUESTS, 10)
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10);
const windowMs = settings.RATE_LIMIT_WINDOW_MS !== undefined
? parseSecondsToMs(settings.RATE_LIMIT_WINDOW_MS)
: parseSecondsToMs(process.env.RATE_LIMIT_WINDOW_MS || '60');
updateRateLimiter(maxRequests, windowMs);
logInfo('Admin', `Rate limiter updated: maxRequests=${maxRequests}, windowMs=${windowMs}`);
}
// Update DNS pool if DNS_POOL_SIZE changed
// Note: PUBLIC_DNS_SERVER is already read from process.env at query time, so it's live-reloadable
if (settings.DNS_POOL_SIZE !== undefined) {
const { updateDnsPool } = require('../networking/dns_pool');
const poolSize = parseInt(settings.DNS_POOL_SIZE, 10);
updateDnsPool(poolSize);
logInfo('Admin', `DNS pool updated: size=${poolSize}`);
}
// Update backup retention if BACKUP_RETENTION changed
if (settings.BACKUP_RETENTION !== undefined) {
const state = require('../infrastructure/state');
const retentionCount = parseInt(settings.BACKUP_RETENTION, 10);
state.backupRetentionCount = retentionCount;
logInfo('Admin', `Backup retention updated: ${retentionCount} backups`);
}
// Other settings (HOLESAIL_TIMEOUT, PORT_CHECK_TIMEOUT, ALLOW_ANY_WRITER_INVITES, PUBLIC_DNS_SERVER, FULL_PERSISTENCE)
// are already read from process.env at runtime, so no action needed
logInfo('Admin', 'Live settings applied successfully');
} catch (err) {
logError('Admin', `Error applying live settings: ${err.message}`);
throw err;
}
}
// Settings metadata with types, descriptions, and categories
const settingsMetadata = {
// Network & Ports
'DNS_PORT': {
type: 'number',
category: 'Network & Ports',
label: 'DNS Server Port',
description: 'Port for the DNS server (default: 53, requires sudo)',
default: '53',
min: 1,
max: 65535
},
'HTTPS_PORT': {
type: 'number',
category: 'Network & Ports',
label: 'HTTPS Proxy Port',
description: 'Port for HTTPS proxy server (default: 443, requires sudo)',
default: '443',
min: 1,
max: 65535
},
'HTTP_PORT': {
type: 'number',
category: 'Network & Ports',
label: 'HTTP Redirect Port',
description: 'Port for HTTP redirect server (default: 80, requires sudo)',
default: '80',
min: 1,
max: 65535
},
'INTERNAL_PORT': {
type: 'number',
category: 'Network & Ports',
label: 'Internal Holesail Port',
description: 'Port used by Holesail clients for tunneling (default: 8080)',
default: '8080',
min: 1,
max: 65535
},
// Backup
'STORAGE_DIR': {
type: 'text',
category: 'Backup',
label: 'Storage Directory',
description: 'Directory for Corestore data storage (default: ./my-storage)',
default: './my-storage'
},
'DOMAINS_FILE': {
type: 'text',
category: 'Backup',
label: 'Domains File',
description: 'Path to domains JSON file (default: cache/domains.json)',
default: 'cache/domains.json'
},
'LOCAL_DNS_FILE': {
type: 'text',
category: 'Backup',
label: 'Local DNS File',
description: 'Path to local DNS records file (default: cache/local_dns.json)',
default: 'cache/local_dns.json'
},
'HOLESAIL_SERVERS_FILE': {
type: 'text',
category: 'Backup',
label: 'Holesail Servers File',
description: 'Path to Holesail servers configuration file (default: ./cache/holesail_servers.json)',
default: './cache/holesail_servers.json'
},
'HOLESAIL_CLIENTS_FILE': {
type: 'text',
category: 'Backup',
label: 'Holesail Clients File',
description: 'Path to Holesail clients configuration file (default: ./cache/holesail_clients.json)',
default: './cache/holesail_clients.json'
},
'SELECTOR_CACHE_FILE': {
type: 'text',
category: 'Backup',
label: 'Selector Cache File',
description: 'Path to DNS version preferences cache file (default: ./cache/selector_cache.json)',
default: './cache/selector_cache.json'
},
'CERTS_DIR': {
type: 'text',
category: 'Backup',
label: 'Certificates Directory',
description: 'Directory for storing certificates (default: ./certs)',
default: './certs'
},
'BACKUP_RETENTION': {
type: 'number',
category: 'Backup',
label: 'Backup Retention Count',
description: 'Maximum number of backups to keep (default: 25). Older backups will be automatically deleted.',
default: '25',
min: 1,
max: 1000
},
// Network Configuration
'SUBNET_BASE': {
type: 'text',
category: 'Network Configuration',
label: 'Subnet Base',
description: 'IP subnet base for virtual interfaces (default: 192.168.3.)',
default: '192.168.3.'
},
'SUBNET_NAME': {
type: 'text',
category: 'Network Configuration',
label: 'Subnet Interface Name',
description: 'Network interface name for virtual IPs (default: lo0 on macOS, lo on Linux)',
default: ''
},
'INITIAL_IP_INDEX': {
type: 'number',
category: 'Network Configuration',
label: 'Initial IP Index',
description: 'Starting IP index for virtual interfaces (default: 2)',
default: '2',
min: 1,
max: 254
},
'SUBNETS': {
type: 'text',
category: 'Network Configuration',
label: 'Subnets Configuration',
description: 'JSON array of subnet configurations. Use the Subnet Configuration section in Settings to manage this.',
default: '[]'
},
'PUBLIC_DNS_SERVER': {
type: 'text',
category: 'Network Configuration',
label: 'Public DNS Server',
description: 'Public DNS server for fallback resolution (default: 1.1.1.1)',
default: '1.1.1.1'
},
// Holesail
'HOLESAIL_TIMEOUT': {
type: 'number',
category: 'Holesail',
label: 'Holesail Client Timeout (minutes)',
description: 'Timeout in minutes for non-persistent Holesail clients (default: 5 minutes)',
default: '5',
min: 1
},
'FULL_PERSISTENCE': {
type: 'checkbox',
category: 'Holesail',
label: 'Full Persistence',
description: 'Enable full persistence mode - prevents shutdown/restart of holesail clients/servers (default: false) [Not recommended for low ram devices]',
default: 'false'
},
// Security & Access
'ALLOW_ANY_WRITER_INVITES': {
type: 'checkbox',
category: 'Security & Access',
label: 'Allow Any Writer Invites',
description: 'Allow non-master nodes to issue invites (default: true)',
default: 'true'
},
'DISABLE_DNS_SERVER': {
type: 'checkbox',
category: 'Security & Access',
label: 'Disable DNS Server',
description: 'Disable the DNS server (default: false)',
default: 'false'
},
'DISABLE_PROXY_SERVER': {
type: 'checkbox',
category: 'Security & Access',
label: 'Disable Proxy Server',
description: 'Disable HTTPS and HTTP proxy servers (default: false)',
default: 'false'
},
// Logging & Debugging
'LOG_LEVEL': {
type: 'select',
category: 'Logging & Debugging',
label: 'Log Level',
description: 'Logging verbosity level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR)',
default: '0',
options: [
{ value: '0', label: 'DEBUG (0)' },
{ value: '1', label: 'INFO (1)' },
{ value: '2', label: 'WARN (2)' },
{ value: '3', label: 'ERROR (3)' }
]
},
// Performance
'RATE_LIMIT_MAX_REQUESTS': {
type: 'number',
category: 'Performance',
label: 'Rate Limit Max Requests',
description: 'Maximum requests per window for rate limiting (default: 100)',
default: '100',
min: 1
},
'RATE_LIMIT_WINDOW_MS': {
type: 'number',
category: 'Performance',
label: 'Rate Limit Window (seconds)',
description: 'Time window in seconds for rate limiting (default: 60 = 1 minute)',
default: '60',
min: 1
},
'DNS_POOL_SIZE': {
type: 'number',
category: 'Performance',
label: 'DNS Pool Size',
description: 'Maximum number of DNS resolver connections in pool (default: 5)',
default: '5',
min: 1,
max: 50
},
'PORT_CHECK_TIMEOUT': {
type: 'number',
category: 'Performance',
label: 'Port Check Timeout (ms)',
description: 'Timeout in milliseconds for port availability checks (default: 2000)',
default: '2000',
min: 100
},
// Advanced
'TOPIC_SEED': {
type: 'text',
category: 'Advanced',
label: 'Topic Seed',
description: 'Seed for generating Hyperswarm discovery topic (default: p2ns-dns)',
default: 'p2ns-dns'
},
};
module.exports = {
settingsMetadata,
restartRequiredSettings,
liveReloadableSettings,
envWhitelist,
applyLiveSettings
};
-169
View File
@@ -1,169 +0,0 @@
// Certificates UI functions
function regenerateCA() {
if (window.showConfirm) {
window.showConfirm('Regenerate Root CA?', async () => {
try {
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Root CA regenerated successfully');
if (window.genericFetch) window.genericFetch('certs', true);
} catch (err) {
console.error('Failed to regenerate CA:', err);
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
}
});
}
}
function installCA() {
if (window.showConfirm) {
window.showConfirm('Install Root CA?', async () => {
try {
const response = await fetch('/api/install-ca', { method: 'POST' });
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Root CA installed successfully');
} catch (err) {
console.error('Failed to install CA:', err);
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
}
});
}
}
async function generateCert() {
const domainEl = document.getElementById('cert-domain');
if (!domainEl) return;
const domain = domainEl.value;
try {
const response = await fetch('/api/generate-cert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Certificate generated successfully');
if (window.genericFetch) window.genericFetch('certs', true);
} catch (err) {
console.error('Failed to generate cert:', err);
if (window.showNotification) window.showNotification('Failed to generate cert: ' + err.message, 'error');
}
}
function deleteCert(domain) {
if (window.showConfirm) {
window.showConfirm(`Delete certificate for ${domain}?`, async () => {
try {
const response = await fetch('/api/delete-cert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Certificate deleted successfully');
if (window.genericFetch) window.genericFetch('certs', true);
} catch (err) {
console.error('Failed to delete cert:', err);
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
}
});
}
}
function regenerateCert(domain) {
if (window.showConfirm) {
window.showConfirm(`Regenerate certificate for ${domain}?`, async () => {
try {
const response = await fetch('/api/regenerate-cert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Certificate regenerated successfully');
if (window.genericFetch) window.genericFetch('certs', true);
} catch (err) {
console.error('Failed to regenerate cert:', err);
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
}
});
}
}
async function showCertDetails(domain) {
try {
const res = await fetch(`/api/cert-details?domain=${encodeURIComponent(domain)}`);
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.text();
const formatted = formatCertificate(data);
const contentEl = document.getElementById('cert-details-content');
const modal = document.getElementById('certDetailsModal');
if (contentEl) contentEl.textContent = formatted;
if (modal) modal.showModal();
} catch (err) {
console.error('Failed to fetch cert details:', err);
if (window.showNotification) window.showNotification('Failed to load certificate details: ' + err.message, 'error');
}
}
function formatCertificate(certPem) {
return certPem.replace(/(-----BEGIN CERTIFICATE-----)/g, '\n$1\n')
.replace(/(-----END CERTIFICATE-----)/g, '\n$1\n')
.trim();
}
function copyCertDetails() {
const contentEl = document.getElementById('cert-details-content');
if (!contentEl) return;
const content = contentEl.textContent;
navigator.clipboard.writeText(content).then(() => {
if (window.showNotification) window.showNotification('Certificate details copied to clipboard', 'success');
}).catch(err => {
console.error('Failed to copy:', err);
if (window.showNotification) window.showNotification('Failed to copy certificate details', 'error');
});
}
window.regenerateCA = regenerateCA;
window.installCA = installCA;
window.generateCert = generateCert;
window.deleteCert = deleteCert;
window.regenerateCert = regenerateCert;
window.showCertDetails = showCertDetails;
window.formatCertificate = formatCertificate;
window.copyCertDetails = copyCertDetails;
-385
View File
@@ -1,385 +0,0 @@
// Configuration constants
window.updateMap = {
'update-database': ['domains', 'entries'],
'update-peers': ['peers'],
'update-certs': ['certs'],
'update-interfaces': ['interfaces'],
'update-local-dns': ['local-dns', 'dns-conflicts', 'p2p-domain-conflicts'],
'update-holesail': [],
'update-holesail-clients': [],
'update-settings': ['settings'],
'update-stats': [],
'system-reset': () => location.reload()
};
window.paginationState = {
domains: { current: 1, size: 8 },
entries: { current: 1, size: 10 },
peers: { current: 1, size: 20 },
certs: { current: 1, size: 5 },
interfaces: { current: 1, size: 10 },
'local-dns': { current: 1, size: 10 },
'dns-conflicts': { current: 1, size: 10 },
'host-servers': { current: 1, size: 10 },
'host-clients': { current: 1, size: 10 },
plugins: { current: 1, size: 10 }
};
// Helper function to get consensus status badge
function getConsensusStatusBadge(status) {
const badges = {
'resolved': '<span class="px-2 py-1 bg-green-500 text-white rounded text-xs">Resolved</span>',
'tie': '<span class="px-2 py-1 bg-yellow-500 text-white rounded text-xs">Tie</span>',
'conflict': '<span class="px-2 py-1 bg-red-500 text-white rounded text-xs">Conflict</span>',
'insufficient_quorum': '<span class="px-2 py-1 bg-orange-500 text-white rounded text-xs">No Quorum</span>',
'no_claims': '<span class="px-2 py-1 bg-gray-500 text-white rounded text-xs">No Claims</span>',
'error': '<span class="px-2 py-1 bg-red-500 text-white rounded text-xs">Error</span>',
'internal': '<span class="px-2 py-1 bg-blue-500 text-white rounded text-xs">Internal</span>',
'unknown': '<span class="px-2 py-1 bg-gray-400 text-white rounded text-xs">Unknown</span>'
};
return badges[status] || badges['unknown'];
}
// Make function globally available
window.getConsensusStatusBadge = getConsensusStatusBadge;
window.chartColors = {
primary: 'rgb(59, 130, 246)',
success: 'rgb(34, 197, 94)',
warning: 'rgb(234, 179, 8)',
danger: 'rgb(239, 68, 68)',
info: 'rgb(59, 130, 246)',
gray: 'rgb(107, 114, 128)',
dark: 'rgb(17, 24, 39)'
};
window.darkModeColors = {
primary: 'rgb(96, 165, 250)',
success: 'rgb(74, 222, 128)',
warning: 'rgb(250, 204, 21)',
danger: 'rgb(248, 113, 113)',
info: 'rgb(96, 165, 250)',
gray: 'rgb(156, 163, 175)',
dark: 'rgb(243, 244, 246)'
};
// Tabs configuration - uses functions from utils.js and other modules
window.tabs = {
domains: {
api: '/api/resolved-domains',
searchId: 'search-domains',
dataKey: 'domainsData',
filteredKey: 'filteredDomains',
containerId: 'domainsTable',
paginationId: 'domainsPagination',
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.hash.toLowerCase().includes(query),
renderItem: (item) => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
let consensusInfo = '';
if (item.consensusState) {
const statusBadge = getConsensusStatusBadge(item.consensusStatus);
consensusInfo = `<td class="p-3">${statusBadge}</td>`;
} else if (item.consensusStatus === 'internal') {
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('internal')}</td>`;
} else {
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('unknown')}</td>`;
}
tr.innerHTML = `<td class="p-3">${item.domain}${item.isLocal ? '🏠' : ''}</td>
<td class="p-3 break-all">${item.hash}</td>
${consensusInfo}
<td class="p-3">${item.isLocal && item.hash !== 'internal' ? `<button onclick="removeDomain('${item.domain}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Remove</button>` : ''}</td>`;
return tr;
},
postFetch: (data) => {
return data.map(item => {
if (item.consensusStatus === 'internal' || item.hash === 'internal' || item.hash === 'none') {
return { ...item, consensusStatus: 'internal' };
}
// Use consensusStatus set by backend (including 'conflict'), fallback to consensusState status
return {
...item,
consensusStatus: item.consensusStatus || item.consensusState?.status || 'unknown'
};
});
}
},
entries: {
api: '/api/entries',
searchId: 'search-entries',
dataKey: 'entriesData',
filteredKey: 'filteredEntries',
containerId: 'entriesTable',
paginationId: 'entriesPagination',
sort: (a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.key.toLowerCase().includes(query) || item.value.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 break-all">${item.key}</td>
<td class="p-3 break-all">${item.value}</td>`;
return tr;
}
},
peers: {
api: '/api/peers',
searchId: 'search-peers',
dataKey: 'peersData',
filteredKey: 'filteredPeers',
containerId: 'peersList',
paginationId: 'peersPagination',
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.toLowerCase().includes(query),
renderItem: (peer) => {
const li = document.createElement('li');
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow break-all';
li.textContent = peer;
return li;
},
preRender: (total) => {
const el = document.getElementById('peers-count');
if (el) el.textContent = `(${total})`;
}
},
certs: {
api: '/api/certs',
searchId: 'search-certs',
dataKey: 'certsData',
filteredKey: 'filteredCerts',
containerId: 'certsList',
paginationId: 'certsPagination',
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.toLowerCase().includes(query),
renderItem: (cert) => {
const li = document.createElement('li');
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow flex justify-between items-center';
li.innerHTML = `<span class="cursor-pointer flex-1 break-all" onclick="showCertDetails('${cert}')">${cert}</span>
<div>
<button onclick="deleteCert('${cert}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 mr-2">Delete</button>
<button onclick="regenerateCert('${cert}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">Regenerate</button>
</div>`;
return li;
}
},
interfaces: {
api: '/api/interfaces',
searchId: 'search-interfaces',
dataKey: 'interfacesData',
filteredKey: 'filteredInterfaces',
containerId: 'interfacesTable',
paginationId: 'interfacesPagination',
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.ip.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">${item.ip}</td>`;
return tr;
}
},
'local-dns': {
api: '/api/local-dns',
searchId: 'search-local-dns',
dataKey: 'localDnsData',
filteredKey: 'filteredLocalDns',
containerId: 'localDnsTable',
paginationId: 'localDnsPagination',
sort: (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
filter: (item, query) => {
const queryLower = query.toLowerCase();
return (
item.name.toLowerCase().includes(queryLower) ||
item.type.toLowerCase().includes(queryLower) ||
Object.values(item).some(val => typeof val === 'string' && val.toLowerCase().includes(queryLower))
);
},
renderItem: (item) => {
let valueStr = '';
if (item.type === 'MX') {
valueStr = `${item.preference || ''} ${item.exchange || ''}`.trim();
} else if (item.type === 'SRV') {
valueStr = `${item.priority || ''} ${item.weight || ''} ${item.port || ''} ${item.target || ''}`.trim();
} else if (item.type === 'SOA') {
valueStr = `${item.mname || ''} ${item.rname || ''} ${item.serial || ''} ${item.refresh || ''} ${item.retry || ''} ${item.expire || ''} ${item.minimum || ''}`.trim();
} else if (item.type === 'CAA') {
valueStr = `${item.flags || ''} ${item.tag || ''} ${item.value || ''}`.trim();
} else {
valueStr = item.data || item.value || '';
}
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.name}</td>
<td class="p-3">${item.type}</td>
<td class="p-3 break-all">${valueStr}</td>
<td class="p-3">${item.ttl}</td>
<td class="p-3">
<button onclick="editLocalDns(${item.index})" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Edit</button>
<button onclick="deleteLocalDns(${item.index})" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
</td>`;
return tr;
},
postFetch: (data) => {
window.dnsConflictsData = data.conflicts || [];
window.filteredDnsConflicts = window.dnsConflictsData;
data.records = data.records.map((rec, index) => ({ ...rec, index }));
if (window.renderDnsConflicts) window.renderDnsConflicts();
return data.records;
}
},
'dns-conflicts': {
api: '/api/local-dns',
searchId: 'search-dns-conflicts',
dataKey: 'dnsConflictsData',
filteredKey: 'filteredDnsConflicts',
containerId: 'dnsConflictsTable',
paginationId: 'dnsConflictsPagination',
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.version.toLowerCase().includes(query) || item.publicIP.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">${item.publicIP}</td>
<td class="p-3">
<label class="inline-flex items-center cursor-pointer">
<span class="mr-2">${item.version === 'public' ? 'Public' : 'P2P'}</span>
<input type="checkbox" ${item.version === 'public' ? 'checked' : ''} onchange="toggleVersionPreference('${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) => {
window.localDnsData = data.records.map((rec, index) => ({ ...rec, index }));
window.filteredLocalDns = window.localDnsData;
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',
dataKey: 'holesailServersData',
filteredKey: 'filteredHolesailServers',
containerId: 'holesailTable',
paginationId: 'holesailPagination',
sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }),
filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query),
renderItem: (item) => {
const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id);
const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id);
const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailServer('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailServer('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = item.opts.udp ? 'UDP' : 'TCP';
const url = item.info.url || 'N/A';
const truncatedUrl = window.truncateUrl ? window.truncateUrl(url, 40) : url.length > 40 ? url.substring(0, 37) + '...' : url;
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = `
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.name || item.id}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
<td class="p-3">${item.opts.port}</td>
<td class="p-3">${item.opts.host || '0.0.0.0'}</td>
<td class="p-3" title="${url}">${truncatedUrl}</td>
<td class="p-3">${protocol}</td>
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
<td class="p-3">
${restartBtn}
${deleteBtn}
</td>`;
return tr;
}
},
'host-clients': {
api: '/api/holesail-clients',
searchId: 'search-holesail-clients',
dataKey: 'holesailClientsData',
filteredKey: 'filteredHolesailClients',
containerId: 'holesailClientsTable',
paginationId: 'holesailClientsPagination',
sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }),
filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query),
renderItem: (item) => {
const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id);
const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id);
const restartBtn = isPendingRestart
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="restartHolesailClient('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
const deleteBtn = isPendingDelete
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
: `<button onclick="deleteHolesailClient('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
const protocol = (item.opts.protocol || 'tcp').toUpperCase();
const key = item.opts.key || '';
const truncatedKey = window.truncateUrl ? window.truncateUrl(key, 30) : key.length > 30 ? key.substring(0, 27) + '...' : key;
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
tr.innerHTML = `
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.domain}:${item.opts.port}')" title="${item.opts.domain}">${item.opts.domain}</td>
<td class="p-3" title="${key}">${truncatedKey}</td>
<td class="p-3">${item.opts.port}</td>
<td class="p-3">${protocol}</td>
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
<td class="p-3">
${restartBtn}
${deleteBtn}
</td>`;
return tr;
}
},
settings: {
api: '/api/settings',
searchId: 'search-settings',
dataKey: 'settingsData',
filteredKey: 'filteredSettings',
containerId: 'settingsContainer',
paginationId: 'settingsPagination',
sort: null,
filter: null,
renderItem: null,
postFetch: (data) => {
window.settingsMetadata = data.metadata || {};
return data.settings || {};
}
}
};
-180
View File
@@ -1,180 +0,0 @@
// Core UI functions - generic fetch, filter, pagination
async function genericFetch(tabId, shouldRender = true) {
const config = window.tabs[tabId];
if (!config) return;
try {
const res = await fetch(config.api);
let data = await res.json();
if (config.postFetch) data = config.postFetch(data);
// Special handling for settings tab
if (tabId === 'settings') {
window[config.dataKey] = data;
if (shouldRender && window.renderSettings) await window.renderSettings(data);
return;
}
if (config.sort) data.sort(config.sort);
window[config.dataKey] = data;
if (shouldRender) genericFilter(tabId);
} catch (err) {
console.error(`Failed to fetch ${tabId}:`, err);
if (window.showNotification) window.showNotification(`Failed to load ${tabId}`, 'error');
}
}
function genericFilter(tabId) {
const config = window.tabs[tabId];
if (!config || !config.filter) return;
const searchEl = document.getElementById(config.searchId);
if (!searchEl) return;
const query = searchEl.value.toLowerCase();
const filtered = window[config.dataKey].filter(item => config.filter(item, query));
window[config.filteredKey] = filtered;
window.paginationState[tabId].current = 1;
genericRenderPaginated(tabId);
}
function genericRenderPaginated(tabId) {
const config = window.tabs[tabId];
if (!config) return;
const data = window[config.filteredKey] || window[config.dataKey];
if (!data) return;
if (config.preRender) config.preRender(data.length);
const state = window.paginationState[tabId];
const start = (state.current - 1) * state.size;
const end = start + state.size;
const pageData = data.slice(start, end);
const container = document.getElementById(config.containerId);
if (!container) return;
container.innerHTML = '';
if (pageData.length === 0) {
const emptyRow = document.createElement('tr');
emptyRow.className = 'border-b';
const colCount = tabId === 'host-servers' ? 7 : (tabId === 'host-clients' ? 6 : (tabId === 'local-dns' ? 5 : 3));
let message = '';
let subMessage = '';
let icon = '📭';
if (tabId === 'domains' && !window.wsConnected) {
message = 'Disconnected';
subMessage = 'Reconnecting to P2NS server...';
icon = '🔌';
} else {
message = `No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found`;
subMessage = tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search';
}
emptyRow.innerHTML = `<td colspan="${colCount}" class="p-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center gap-2">
<span class="text-4xl">${icon}</span>
<span class="text-lg font-semibold">${message}</span>
<span class="text-sm">${subMessage}</span>
</div>
</td>`;
container.appendChild(emptyRow);
} else {
pageData.forEach(item => container.appendChild(config.renderItem(item)));
}
renderPagination(tabId, data.length);
}
function renderPagination(tabId, total) {
const config = window.tabs[tabId];
if (!config) return;
const pagination = document.getElementById(config.paginationId);
if (!pagination) return;
pagination.innerHTML = '';
const state = window.paginationState[tabId];
const totalPages = Math.ceil(total / state.size);
if (totalPages <= 1) return;
const prevButton = document.createElement('button');
prevButton.textContent = 'Previous';
prevButton.className = 'px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50';
prevButton.disabled = state.current === 1;
prevButton.onclick = () => {
if (state.current > 1) {
state.current--;
genericRenderPaginated(tabId);
}
};
pagination.appendChild(prevButton);
const delta = 2;
const pages = [];
if (totalPages <= 10) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
const left = state.current - delta;
if (left > 2) pages.push('...');
for (let i = Math.max(2, left); i <= Math.min(totalPages - 1, state.current + delta); i++) pages.push(i);
if (state.current + delta < totalPages - 1) pages.push('...');
pages.push(totalPages);
}
for (let page of pages) {
if (page === '...') {
const span = document.createElement('span');
span.textContent = '...';
span.className = 'px-4 py-2 text-gray-500 dark:text-gray-400';
pagination.appendChild(span);
} else {
const button = document.createElement('button');
button.textContent = page;
button.className = `px-4 py-2 ${page === state.current ? 'bg-primary-hover' : 'bg-primary'} text-white rounded hover:bg-primary-hover`;
button.onclick = () => {
state.current = page;
genericRenderPaginated(tabId);
};
pagination.appendChild(button);
}
}
const nextButton = document.createElement('button');
nextButton.textContent = 'Next';
nextButton.className = 'px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50';
nextButton.disabled = state.current === totalPages;
nextButton.onclick = () => {
if (state.current < totalPages) {
state.current++;
genericRenderPaginated(tabId);
}
};
pagination.appendChild(nextButton);
}
// Filter functions for each tab
function filterDomains() { genericFilter('domains'); }
function filterEntries() { genericFilter('entries'); }
function filterPeers() { genericFilter('peers'); }
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'); }
// Make functions globally accessible
window.genericFetch = genericFetch;
window.genericFilter = genericFilter;
window.genericRenderPaginated = genericRenderPaginated;
window.renderPagination = renderPagination;
window.filterDomains = filterDomains;
window.filterEntries = filterEntries;
window.filterPeers = filterPeers;
window.filterCerts = filterCerts;
window.filterInterfaces = filterInterfaces;
window.filterLocalDNS = filterLocalDNS;
window.filterDnsConflicts = filterDnsConflicts;
window.filterP2pDomainConflicts = filterP2pDomainConflicts;
window.filterHolesailServers = filterHolesailServers;
window.filterHolesailClients = filterHolesailClients;
-97
View File
@@ -1,97 +0,0 @@
// Domains UI functions
function renderDnsConflicts() {
if (window.genericFilter) window.genericFilter('dns-conflicts');
}
function openAddModal() {
const modal = document.getElementById('addDomainModal');
if (modal) modal.showModal();
}
async function submitAddDomain() {
const domainEl = document.getElementById('modal-domain');
const hashEl = document.getElementById('modal-hash');
const sslEl = document.getElementById('modal-ssl');
if (!domainEl || !hashEl) return;
const domain = domainEl.value.trim();
const hash = hashEl.value.trim();
const ssl = sslEl ? sslEl.checked : false;
try {
const response = await fetch('/api/add-domain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, hash, ssl })
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = errorText;
// Try to parse as JSON and extract the message
try {
const errorJson = JSON.parse(errorText);
errorMessage = errorJson.message || errorText;
} catch (e) {
// If not JSON, use the text as-is
errorMessage = errorText;
}
throw new Error(errorMessage);
}
if (window.showNotification) window.showNotification('Domain added successfully');
const modal = document.getElementById('addDomainModal');
if (modal) modal.close();
domainEl.value = '';
hashEl.value = '';
if (sslEl) sslEl.checked = false;
if (window.genericFetch) window.genericFetch('domains', true);
} catch (err) {
console.error('Failed to add domain:', err);
// Display just the error message, not the full error object
const errorMessage = err.message || 'An unknown error occurred';
if (window.showNotification) window.showNotification(errorMessage, 'error');
}
}
function removeDomain(domain) {
if (window.showConfirm) {
window.showConfirm(`Remove ${domain}?`, async () => {
try {
const response = await fetch('/api/remove-domain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Domain removed successfully');
if (window.genericFetch) window.genericFetch('domains', true);
} catch (err) {
console.error('Failed to remove domain:', err);
if (window.showNotification) window.showNotification('Failed to remove domain: ' + err.message, 'error');
}
});
}
}
window.renderDnsConflicts = renderDnsConflicts;
window.openAddModal = openAddModal;
window.submitAddDomain = submitAddDomain;
window.removeDomain = removeDomain;
-44
View File
@@ -1,44 +0,0 @@
// Interfaces UI functions
function cleanupInterfaces() {
if (window.showConfirm) {
window.showConfirm('Cleanup interfaces?', async () => {
try {
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Interfaces cleaned up successfully');
if (window.genericFetch) window.genericFetch('interfaces', true);
} catch (err) {
console.error('Failed to cleanup interfaces:', err);
if (window.showNotification) window.showNotification('Failed to cleanup interfaces: ' + err.message, 'error');
}
});
}
}
window.cleanupInterfaces = cleanupInterfaces;
-328
View File
@@ -1,328 +0,0 @@
// Local DNS UI functions
function openLocalDnsModal(editIndex = -1) {
const modal = document.getElementById('localDnsModal');
const title = document.getElementById('local-dns-title');
const nameInput = document.getElementById('local-name');
const typeSelect = document.getElementById('local-type');
const ttlInput = document.getElementById('local-ttl');
const submitBtn = document.getElementById('local-submit');
if (!modal || !title || !nameInput || !typeSelect || !ttlInput || !submitBtn) return;
nameInput.value = '';
typeSelect.value = 'A';
ttlInput.value = 3600;
if (window.updateLocalForm) updateLocalForm();
if (editIndex >= 0) {
const rec = window.localDnsData?.find(r => r.index === editIndex);
if (rec) {
nameInput.value = rec.name;
typeSelect.value = rec.type;
if (window.updateLocalForm) updateLocalForm(rec);
ttlInput.value = rec.ttl;
title.textContent = 'Edit Local DNS Record';
submitBtn.textContent = 'Update';
window.editLocalIndex = editIndex;
}
} else {
title.textContent = 'Add Local DNS Record';
submitBtn.textContent = 'Add';
window.editLocalIndex = -1;
}
modal.showModal();
}
function updateLocalForm(rec = null) {
const typeEl = document.getElementById('local-type');
const fields = document.getElementById('local-value-fields');
if (!typeEl || !fields) return;
const type = typeEl.value;
fields.innerHTML = '';
let inputHtml = '';
switch (type) {
case 'A':
case 'AAAA':
inputHtml = `<input id="local-data" placeholder="IP Address" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'CNAME':
inputHtml = `<input id="local-data" placeholder="Target Domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'TXT':
inputHtml = `<input id="local-data" placeholder="Text" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'MX':
inputHtml = `<input id="local-preference" type="number" placeholder="Preference" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-exchange" placeholder="Exchange" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'SRV':
inputHtml = `<input id="local-priority" type="number" placeholder="Priority" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-weight" type="number" placeholder="Weight" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-port" type="number" placeholder="Port" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-target" placeholder="Target" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'SOA':
inputHtml = `<input id="local-mname" placeholder="Primary Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-rname" placeholder="Responsible Person" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-serial" type="number" placeholder="Serial" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-refresh" type="number" placeholder="Refresh" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-retry" type="number" placeholder="Retry" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-expire" type="number" placeholder="Expire" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-minimum" type="number" placeholder="Minimum TTL" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'CAA':
inputHtml = `<input id="local-flags" type="number" placeholder="Flags" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-tag" placeholder="Tag (e.g., issue, issuewild, iodef)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-value" placeholder="Value" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
case 'NS':
case 'PTR':
inputHtml = `<input id="local-data" placeholder="Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
default:
inputHtml = `<input id="local-data" placeholder="Record Data" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
break;
}
fields.innerHTML = inputHtml;
if (rec) {
switch (type) {
case 'MX':
const prefEl = document.getElementById('local-preference');
const exchEl = document.getElementById('local-exchange');
if (prefEl) prefEl.value = rec.preference || '';
if (exchEl) exchEl.value = rec.exchange || '';
break;
case 'SRV':
const priEl = document.getElementById('local-priority');
const weightEl = document.getElementById('local-weight');
const portEl = document.getElementById('local-port');
const targetEl = document.getElementById('local-target');
if (priEl) priEl.value = rec.priority || '';
if (weightEl) weightEl.value = rec.weight || '';
if (portEl) portEl.value = rec.port || '';
if (targetEl) targetEl.value = rec.target || '';
break;
case 'SOA':
const mnameEl = document.getElementById('local-mname');
const rnameEl = document.getElementById('local-rname');
const serialEl = document.getElementById('local-serial');
const refreshEl = document.getElementById('local-refresh');
const retryEl = document.getElementById('local-retry');
const expireEl = document.getElementById('local-expire');
const minimumEl = document.getElementById('local-minimum');
if (mnameEl) mnameEl.value = rec.mname || '';
if (rnameEl) rnameEl.value = rec.rname || '';
if (serialEl) serialEl.value = rec.serial || '';
if (refreshEl) refreshEl.value = rec.refresh || '';
if (retryEl) retryEl.value = rec.retry || '';
if (expireEl) expireEl.value = rec.expire || '';
if (minimumEl) minimumEl.value = rec.minimum || '';
break;
case 'CAA':
const flagsEl = document.getElementById('local-flags');
const tagEl = document.getElementById('local-tag');
const valueEl = document.getElementById('local-value');
if (flagsEl) flagsEl.value = rec.flags || '';
if (tagEl) tagEl.value = rec.tag || '';
if (valueEl) valueEl.value = rec.value || '';
break;
default:
const dataEl = document.getElementById('local-data');
if (dataEl) dataEl.value = rec.data || rec.value || '';
break;
}
}
}
async function submitLocalDns() {
const nameEl = document.getElementById('local-name');
const typeEl = document.getElementById('local-type');
const ttlEl = document.getElementById('local-ttl');
if (!nameEl || !typeEl || !ttlEl) return;
const name = nameEl.value;
const type = typeEl.value;
const ttl = parseInt(ttlEl.value) || 3600;
let record = { name, type, ttl, class: 'IN' };
switch (type) {
case 'MX':
const prefEl = document.getElementById('local-preference');
const exchEl = document.getElementById('local-exchange');
record.preference = parseInt(prefEl?.value) || 10;
record.exchange = exchEl?.value || '';
break;
case 'SRV':
record.priority = parseInt(document.getElementById('local-priority')?.value) || 0;
record.weight = parseInt(document.getElementById('local-weight')?.value) || 0;
record.port = parseInt(document.getElementById('local-port')?.value) || 0;
record.target = document.getElementById('local-target')?.value || '';
break;
case 'SOA':
record.mname = document.getElementById('local-mname')?.value || '';
record.rname = document.getElementById('local-rname')?.value || '';
record.serial = parseInt(document.getElementById('local-serial')?.value) || 0;
record.refresh = parseInt(document.getElementById('local-refresh')?.value) || 0;
record.retry = parseInt(document.getElementById('local-retry')?.value) || 0;
record.expire = parseInt(document.getElementById('local-expire')?.value) || 0;
record.minimum = parseInt(document.getElementById('local-minimum')?.value) || 0;
break;
case 'CAA':
record.flags = parseInt(document.getElementById('local-flags')?.value) || 0;
record.tag = document.getElementById('local-tag')?.value || '';
record.value = document.getElementById('local-value')?.value || '';
break;
default:
record.data = document.getElementById('local-data')?.value || '';
break;
}
const isEdit = window.editLocalIndex >= 0;
const url = isEdit ? '/api/update-local-dns' : '/api/add-local-dns';
const body = isEdit ? JSON.stringify({ index: window.editLocalIndex, record }) : JSON.stringify(record);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification(isEdit ? 'Record updated successfully' : 'Record added successfully');
const modal = document.getElementById('localDnsModal');
if (modal) modal.close();
if (window.genericFetch) window.genericFetch('local-dns', true);
} catch (err) {
console.error('Failed to submit local DNS record:', err);
if (window.showNotification) window.showNotification('Failed to submit record: ' + err.message, 'error');
}
}
function editLocalDns(index) {
openLocalDnsModal(index);
}
function deleteLocalDns(index) {
if (window.showConfirm) {
window.showConfirm('Delete this record?', async () => {
try {
const response = await fetch('/api/delete-local-dns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification('Record deleted successfully');
if (window.genericFetch) window.genericFetch('local-dns', true);
} catch (err) {
console.error('Failed to delete local DNS record:', err);
if (window.showNotification) window.showNotification('Failed to delete record: ' + err.message, 'error');
}
});
}
}
async function toggleVersionPreference(domain, isPublic) {
try {
const version = isPublic ? 'public' : 'p2p';
const response = await fetch('/api/update-version-preference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, version })
});
if (!response.ok) {
throw new Error(await response.text());
}
if (window.showNotification) window.showNotification(`Version preference for ${domain} set to ${version}`);
if (window.genericFetch) window.genericFetch('dns-conflicts', true);
} catch (err) {
console.error('Failed to update version preference:', err);
if (window.showNotification) window.showNotification('Failed to update version preference: ' + err.message, 'error');
}
}
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;
window.editLocalDns = editLocalDns;
window.deleteLocalDns = deleteLocalDns;
window.toggleVersionPreference = toggleVersionPreference;
-114
View File
@@ -1,114 +0,0 @@
// Notification and confirmation dialog functions
const TOAST_DURATION_MS = 4000;
const TOAST_META = {
success: { tone: 'success', icon: 'fa-circle-check', label: 'Success' },
error: { tone: 'error', icon: 'fa-circle-xmark', label: 'Error' },
warning: { tone: 'warning', icon: 'fa-triangle-exclamation', label: 'Warning' },
info: { tone: 'info', icon: 'fa-circle-info', label: 'Info' }
};
function getToastMeta(type) {
return TOAST_META[type] || TOAST_META.success;
}
function getNotificationContainer() {
const openDialog = document.querySelector('dialog[open]');
if (openDialog) {
let dialogNotificationContainer = openDialog.querySelector('.dialog-notifications');
if (!dialogNotificationContainer) {
dialogNotificationContainer = document.createElement('div');
dialogNotificationContainer.className = 'dialog-notifications admin-toast-stack';
openDialog.appendChild(dialogNotificationContainer);
}
return { container: dialogNotificationContainer, openDialog };
}
return { container: document.getElementById('notifications'), openDialog: null };
}
function dismissToast(notification, container, openDialog) {
if (!notification || notification.dataset.dismissed === 'true') return;
notification.dataset.dismissed = 'true';
notification.classList.remove('admin-toast--visible');
notification.classList.add('admin-toast--leaving');
window.setTimeout(() => {
notification.remove();
if (openDialog && container?.classList.contains('dialog-notifications') && container.children.length === 0) {
container.remove();
}
}, 260);
}
function showNotification(message, type = 'success') {
const { container, openDialog } = getNotificationContainer();
if (!container) return;
const meta = getToastMeta(type);
const notification = document.createElement('div');
notification.className = `admin-toast admin-toast--${meta.tone}`;
notification.setAttribute('role', type === 'error' ? 'alert' : 'status');
notification.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
notification.innerHTML = `
<div class="admin-toast-icon" aria-hidden="true">
<i class="fas ${meta.icon}"></i>
</div>
<div class="admin-toast-body">
<span class="admin-toast-label">${meta.label}</span>
<p class="admin-toast-message"></p>
</div>
<button type="button" class="admin-toast-dismiss" aria-label="Dismiss notification">
<i class="fas fa-xmark" aria-hidden="true"></i>
</button>
<div class="admin-toast-progress" aria-hidden="true"></div>
`;
notification.querySelector('.admin-toast-message').textContent = message;
const progress = notification.querySelector('.admin-toast-progress');
progress.style.animationDuration = `${TOAST_DURATION_MS}ms`;
const dismiss = () => dismissToast(notification, container, openDialog);
notification.querySelector('.admin-toast-dismiss').addEventListener('click', dismiss);
container.appendChild(notification);
window.requestAnimationFrame(() => {
notification.classList.add('admin-toast--visible');
});
window.setTimeout(dismiss, TOAST_DURATION_MS);
}
function showConfirm(message, callback) {
const messageEl = document.getElementById('confirm-message');
const yesBtn = document.getElementById('confirm-yes');
const noBtn = document.getElementById('confirm-no');
const modal = document.getElementById('confirmModal');
if (!messageEl || !yesBtn || !noBtn || !modal) return;
messageEl.textContent = message;
modal.showModal();
const yesHandler = () => {
callback();
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
const noHandler = () => {
modal.close();
yesBtn.removeEventListener('click', yesHandler);
noBtn.removeEventListener('click', noHandler);
};
yesBtn.addEventListener('click', yesHandler);
noBtn.addEventListener('click', noHandler);
}
window.showNotification = showNotification;
window.showConfirm = showConfirm;
-55
View File
@@ -1,55 +0,0 @@
// Global state variables
window.activeTab = 'domains';
window.ws = null;
window.wsConnected = false;
window.wsReconnectAttempts = 0;
window.wsPollingInterval = null;
window.statusUpdateInterval = null;
window.logBuffer = [];
window.logBuffers = new Map();
window.activeLogChannel = 'core';
window.maxLogLines = 1000;
window.terminalInitialized = false;
window.term = null;
window.fitAddon = null;
window.holesailLogBuffers = new Map();
window.currentOpenHolesailId = null;
window.holesailTerm = null;
window.holesailFitAddon = null;
window.pendingClientRestarts = new Set();
window.pendingServerRestarts = new Set();
window.pendingClientDeletions = new Set();
window.pendingServerDeletions = new Set();
// Stats charts
window.statsCharts = {};
window.statsUpdateInterval = null;
window.statsData = null;
window.historicalData = null;
// Local DNS editing state
window.editLocalIndex = -1;
-73
View File
@@ -1,73 +0,0 @@
const WebSocket = require('ws');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
const adminWss = new WebSocket.Server({ noServer: true });
const adminClients = new Set();
adminWss.on('connection', (ws) => {
logDebug('Admin', 'WebSocket client connected');
adminClients.add(ws);
// Handle close event
ws.on('close', () => {
adminClients.delete(ws);
logDebug('Admin', 'WebSocket client disconnected');
});
// Handle error event to ensure cleanup
ws.on('error', (err) => {
logError('Admin', `WebSocket error: ${err.message}`);
adminClients.delete(ws);
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
} catch (closeErr) {
logDebug('Admin', `Error closing WebSocket after error: ${closeErr.message}`);
}
});
// Handle unexpected termination
ws.on('unexpected-response', () => {
logWarn('Admin', 'WebSocket received unexpected response');
adminClients.delete(ws);
});
});
function broadcast(msg) {
for (const client of adminClients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
}
}
function closeAllWebSockets() {
logDebug('Admin', `Closing ${adminClients.size} WebSocket connections...`);
for (const client of adminClients) {
try {
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
client.close();
}
} catch (err) {
logError('Admin', `Error closing WebSocket client: ${err.message}`);
}
}
adminClients.clear();
// Close the WebSocket server
try {
adminWss.close();
logInfo('Admin', 'WebSocket server closed');
} catch (err) {
logError('Admin', `Error closing WebSocket server: ${err.message}`);
}
}
module.exports = {
adminWss,
adminClients,
broadcast,
closeAllWebSockets
};
-418
View File
@@ -1,418 +0,0 @@
// WebSocket client management
let reconnectTimeout = null;
function startPollingFallback() {
if (window.wsPollingInterval) return;
window.wsPollingInterval = setInterval(() => {
if (window.activeTab === 'host' && !window.wsConnected) {
if (window.genericFetch) {
window.genericFetch('host-servers', true);
window.genericFetch('host-clients', true);
}
}
}, 5000);
}
function stopPollingFallback() {
if (window.wsPollingInterval) {
clearInterval(window.wsPollingInterval);
window.wsPollingInterval = null;
}
}
function connectWebSocket() {
// Close existing connection if any
if (window.ws) {
try {
window.ws.onopen = null;
window.ws.onclose = null;
window.ws.onerror = null;
window.ws.onmessage = null;
if (window.ws.readyState === WebSocket.OPEN || window.ws.readyState === WebSocket.CONNECTING) {
window.ws.close();
}
} catch (err) {
console.error('Error closing existing WebSocket:', err);
}
}
// Clear any pending reconnect timeout
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
window.ws = new WebSocket('wss://' + location.host + '/ws');
window.ws.onopen = () => {
console.log('WebSocket connected');
window.wsConnected = true;
window.wsReconnectAttempts = 0;
stopPollingFallback();
if (window.updateStatus) window.updateStatus();
// Refresh data on reconnection to ensure everything is up to date
if (window.activeTab === 'host' && window.genericFetch) {
window.genericFetch('host-servers', true);
window.genericFetch('host-clients', true);
}
// Start periodic domains updates as fallback
if (window.startDomainsUpdates) {
window.startDomainsUpdates();
}
// Request domains list from server
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
window.ws.send(JSON.stringify({ type: 'request-domains' }));
}
};
window.ws.onclose = () => {
window.wsConnected = false;
// Stop periodic domains updates when disconnected
if (window.stopDomainsUpdates) {
window.stopDomainsUpdates();
}
// Clear domains data and show disconnected state when WebSocket disconnects
window.domainsData = [];
if (window.activeTab === 'domains' && window.genericFilter) {
window.genericFilter('domains');
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
return;
}
const delay = Math.min(1000 * Math.pow(2, window.wsReconnectAttempts), 30000);
window.wsReconnectAttempts++;
reconnectTimeout = setTimeout(connectWebSocket, delay);
if (window.activeTab === 'host') {
startPollingFallback();
}
};
window.ws.onerror = (err) => {
console.error('WebSocket error:', err);
window.wsConnected = false;
if (window.activeTab === 'host') {
startPollingFallback();
}
};
window.ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'update-holesail-clients') {
if (window.genericFetch) {
window.genericFetch('host-clients', window.activeTab === 'host').then(() => {
let updated = false;
for (const pendingId of [...window.pendingClientRestarts]) {
const item = window.holesailClientsData?.find(i => i.id === pendingId);
if (item && item.info.state === 'running') {
if (window.showNotification) window.showNotification('Holesail client restarted successfully');
window.pendingClientRestarts.delete(pendingId);
updated = true;
}
}
for (const pendingId of [...window.pendingClientDeletions]) {
const item = window.holesailClientsData?.find(i => i.id === pendingId);
if (!item) {
if (window.showNotification) window.showNotification('Holesail client deleted successfully');
window.pendingClientDeletions.delete(pendingId);
updated = true;
}
}
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-clients');
}
});
}
return;
}
if (data.type === 'update-holesail') {
if (window.genericFetch) {
window.genericFetch('host-servers', window.activeTab === 'host').then(() => {
let updated = false;
for (const pendingId of [...window.pendingServerRestarts]) {
const item = window.holesailServersData?.find(i => i.id === pendingId);
if (item && item.info.state === 'running') {
if (window.showNotification) window.showNotification('Holesail server restarted successfully');
window.pendingServerRestarts.delete(pendingId);
updated = true;
}
}
for (const pendingId of [...window.pendingServerDeletions]) {
const item = window.holesailServersData?.find(i => i.id === pendingId);
if (!item) {
if (window.showNotification) window.showNotification('Holesail server deleted successfully');
window.pendingServerDeletions.delete(pendingId);
updated = true;
}
}
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
window.genericRenderPaginated('host-servers');
}
});
}
return;
}
if (data.type === 'update-stats' && window.activeTab === 'stats') {
if (window.renderStats) window.renderStats();
return;
}
if (data.type === 'update-settings' && window.activeTab === 'settings') {
if (window.fetchSubnets) window.fetchSubnets();
if (window.genericFetch) window.genericFetch('settings', true);
return;
}
if (data.type === 'domains-list') {
// Update domains data directly from WebSocket
if (data.domains) {
window.domainsData = data.domains;
// Reset pagination state when receiving fresh WebSocket data
if (window.paginationState && window.paginationState['domains']) {
window.paginationState['domains'].current = 1;
}
// Render if domains tab is active
if (window.activeTab === 'domains' && window.genericFilter) {
window.genericFilter('domains');
}
}
return;
}
if (window.updateMap && window.updateMap[data.type]) {
if (typeof window.updateMap[data.type] === 'function') {
window.updateMap[data.type]();
} else {
window.updateMap[data.type].forEach(tab => {
// Always fetch data in background, but only render if tab is active
// For domains tab, always render when active to ensure real-time updates
const shouldRender = tab === 'domains' ? window.activeTab === 'domains' : (window.activeTab === 'host' || window.activeTab === tab);
if (window.genericFetch) {
window.genericFetch(tab, shouldRender);
}
});
}
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
window.applyLogSnapshot(data);
} else if (data.type === 'file-log' && window.applyFileLog) {
window.applyFileLog(data);
} else if (data.type === 'log') {
if (window.applyFileLog) {
window.applyFileLog({ channel: 'core', level: data.level, message: data.message });
}
} else if (data.type === 'holesail-log') {
let buffer = window.holesailLogBuffers.get(data.id) || [];
buffer.push(`[${data.level.toUpperCase()}] ${data.message}`);
if (buffer.length > window.maxLogLines) buffer.shift();
window.holesailLogBuffers.set(data.id, buffer);
if (window.currentOpenHolesailId === data.id && window.holesailTerm) {
window.holesailTerm.writeln(`[${data.level.toUpperCase()}] ${data.message}`);
}
}
};
}
function cleanupWebSocket() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (window.ws) {
try {
window.ws.onopen = null;
window.ws.onclose = null;
window.ws.onerror = null;
window.ws.onmessage = null;
if (window.ws.readyState === WebSocket.OPEN || window.ws.readyState === WebSocket.CONNECTING) {
window.ws.close();
}
} catch (err) {
console.error('Error closing WebSocket:', err);
}
window.ws = null;
}
window.wsConnected = false;
}
async function updateStatus() {
try {
const res = await fetch('/api/status');
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json();
let text;
let color = 'bg-blue-600';
if (data.isShuttingDown) {
text = 'Gracefully Cleaning...';
color = 'bg-orange-500';
} else if (data.isMaster) {
text = `This is Master • Peers: ${data.peersCount}`;
} else {
if (data.isConnected) {
text = `Connected to Master • Peers: ${data.peersCount}`;
color = 'bg-blue-500';
} else {
if (data.peersCount > 0) {
text = 'Requesting access...';
color = 'bg-blue-300';
} else {
text = 'Searching for peers...';
color = 'bg-blue-300';
}
}
}
if (!window.wsConnected) {
text += ' (Polling)';
color = 'bg-yellow-500';
}
const indicator = document.getElementById('status-indicator');
if (indicator) {
indicator.textContent = text;
indicator.className = `fixed top-4 right-4 px-4 py-2 ${color} text-white rounded-lg shadow-md`;
}
} catch (err) {
console.error('Failed to fetch status:', err);
const indicator = document.getElementById('status-indicator');
if (indicator) {
indicator.textContent = 'Status unknown';
indicator.className = 'fixed top-4 right-4 px-4 py-2 bg-blue-900 text-white rounded-lg shadow-md';
}
}
}
function startStatusUpdates() {
if (window.statusUpdateInterval) {
clearInterval(window.statusUpdateInterval);
}
window.statusUpdateInterval = setInterval(updateStatus, 5000);
}
function stopStatusUpdates() {
if (window.statusUpdateInterval) {
clearInterval(window.statusUpdateInterval);
window.statusUpdateInterval = null;
}
}
function startDomainsUpdates() {
if (window.domainsUpdateInterval) {
clearInterval(window.domainsUpdateInterval);
}
// Refresh domains data every 30 seconds as fallback for missed WebSocket updates
window.domainsUpdateInterval = setInterval(() => {
if (window.wsConnected && window.genericFetch) {
window.genericFetch('domains', window.activeTab === 'domains');
}
}, 30000);
}
function stopDomainsUpdates() {
if (window.domainsUpdateInterval) {
clearInterval(window.domainsUpdateInterval);
window.domainsUpdateInterval = null;
}
}
async function handleRefresh() {
if (window.ConfirmationModal) {
const confirmed = await window.ConfirmationModal.warning(
'Are you sure you want to gracefully stop the process? This will shut down the system without cleaning my-storage.',
{
title: 'Graceful Shutdown',
confirmText: 'Shutdown',
cancelText: 'Cancel'
}
);
if (!confirmed) {
return;
}
await performShutdown();
} else if (window.showConfirm) {
// Fallback to showConfirm if ConfirmationModal not available
window.showConfirm(
'Are you sure you want to gracefully stop the process? This will shut down the system without cleaning my-storage.',
async () => {
await performShutdown();
}
);
} else {
// Final fallback to native confirm
if (!confirm('Are you sure you want to gracefully stop the process? This will shut down the system without cleaning my-storage.')) {
return;
}
await performShutdown();
}
}
async function performShutdown() {
try {
const res = await fetch('/api/shutdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
if (window.showNotification) {
window.showNotification(data.message || 'Graceful shutdown initiated', 'info');
}
} else {
const errorText = await res.text();
if (window.showNotification) {
window.showNotification('Failed to initiate shutdown: ' + errorText, 'error');
}
}
} catch (err) {
console.error('Failed to call shutdown endpoint:', err);
if (window.showNotification) {
window.showNotification('Failed to initiate shutdown: ' + err.message, 'error');
}
}
}
// Make functions globally accessible
window.connectWebSocket = connectWebSocket;
window.startPollingFallback = startPollingFallback;
window.stopPollingFallback = stopPollingFallback;
window.cleanupWebSocket = cleanupWebSocket;
window.updateStatus = updateStatus;
window.startStatusUpdates = startStatusUpdates;
window.stopStatusUpdates = stopStatusUpdates;
window.startDomainsUpdates = startDomainsUpdates;
window.stopDomainsUpdates = stopDomainsUpdates;
window.handleRefresh = handleRefresh;
// Initialize WebSocket connection
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connectWebSocket);
} else {
connectWebSocket();
}
+3 -1
View File
@@ -82,7 +82,9 @@ function enqueueDnsPass(operation) {
}); });
chain = run.then( chain = run.then(
() => {}, () => {},
() => {} (err) => {
logDebug('DNSPassQueue', `Serialized operation failed: ${err.message}`);
}
); );
return run; return run;
} }
+58
View File
@@ -0,0 +1,58 @@
const { createErrorResponse } = require('./error_handler');
/**
* Read JSON body from an incoming HTTP request.
* @param {import('http').IncomingMessage} req
* @returns {Promise<object>}
*/
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
if (!body) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch (err) {
reject(new Error('Invalid JSON body'));
}
});
req.on('error', reject);
});
}
/**
* Read JSON body or send a 400 response.
* @returns {Promise<object|null>} Parsed body, or null if error response was sent.
*/
async function readJsonBodyOrError(req, res) {
try {
return await readJsonBody(req);
} catch (err) {
sendJson(res, 400, { error: 'Bad Request', message: err.message });
return null;
}
}
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function sendError(res, err, statusCode = 500) {
const errorResponse = createErrorResponse(err, statusCode);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
module.exports = {
readJsonBody,
readJsonBodyOrError,
sendJson,
sendError
};
+180
View File
@@ -0,0 +1,180 @@
const { logDebug, logError, logInfo, logWarn } = require('./logger');
/**
* Shutdown timing configuration.
* SHUTDOWN_MODE=fast uses shorter timeouts and skips optional waits.
*/
function getShutdownConfig() {
const fast = process.env.SHUTDOWN_MODE === 'fast';
const int = (name, fastDefault, normalDefault) =>
parseInt(process.env[name] || String(fast ? fastDefault : normalDefault), 10);
return {
fast,
serverCloseTimeoutMs: int('SHUTDOWN_SERVER_TIMEOUT_MS', 1000, 5000),
serverSettleMs: int('SHUTDOWN_SETTLE_MS', 0, 2000),
replicationSettleMs: int('SHUTDOWN_REPLICATION_SETTLE_MS', 500, 5000),
holesailChildGraceMs: int('SHUTDOWN_HOLESAIL_CHILD_GRACE_MS', 1000, 10000),
holesailChildKillMs: int('SHUTDOWN_HOLESAIL_CHILD_KILL_MS', 2000, 5000),
inviteProcessingTimeoutMs: int('SHUTDOWN_INVITE_TIMEOUT_MS', 2000, 5000),
holesailUdpCloseTimeoutMs: int('SHUTDOWN_HOLESAIL_UDP_CLOSE_MS', 1000, 5000),
skipPortReleaseWait: fast || process.env.SHUTDOWN_SKIP_PORT_WAIT === 'true'
};
}
function sleep(ms) {
if (!ms || ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveTlsServer(tlsServer) {
if (tlsServer && typeof tlsServer.close === 'function') {
return tlsServer;
}
if (tlsServer && tlsServer.tlsServer && typeof tlsServer.tlsServer.close === 'function') {
return tlsServer.tlsServer;
}
return null;
}
function closeHttpServerGracefully(server, key, config) {
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
try {
if (config.fast) {
if (server.destroy) server.destroy();
else server.close();
finish();
return;
}
server.close(finish);
setTimeout(() => {
logWarn('Main', `Timeout closing HTTP server for ${key}, forcing closure`);
if (server.destroy) server.destroy();
else server.close();
finish();
}, config.serverCloseTimeoutMs);
} catch (err) {
logError('Main', `Error closing HTTP server for ${key}: ${err.message}`);
finish();
}
});
}
function closeTlsServerGracefully(tlsEntry, key, config) {
const server = resolveTlsServer(tlsEntry);
if (!server) {
logWarn('Main', `Invalid TLS server object for ${key}, skipping`);
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
try {
if (config.fast) {
if (server.destroy) server.destroy();
else server.close();
finish();
return;
}
server.close(finish);
setTimeout(() => {
logWarn('Main', `Timeout closing TLS server for ${key}, forcing closure`);
if (server.destroy) server.destroy();
else server.close();
finish();
}, config.serverCloseTimeoutMs);
} catch (err) {
logError('Main', `Error closing TLS server for ${key}: ${err.message}`);
finish();
}
});
}
async function closeAllHttpTlsServers(tlsServers, httpServers, config) {
logDebug('Main', 'Closing HTTP/TLS servers in parallel...');
const closeTasks = [
...Array.from(tlsServers.entries()).map(async ([key, tlsServer]) => {
await closeTlsServerGracefully(tlsServer, key, config);
logInfo('Main', `Closed TLS server for ${key}`);
}),
...Array.from(httpServers.entries()).map(async ([key, httpServer]) => {
await closeHttpServerGracefully(httpServer, key, config);
logInfo('Main', `Closed HTTP server for ${key}`);
})
];
await Promise.allSettled(closeTasks);
tlsServers.clear();
httpServers.clear();
}
function killHolesailChildProcess(child, id, config, label) {
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
try {
child.removeAllListeners();
child.disconnect();
if (config.fast) {
child.kill('SIGTERM');
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch (_) {
// already exited
}
finish();
}, config.holesailChildKillMs);
return;
}
const exitHandler = () => finish();
child.once('exit', exitHandler);
setTimeout(() => {
child.removeListener('exit', exitHandler);
child.kill('SIGTERM');
setTimeout(() => {
try {
child.kill('SIGKILL');
logWarn('Main', `Forced SIGKILL for Holesail ${label} ${id}`);
} catch (_) {
// already exited
}
finish();
}, config.holesailChildKillMs);
}, config.holesailChildGraceMs);
} catch (err) {
logError('Main', `Error killing Holesail ${label} child ${id}: ${err.message}`);
finish();
}
});
}
module.exports = {
getShutdownConfig,
sleep,
closeAllHttpTlsServers,
killHolesailChildProcess
};
+25
View File
@@ -201,8 +201,33 @@ function validateHolesailClient(data) {
return { valid: true, domain, key, port: parseInt(data.port, 10), protocol }; return { valid: true, domain, key, port: parseInt(data.port, 10), protocol };
} }
/**
* Validates a domain name with detailed error response.
* @param {string} domain - Domain name to validate
* @param {object} [options]
* @param {number} [options.maxLength=253] - Maximum domain length
* @returns {{ valid: boolean, error?: string }}
*/
function validateDomainDetailed(domain, options = {}) {
const maxLength = options.maxLength ?? 253;
if (!domain || typeof domain !== 'string') {
return { valid: false, error: 'Domain must be a non-empty string' };
}
if (domain.length > maxLength) {
return { valid: false, error: `Domain exceeds maximum length of ${maxLength}` };
}
if (domain.includes('..') || domain.includes('/') || domain.includes('\\')) {
return { valid: false, error: 'Domain contains invalid path characters' };
}
if (!validateDomain(domain)) {
return { valid: false, error: 'Domain contains invalid characters' };
}
return { valid: true };
}
module.exports = { module.exports = {
validateDomain, validateDomain,
validateDomainDetailed,
validateHolesailHash, validateHolesailHash,
validatePort, validatePort,
validateIP, validateIP,
+4
View File
@@ -334,6 +334,7 @@ function startHealthChecks() {
} }
healthCheckInterval = setInterval(async () => { healthCheckInterval = setInterval(async () => {
try {
const keysToCheck = Array.from(state.holesails.keys()); const keysToCheck = Array.from(state.holesails.keys());
if (keysToCheck.length === 0) { if (keysToCheck.length === 0) {
@@ -397,6 +398,9 @@ function startHealthChecks() {
logDebug('Holesail', `Health check passed for ${key}`); logDebug('Holesail', `Health check passed for ${key}`);
} }
} }
} catch (err) {
logError('Holesail', `Health check cycle failed: ${err.message}`);
}
}, intervalMs); }, intervalMs);
logInfo('Holesail', `Started periodic health checks (interval: ${intervalMs}ms)`); logInfo('Holesail', `Started periodic health checks (interval: ${intervalMs}ms)`);
+3 -1
View File
@@ -287,7 +287,9 @@ function createPluginChannelsForPeer(peerId, conn, mux) {
logError('ChannelManager', `onPeerOpen error for ${fullProtocol}: ${err.message}`); logError('ChannelManager', `onPeerOpen error for ${fullProtocol}: ${err.message}`);
} }
} }
}).catch(() => {}); }).catch((err) => {
logDebug('ChannelManager', `RPC open wait failed for ${fullProtocol}: ${err.message}`);
});
setTimeout(() => clearRecreating(pluginDomain, protocol, peerId), 500); setTimeout(() => clearRecreating(pluginDomain, protocol, peerId), 500);
logDebug('ChannelManager', `RPC ${fullProtocol}-rpc attached for peer ${peerId.substring(0, 16)}...`); logDebug('ChannelManager', `RPC ${fullProtocol}-rpc attached for peer ${peerId.substring(0, 16)}...`);
+3 -4
View File
@@ -773,7 +773,7 @@ async function handlePluginRequest(domain, req, res) {
// Serve sdk-utils.js globally to all plugins // Serve sdk-utils.js globally to all plugins
if (urlPath === '/sdk-utils.js' || urlPath.endsWith('/sdk-utils.js')) { if (urlPath === '/sdk-utils.js' || urlPath.endsWith('/sdk-utils.js')) {
try { try {
const sdkUtilsPath = path.join(__dirname, '..', 'admin', 'admin-frontend', 'utils.js'); const sdkUtilsPath = path.join(__dirname, '..', 'shared', 'browser-utils.js');
const sdkUtilsContent = await fs.readFile(sdkUtilsPath, 'utf8'); const sdkUtilsContent = await fs.readFile(sdkUtilsPath, 'utf8');
res.writeHead(200, { res.writeHead(200, {
@@ -1039,9 +1039,8 @@ async function serveStaticFiles(domain, req, res) {
*/ */
async function shutdownAllPlugins() { async function shutdownAllPlugins() {
logInfo('PluginHandler', 'Shutting down all plugins...'); logInfo('PluginHandler', 'Shutting down all plugins...');
for (const plugin of plugins.values()) { const pluginList = [...plugins.values()];
await shutdownPlugin(plugin); await Promise.allSettled(pluginList.map((plugin) => shutdownPlugin(plugin)));
}
plugins.clear(); plugins.clear();
// Ensure all databases are closed (safety cleanup) // Ensure all databases are closed (safety cleanup)
+3 -1
View File
@@ -81,7 +81,9 @@ function enqueueDbWrite(pluginDomain, fn) {
} }
}; };
const next = prev.then(run, run); const next = prev.then(run, run);
dbWriteQueues.set(pluginDomain, next.catch(() => {})); dbWriteQueues.set(pluginDomain, next.catch((err) => {
logDebug('PluginSDK', `DB write queue error for ${pluginDomain}: ${err.message}`);
}));
return next; return next;
} }
@@ -1,6 +1,6 @@
/** /**
* P2NS Admin Panel - Utilities * P2NS Browser Utilities
* Consolidated utilities matching the P2NS Plugin SDK * Shared formatting, DOM, and status helpers for admin UI and plugins.
*/ */
// Initialize sdk global if not present // Initialize sdk global if not present
@@ -11,15 +11,12 @@ window.sdk.utils = window.sdk.utils || {};
* Formatting Utilities * Formatting Utilities
*/ */
window.sdk.utils.format = { window.sdk.utils.format = {
/**
* Format a timestamp to human-readable relative time
*/
formatTimestamp(timestamp) { formatTimestamp(timestamp) {
if (!timestamp) return 'Never'; if (!timestamp) return 'Never';
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
const diff = now - date; const diff = now - date;
if (diff < 60000) { if (diff < 60000) {
return `${Math.floor(diff / 1000)}s ago`; return `${Math.floor(diff / 1000)}s ago`;
} else if (diff < 3600000) { } else if (diff < 3600000) {
@@ -31,9 +28,6 @@ window.sdk.utils.format = {
} }
}, },
/**
* Format a peer ID to shortened version
*/
formatPeerId(peerId, short = true) { formatPeerId(peerId, short = true) {
if (!peerId) return 'N/A'; if (!peerId) return 'N/A';
if (!short) return peerId; if (!short) return peerId;
@@ -41,18 +35,12 @@ window.sdk.utils.format = {
return `${peerId.slice(0, 8)}...${peerId.slice(-8)}`; return `${peerId.slice(0, 8)}...${peerId.slice(-8)}`;
}, },
/**
* Format a hash to shortened version
*/
formatHash(hash) { formatHash(hash) {
if (!hash) return 'N/A'; if (!hash) return 'N/A';
if (hash.length <= 20) return hash; if (hash.length <= 20) return hash;
return `${hash.slice(0, 10)}...${hash.slice(-10)}`; return `${hash.slice(0, 10)}...${hash.slice(-10)}`;
}, },
/**
* Format duration (milliseconds to readable string)
*/
formatDuration(ms) { formatDuration(ms) {
if (!ms || ms === 0) return '-'; if (!ms || ms === 0) return '-';
if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 1000) return `${Math.round(ms)}ms`;
@@ -61,25 +49,19 @@ window.sdk.utils.format = {
return `${(ms / 3600000).toFixed(2)}h`; return `${(ms / 3600000).toFixed(2)}h`;
}, },
/**
* Format uptime (milliseconds to readable string)
*/
formatUptime(ms) { formatUptime(ms) {
if (!ms || ms === 0) return '0s'; if (!ms || ms === 0) return '0s';
const days = Math.floor(ms / 86400000); const days = Math.floor(ms / 86400000);
const hours = Math.floor((ms % 86400000) / 3600000); const hours = Math.floor((ms % 86400000) / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000); const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000); const seconds = Math.floor((ms % 60000) / 1000);
if (days > 0) return `${days}d ${hours}h ${minutes}m`; if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
if (minutes > 0) return `${minutes}m ${seconds}s`; if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`; return `${seconds}s`;
}, },
/**
* Format bytes to human readable string
*/
formatBytes(bytes) { formatBytes(bytes) {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
if (!bytes) return 'N/A'; if (!bytes) return 'N/A';
@@ -89,17 +71,11 @@ window.sdk.utils.format = {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}, },
/**
* Format number with commas
*/
formatNumber(num) { formatNumber(num) {
if (num === null || num === undefined) return '0'; if (num === null || num === undefined) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}, },
/**
* Format CPU usage
*/
formatCPUUsage(cpuUsage, uptime) { formatCPUUsage(cpuUsage, uptime) {
if (!cpuUsage) return 'N/A'; if (!cpuUsage) return 'N/A';
if (typeof cpuUsage.percentage === 'number') { if (typeof cpuUsage.percentage === 'number') {
@@ -117,9 +93,6 @@ window.sdk.utils.format = {
return 'N/A'; return 'N/A';
}, },
/**
* Format memory usage
*/
formatMemoryUsage(memoryUsage) { formatMemoryUsage(memoryUsage) {
if (!memoryUsage) return 'N/A'; if (!memoryUsage) return 'N/A';
const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2); const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2);
@@ -128,9 +101,6 @@ window.sdk.utils.format = {
return `${rssMB} MB RSS (${heapUsedMB}/${heapTotalMB} MB heap)`; return `${rssMB} MB RSS (${heapUsedMB}/${heapTotalMB} MB heap)`;
}, },
/**
* Format Holesail hash for display
*/
formatHolesailHash(hash) { formatHolesailHash(hash) {
if (!hash) return 'none'; if (!hash) return 'none';
if (hash.startsWith('hs://')) return hash; if (hash.startsWith('hs://')) return hash;
@@ -149,6 +119,10 @@ window.sdk.utils.dom = {
return div.innerHTML; return div.innerHTML;
}, },
escapeJsAttr(text) {
return String(text || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
},
async copyToClipboard(text) { async copyToClipboard(text) {
try { try {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -229,9 +203,6 @@ window.sdk.utils.status = {
} }
}, },
/**
* Get display text for a consensus status
*/
getConsensusText(status) { getConsensusText(status) {
switch (status) { switch (status) {
case 'resolved': return 'Resolved'; case 'resolved': return 'Resolved';
@@ -244,9 +215,6 @@ window.sdk.utils.status = {
} }
}, },
/**
* Get hex color for a consensus status
*/
getConsensusColor(status) { getConsensusColor(status) {
switch (status) { switch (status) {
case 'resolved': return '#10b981'; case 'resolved': return '#10b981';
@@ -271,7 +239,6 @@ window.formatDuration = window.sdk.utils.format.formatDuration;
window.formatCPUUsage = window.sdk.utils.format.formatCPUUsage; window.formatCPUUsage = window.sdk.utils.format.formatCPUUsage;
window.formatMemoryUsage = window.sdk.utils.format.formatMemoryUsage; window.formatMemoryUsage = window.sdk.utils.format.formatMemoryUsage;
// Default chart colors if not defined
window.chartColors = window.chartColors || { window.chartColors = window.chartColors || {
primary: 'rgb(59, 130, 246)', primary: 'rgb(59, 130, 246)',
success: 'rgb(34, 197, 94)', success: 'rgb(34, 197, 94)',
+82 -179
View File
@@ -27,6 +27,7 @@ const { cleanupInterfaces, waitForPortRelease } = require('./includes/maintenanc
const { createInterfaceForDomain } = require('./includes/networking/virtual_interfaces'); const { createInterfaceForDomain } = require('./includes/networking/virtual_interfaces');
const { bindDnsServer } = require('./includes/networking/dns'); const { bindDnsServer } = require('./includes/networking/dns');
const { setupCache, secondsToMs, loadOrCreateKeypair, getPersistentPublicKey } = require('./includes/infrastructure/utils'); const { setupCache, secondsToMs, loadOrCreateKeypair, getPersistentPublicKey } = require('./includes/infrastructure/utils');
const { getShutdownConfig, sleep, closeAllHttpTlsServers, killHolesailChildProcess } = require('./includes/infrastructure/shutdown-utils');
const state = require('./includes/infrastructure/state'); const state = require('./includes/infrastructure/state');
const { broadcast, loadHolesailServers, startHolesailServer, saveHolesailServers, loadHolesailClients, saveHolesailClients, loadBlockedPeers, loadPeerMetrics, savePeerMetrics, loadPeerHistory, savePeerHistory } = require('./includes/admin'); const { broadcast, loadHolesailServers, startHolesailServer, saveHolesailServers, loadHolesailClients, saveHolesailClients, loadBlockedPeers, loadPeerMetrics, savePeerMetrics, loadPeerHistory, savePeerHistory } = require('./includes/admin');
const { dnsPool } = require('./includes/networking/dns_pool'); const { dnsPool } = require('./includes/networking/dns_pool');
@@ -788,6 +789,18 @@ async function main() {
// consecutiveInviteFailures is now tracked in state object // consecutiveInviteFailures is now tracked in state object
state.connectedPeers = connectedPeers; state.connectedPeers = connectedPeers;
state.peerChannels = peerChannels; state.peerChannels = peerChannels;
function prunePeerTrackingMaps() {
const maxTracked = parseInt(process.env.MAX_TRACKED_PEERS || '500', 10);
if (!state.peerHistory || state.peerHistory.size <= maxTracked) return;
for (const trackedPeerId of [...state.peerHistory.keys()]) {
if (!connectedPeers.has(trackedPeerId)) {
state.peerHistory.delete(trackedPeerId);
state.peerMetrics.delete(trackedPeerId);
}
if (state.peerHistory.size <= maxTracked) break;
}
}
// Register core p2ns channels using channel-manager (like plugins do) // Register core p2ns channels using channel-manager (like plugins do)
// This makes them visible in the UI and uses the proven SDK code // This makes them visible in the UI and uses the proven SDK code
@@ -1097,8 +1110,12 @@ async function main() {
const maxRetryAttempts = parseInt(process.env.MAX_INVITE_RETRY_ATTEMPTS || '20', 10); const maxRetryAttempts = parseInt(process.env.MAX_INVITE_RETRY_ATTEMPTS || '20', 10);
logInfo('Swarm', `Starting persistent invite retry loop (interval: ${retryIntervalMs}ms, relay after ${relayAfterAttempts} attempts, max attempts: ${maxRetryAttempts})`); logInfo('Swarm', `Starting persistent invite retry loop (interval: ${retryIntervalMs}ms, relay after ${relayAfterAttempts} attempts, max attempts: ${maxRetryAttempts})`);
let inviteRetryInFlight = false;
persistentInviteRetryInterval = setInterval(() => { persistentInviteRetryInterval = setInterval(() => {
if (inviteRetryInFlight) return;
inviteRetryInFlight = true;
void (async () => { void (async () => {
try {
const pass = getDnsPass(); const pass = getDnsPass();
if (pass) { if (pass) {
// Got invite, stop the loop // Got invite, stop the loop
@@ -1153,6 +1170,11 @@ async function main() {
} }
} }
} }
} catch (err) {
logError('Swarm', `Persistent invite retry failed: ${err.message}`);
} finally {
inviteRetryInFlight = false;
}
})(); })();
}, retryIntervalMs); }, retryIntervalMs);
@@ -2099,6 +2121,7 @@ async function main() {
// Remove from start times // Remove from start times
state.peerStartTimes.delete(peerId); state.peerStartTimes.delete(peerId);
prunePeerTrackingMaps();
// Handle plugin channel disconnection // Handle plugin channel disconnection
try { try {
@@ -2264,6 +2287,7 @@ async function main() {
} }
state.peerStartTimes.delete(peerId); state.peerStartTimes.delete(peerId);
prunePeerTrackingMaps();
trackPeerEvent('disconnect', peerId); trackPeerEvent('disconnect', peerId);
broadcast({ type: 'update-peers' }); broadcast({ type: 'update-peers' });
broadcast({ type: 'update-stats' }); broadcast({ type: 'update-stats' });
@@ -2317,6 +2341,7 @@ async function main() {
} }
state.peerStartTimes.delete(peerId); state.peerStartTimes.delete(peerId);
prunePeerTrackingMaps();
connectedPeers.delete(peerId); connectedPeers.delete(peerId);
peerChannels.delete(peerId); peerChannels.delete(peerId);
@@ -2581,6 +2606,11 @@ async function main() {
state.isShuttingDown = true; // Make shutdown status available to other modules state.isShuttingDown = true; // Make shutdown status available to other modules
logInfo('Main', 'Shutting down gracefully...'); logInfo('Main', 'Shutting down gracefully...');
const shutdownConfig = getShutdownConfig();
if (shutdownConfig.fast) {
logInfo('Main', 'Fast shutdown mode enabled (SHUTDOWN_MODE=fast)');
}
// Start periodic "system is shutting down" messages every 3 seconds // Start periodic "system is shutting down" messages every 3 seconds
let shutdownMessageInterval = setInterval(() => { let shutdownMessageInterval = setInterval(() => {
logInfo('Main', '🔄 SYSTEM IS SHUTTING DOWN - Please wait for graceful cleanup to complete...'); logInfo('Main', '🔄 SYSTEM IS SHUTTING DOWN - Please wait for graceful cleanup to complete...');
@@ -2714,56 +2744,7 @@ async function main() {
// PHASE 3.5: Close HTTP/TLS servers IMMEDIATELY to prevent new requests // PHASE 3.5: Close HTTP/TLS servers IMMEDIATELY to prevent new requests
// ======================================================================= // =======================================================================
logDebug('Main', 'Closing HTTP/TLS servers to prevent new requests during shutdown...'); await closeAllHttpTlsServers(state.tlsServers, state.httpServers, shutdownConfig);
// Close TLS and HTTP servers first to prevent admin routes from accessing corestore
for (const [key, tlsServer] of state.tlsServers) {
try {
// Handle case where tlsServer might be an object with tlsServer property
const server = tlsServer && typeof tlsServer.close === 'function'
? tlsServer
: (tlsServer && tlsServer.tlsServer ? tlsServer.tlsServer : null);
if (!server) {
logWarn('Main', `Invalid TLS server object for ${key}, skipping`);
continue;
}
await new Promise(resolve => {
server.close(resolve);
setTimeout(() => {
logWarn('Main', `Timeout closing TLS server for ${key}, forcing closure`);
if (server.destroy) {
server.destroy();
} else {
server.close();
}
resolve();
}, 5000); // Shorter timeout since we're in early shutdown
});
logInfo('Main', `Closed TLS server for ${key}`);
} catch (err) {
logError('Main', `Error closing TLS server for ${key}: ${err.message}`);
}
}
state.tlsServers.clear();
for (const [key, httpServer] of state.httpServers) {
try {
await new Promise(resolve => {
httpServer.close(resolve);
setTimeout(() => {
logWarn('Main', `Timeout closing HTTP server for ${key}, forcing closure`);
httpServer.destroy ? httpServer.destroy() : httpServer.close();
resolve();
}, 5000); // Shorter timeout since we're in early shutdown
});
logInfo('Main', `Closed HTTP server for ${key}`);
} catch (err) {
logError('Main', `Error closing HTTP server for ${key}: ${err.message}`);
}
}
state.httpServers.clear();
// Close DNS server to prevent DNS queries during shutdown // Close DNS server to prevent DNS queries during shutdown
const { closeDnsServer, stopDNSCacheCleanup } = require('./includes/networking/dns'); const { closeDnsServer, stopDNSCacheCleanup } = require('./includes/networking/dns');
@@ -2776,9 +2757,11 @@ async function main() {
logError('Main', `Error closing DNS server: ${err.message}`); logError('Main', `Error closing DNS server: ${err.message}`);
} }
// Additional delay to ensure all server operations have fully settled // Brief delay to let in-flight server operations finish
logDebug('Main', 'Waiting for server operations to fully settle...'); if (shutdownConfig.serverSettleMs > 0) {
await new Promise(resolve => setTimeout(resolve, 2000)); logDebug('Main', `Waiting ${shutdownConfig.serverSettleMs}ms for server operations to settle...`);
await sleep(shutdownConfig.serverSettleMs);
}
// Remove swarm event listeners to prevent memory leaks // Remove swarm event listeners to prevent memory leaks
logDebug('Main', 'Removing swarm event listeners...'); logDebug('Main', 'Removing swarm event listeners...');
@@ -2805,60 +2788,6 @@ async function main() {
} catch (err) { } catch (err) {
logError('Main', `Error destroying swarm: ${err.message}`); logError('Main', `Error destroying swarm: ${err.message}`);
} }
// =======================================================================
// PHASE 3.5: Close HTTP/TLS servers IMMEDIATELY to prevent new requests
// =======================================================================
logDebug('Main', 'Closing HTTP/TLS servers to prevent new requests during shutdown...');
// Close TLS and HTTP servers first to prevent admin routes from accessing corestore
for (const [key, tlsServer] of state.tlsServers) {
try {
// Handle case where tlsServer might be an object with tlsServer property
const server = tlsServer && typeof tlsServer.close === 'function'
? tlsServer
: (tlsServer && tlsServer.tlsServer ? tlsServer.tlsServer : null);
if (!server) {
logWarn('Main', `Invalid TLS server object for ${key}, skipping`);
continue;
}
await new Promise(resolve => {
server.close(resolve);
setTimeout(() => {
logWarn('Main', `Timeout closing TLS server for ${key}, forcing closure`);
if (server.destroy) {
server.destroy();
} else {
server.close();
}
resolve();
}, 5000); // Shorter timeout since we're in early shutdown
});
logInfo('Main', `Closed TLS server for ${key}`);
} catch (err) {
logError('Main', `Error closing TLS server for ${key}: ${err.message}`);
}
}
state.tlsServers.clear();
for (const [key, httpServer] of state.httpServers) {
try {
await new Promise(resolve => {
httpServer.close(resolve);
setTimeout(() => {
logWarn('Main', `Timeout closing HTTP server for ${key}, forcing closure`);
httpServer.destroy ? httpServer.destroy() : httpServer.close();
resolve();
}, 5000); // Shorter timeout since we're in early shutdown
});
logInfo('Main', `Closed HTTP server for ${key}`);
} catch (err) {
logError('Main', `Error closing HTTP server for ${key}: ${err.message}`);
}
}
state.httpServers.clear();
// ======================================================================= // =======================================================================
// PHASE 4: Stop background services that might access corestore // PHASE 4: Stop background services that might access corestore
@@ -2914,27 +2843,31 @@ async function main() {
// PHASE 6: Cleanup replication managers (they use corestore) // PHASE 6: Cleanup replication managers (they use corestore)
// ======================================================================= // =======================================================================
const replicationCleanupTasks = [];
if (state.replicationManager) { if (state.replicationManager) {
logDebug('Main', 'Cleaning up replication manager...'); replicationCleanupTasks.push(
try { state.replicationManager.cleanupAll().catch((err) => {
await state.replicationManager.cleanupAll(); logWarn('Main', `Error cleaning up replication manager: ${err.message}`);
} catch (err) { })
logWarn('Main', `Error cleaning up replication manager: ${err.message}`); );
}
} }
if (state.driveReplicationManager) { if (state.driveReplicationManager) {
logDebug('Main', 'Cleaning up drive replication manager...'); replicationCleanupTasks.push(
try { state.driveReplicationManager.cleanupAll().catch((err) => {
await state.driveReplicationManager.cleanupAll(); logWarn('Main', `Error cleaning up drive replication manager: ${err.message}`);
} catch (err) { })
logWarn('Main', `Error cleaning up drive replication manager: ${err.message}`); );
} }
if (replicationCleanupTasks.length > 0) {
logDebug('Main', 'Cleaning up replication managers in parallel...');
await Promise.allSettled(replicationCleanupTasks);
} }
// Wait for replication cleanup to fully settle before closing corestore // Wait for replication cleanup to fully settle before closing corestore
// This prevents "Corestore is closed" errors from lingering replication operations if (shutdownConfig.replicationSettleMs > 0) {
logDebug('Main', 'Waiting for replication operations to settle...'); logDebug('Main', `Waiting ${shutdownConfig.replicationSettleMs}ms for replication operations to settle...`);
await new Promise(resolve => setTimeout(resolve, 5000)); await sleep(shutdownConfig.replicationSettleMs);
}
// ======================================================================= // =======================================================================
// PHASE 7: Close all plugin drives (before closing dnsPass) // PHASE 7: Close all plugin drives (before closing dnsPass)
@@ -2963,7 +2896,7 @@ async function main() {
if (currentInvitePromise) { if (currentInvitePromise) {
try { try {
logDebug('Main', 'Waiting for current invite processing to complete...'); logDebug('Main', 'Waiting for current invite processing to complete...');
await withTimeout(currentInvitePromise, 5000, 'Current invite processing shutdown'); await withTimeout(currentInvitePromise, shutdownConfig.inviteProcessingTimeoutMs, 'Current invite processing shutdown');
logDebug('Main', 'Current invite processing completed during shutdown'); logDebug('Main', 'Current invite processing completed during shutdown');
} catch (err) { } catch (err) {
logWarn('Main', `Current invite processing did not complete during shutdown: ${err.message}`); logWarn('Main', `Current invite processing did not complete during shutdown: ${err.message}`);
@@ -3035,57 +2968,20 @@ async function main() {
// ======================================================================= // =======================================================================
// Close Holesail servers and clients (in parallel) // Close Holesail servers and clients (in parallel)
const serverChildKillPromises = Array.from(state.holesailChildren.entries()).map(async ([id, child]) => { const holesailChildKillPromises = [
try { ...Array.from(state.holesailChildren.entries()).map(async ([id, child]) => {
// Remove all event listeners before killing await killHolesailChildProcess(child, id, shutdownConfig, 'server');
child.removeAllListeners();
child.disconnect();
await new Promise(resolve => {
const exitHandler = () => resolve();
child.once('exit', exitHandler);
setTimeout(() => {
child.removeListener('exit', exitHandler);
child.kill('SIGTERM');
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Main', `Forced SIGKILL for Holesail child ${id}`);
resolve();
}, 5000);
}, 10000);
});
logInfo('Main', `Killed child process for server ${id}`); logInfo('Main', `Killed child process for server ${id}`);
} catch (err) { }),
logError('Main', `Error killing child for server ${id}: ${err.message}`); ...Array.from(state.holesailClientChildren.entries()).map(async ([id, child]) => {
} await killHolesailChildProcess(child, id, shutdownConfig, 'client');
}); logInfo('Main', `Killed child process for client ${id}`);
await Promise.allSettled(serverChildKillPromises); })
];
await Promise.allSettled(holesailChildKillPromises);
state.holesailChildren.clear(); state.holesailChildren.clear();
state.holesailOpts.clear(); state.holesailOpts.clear();
state.holesailInfos.clear(); state.holesailInfos.clear();
const clientChildKillPromises = Array.from(state.holesailClientChildren.entries()).map(async ([id, child]) => {
try {
// Remove all event listeners before killing
child.removeAllListeners();
child.disconnect();
await new Promise(resolve => {
const exitHandler = () => resolve();
child.once('exit', exitHandler);
setTimeout(() => {
child.removeListener('exit', exitHandler);
child.kill('SIGTERM');
setTimeout(() => {
child.kill('SIGKILL');
logWarn('Main', `Forced SIGKILL for Holesail client child ${id}`);
resolve();
}, 5000);
}, 10000);
});
logInfo('Main', `Killed child process for client ${id}`);
} catch (err) {
logError('Main', `Error killing child for client ${id}: ${err.message}`);
}
});
await Promise.allSettled(clientChildKillPromises);
state.holesailClientChildren.clear(); state.holesailClientChildren.clear();
state.holesailClientOpts.clear(); state.holesailClientOpts.clear();
state.holesailClientInfos.clear(); state.holesailClientInfos.clear();
@@ -3102,18 +2998,20 @@ async function main() {
logWarn('Main', `Timeout closing UDP Holesail for ${key}, forcing closure`); logWarn('Main', `Timeout closing UDP Holesail for ${key}, forcing closure`);
holesail.close(); holesail.close();
resolve(); resolve();
}, 5000); }, shutdownConfig.holesailUdpCloseTimeoutMs);
}); });
} else { } else {
await holesail.close(); await holesail.close();
logInfo('Main', `Closed TCP Holesail client connection for ${key}`); logInfo('Main', `Closed TCP Holesail client connection for ${key}`);
} }
const [domain, port] = key.split(':'); if (!shutdownConfig.skipPortReleaseWait) {
const ip = state.domainToIPMap.get(domain); const [domain, port] = key.split(':');
if (ip && port) { const ip = state.domainToIPMap.get(domain);
const isPortFree = await waitForPortRelease(ip, parseInt(port)); if (ip && port) {
if (!isPortFree) { const isPortFree = await waitForPortRelease(ip, parseInt(port));
logError('Main', `Port ${port} on ${ip} for ${domain} still in use after cleanup`); if (!isPortFree) {
logError('Main', `Port ${port} on ${ip} for ${domain} still in use after cleanup`);
}
} }
} }
} catch (err) { } catch (err) {
@@ -3187,6 +3085,7 @@ async function main() {
// DNS server is closed last to ensure public DNS continues to respond during shutdown // DNS server is closed last to ensure public DNS continues to respond during shutdown
process.exit(0); process.exit(0);
}; };
state.gracefulShutdown = cleanup;
process.on('SIGINT', cleanup); process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup); process.on('SIGTERM', cleanup);
process.on('SIGQUIT', cleanup); process.on('SIGQUIT', cleanup);
@@ -3214,7 +3113,9 @@ async function main() {
return; return;
} }
logError('Main', `Uncaught exception: ${msg}`); logError('Main', `Uncaught exception: ${msg}`);
// Do not call cleanup() to prevent automatic shutdown if (process.env.EXIT_ON_FATAL_ERROR === 'true' && state.gracefulShutdown) {
void state.gracefulShutdown();
}
}); });
// Handle unhandled promise rejections // Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => { process.on('unhandledRejection', (reason, promise) => {
@@ -3224,7 +3125,9 @@ async function main() {
return; return;
} }
logError('Main', `Unhandled rejection at: ${promise} reason: ${reason}`); logError('Main', `Unhandled rejection at: ${promise} reason: ${reason}`);
// Do not call cleanup() to prevent automatic shutdown if (process.env.EXIT_ON_FATAL_ERROR === 'true' && state.gracefulShutdown) {
void state.gracefulShutdown();
}
}); });
} catch (err) { } catch (err) {
logError('Main', `Main function error: ${err.message}`); logError('Main', `Main function error: ${err.message}`);
+33 -8
View File
@@ -17,6 +17,10 @@ let lastConsensusStates = new Map(); // Track last known consensus states for ch
let debounceTimer = null; let debounceTimer = null;
let dnsPassUpdateHandler = null; let dnsPassUpdateHandler = null;
let coreAppendHandler = null; let coreAppendHandler = null;
let domainAddedHandler = null;
let domainRemovedHandler = null;
let peerConnectedHandler = null;
let peerDisconnectedHandler = null;
/** /**
* Enrich peer IDs with profile data * Enrich peer IDs with profile data
@@ -457,7 +461,7 @@ async function onInit() {
setupWebSocketHandlers(); setupWebSocketHandlers();
// Subscribe to consensus-related events (non-blocking) // Subscribe to consensus-related events (non-blocking)
sdk.events.on('domain-added', async (data) => { domainAddedHandler = async (data) => {
try { try {
sdk.log.debug('domain.consensus', `Domain added: ${data.domain}`); sdk.log.debug('domain.consensus', `Domain added: ${data.domain}`);
// Trigger consensus check after a short delay to allow DNS to update // Trigger consensus check after a short delay to allow DNS to update
@@ -467,9 +471,10 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('domain.consensus', `Error handling domain-added event: ${err.message}`); sdk.log.error('domain.consensus', `Error handling domain-added event: ${err.message}`);
} }
}); };
sdk.events.on('domain-added', domainAddedHandler);
sdk.events.on('domain-removed', async (data) => { domainRemovedHandler = async (data) => {
try { try {
sdk.log.debug('domain.consensus', `Domain removed: ${data.domain}`); sdk.log.debug('domain.consensus', `Domain removed: ${data.domain}`);
lastConsensusStates.delete(data.domain); lastConsensusStates.delete(data.domain);
@@ -480,7 +485,8 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('domain.consensus', `Error handling domain-removed event: ${err.message}`); sdk.log.error('domain.consensus', `Error handling domain-removed event: ${err.message}`);
} }
}); };
sdk.events.on('domain-removed', domainRemovedHandler);
// Listen to DNS pass updates for real-time consensus changes // Listen to DNS pass updates for real-time consensus changes
// This is the key to making it fully live - we detect when DNS entries change // This is the key to making it fully live - we detect when DNS entries change
@@ -529,7 +535,7 @@ async function onInit() {
} }
// Listen to peer connection/disconnection events // Listen to peer connection/disconnection events
sdk.events.on('peer-connected', async (data) => { peerConnectedHandler = async (data) => {
try { try {
sdk.log.debug('domain.consensus', `Peer connected: ${data.peerId}`); sdk.log.debug('domain.consensus', `Peer connected: ${data.peerId}`);
// Peer count changed, update state // Peer count changed, update state
@@ -537,9 +543,10 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('domain.consensus', `Error handling peer-connected event: ${err.message}`); sdk.log.error('domain.consensus', `Error handling peer-connected event: ${err.message}`);
} }
}); };
sdk.events.on('peer-connected', peerConnectedHandler);
sdk.events.on('peer-disconnected', async (data) => { peerDisconnectedHandler = async (data) => {
try { try {
sdk.log.debug('domain.consensus', `Peer disconnected: ${data.peerId}`); sdk.log.debug('domain.consensus', `Peer disconnected: ${data.peerId}`);
// Peer count changed, update state // Peer count changed, update state
@@ -547,7 +554,8 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('domain.consensus', `Error handling peer-disconnected event: ${err.message}`); sdk.log.error('domain.consensus', `Error handling peer-disconnected event: ${err.message}`);
} }
}); };
sdk.events.on('peer-disconnected', peerDisconnectedHandler);
sdk.log.info('domain.consensus', 'WebSocket server initialized with real-time event listeners'); sdk.log.info('domain.consensus', 'WebSocket server initialized with real-time event listeners');
} }
@@ -569,6 +577,23 @@ async function onShutdown() {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = null; debounceTimer = null;
} }
if (domainAddedHandler) {
sdk.events.off('domain-added', domainAddedHandler);
domainAddedHandler = null;
}
if (domainRemovedHandler) {
sdk.events.off('domain-removed', domainRemovedHandler);
domainRemovedHandler = null;
}
if (peerConnectedHandler) {
sdk.events.off('peer-connected', peerConnectedHandler);
peerConnectedHandler = null;
}
if (peerDisconnectedHandler) {
sdk.events.off('peer-disconnected', peerDisconnectedHandler);
peerDisconnectedHandler = null;
}
// Remove event listeners (safely) // Remove event listeners (safely)
try { try {
@@ -155,7 +155,10 @@ class DomainDetailView {
</div> </div>
<!-- Resolved Information --> <!-- Resolved Information -->
${consensus.status === 'resolved' ? ` ${consensus.status === 'resolved' ? (() => {
const claimantAttr = String(consensus.resolvedClaimant || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const hashAttr = String(consensus.hash || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `
<div class="glass-card mb-6" id="resolved-info-section"> <div class="glass-card mb-6" id="resolved-info-section">
<h3 class="text-sm font-semibold text-secondary mb-3">Resolved Information</h3> <h3 class="text-sm font-semibold text-secondary mb-3">Resolved Information</h3>
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);"> <div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
@@ -163,7 +166,7 @@ class DomainDetailView {
<span class="text-tertiary">Resolved Claimant:</span> <span class="text-tertiary">Resolved Claimant:</span>
<div class="ml-2 inline-block" data-resolved-claimant>${this.renderPeerWithProfile(consensus.resolvedClaimant, this.data.profiles?.[consensus.resolvedClaimant] || null, 32)}</div> <div class="ml-2 inline-block" data-resolved-claimant>${this.renderPeerWithProfile(consensus.resolvedClaimant, this.data.profiles?.[consensus.resolvedClaimant] || null, 32)}</div>
<button <button
onclick="window.utils.copyToClipboard('${consensus.resolvedClaimant}').then(() => alert('Copied!'))" onclick="window.utils.copyToClipboard('${claimantAttr}').then(() => alert('Copied!'))"
class="btn btn-secondary ml-2 text-xs" class="btn btn-secondary ml-2 text-xs"
> >
Copy Copy
@@ -173,7 +176,7 @@ class DomainDetailView {
<span class="text-tertiary">Resolved Hash:</span> <span class="text-tertiary">Resolved Hash:</span>
<span class="ml-2 font-mono" data-resolved-hash>${window.utils.formatHash(consensus.hash)}</span> <span class="ml-2 font-mono" data-resolved-hash>${window.utils.formatHash(consensus.hash)}</span>
<button <button
onclick="window.utils.copyToClipboard('${consensus.hash}').then(() => alert('Copied!'))" onclick="window.utils.copyToClipboard('${hashAttr}').then(() => alert('Copied!'))"
class="btn btn-secondary ml-2 text-xs" class="btn btn-secondary ml-2 text-xs"
> >
Copy Copy
@@ -181,7 +184,8 @@ class DomainDetailView {
</div> </div>
</div> </div>
</div> </div>
` : ''} `;
})() : ''}
<!-- Vote Distribution Chart --> <!-- Vote Distribution Chart -->
${this.getVoteCounts().length > 0 ? ` ${this.getVoteCounts().length > 0 ? `
+19 -5
View File
@@ -14,7 +14,7 @@
const sdk = require('../../includes/plugins/sdk'); const sdk = require('../../includes/plugins/sdk');
const crypto = require('crypto'); const crypto = require('crypto');
const { setupDatabaseWatcher } = require('./watcher'); const { setupDatabaseWatcher, teardownDatabaseWatcher } = require('./watcher');
const pluginConfig = require('./config.json'); const pluginConfig = require('./config.json');
const { resolveSpecDirs } = require('../../includes/plugins/hyperdb-builder'); const { resolveSpecDirs } = require('../../includes/plugins/hyperdb-builder');
@@ -27,6 +27,8 @@ const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
let cleanupTimer = null; let cleanupTimer = null;
let localDriveKey = null; // Our local drive's key (hex string) let localDriveKey = null; // Our local drive's key (hex string)
let peerConnectedHandler = null;
let peerDisconnectedHandler = null;
/** /**
* Generate a unique file ID * Generate a unique file ID
@@ -492,7 +494,7 @@ async function onInit() {
}); });
// Watch for peer connections/disconnections to trigger active replication updates // Watch for peer connections/disconnections to trigger active replication updates
sdk.events.on('peer-connected', (data) => { peerConnectedHandler = (data) => {
(async () => { (async () => {
sdk._initializePluginContext(PLUGIN_DOMAIN, __dirname, pluginConfig, pluginDbConfig); sdk._initializePluginContext(PLUGIN_DOMAIN, __dirname, pluginConfig, pluginDbConfig);
try { try {
@@ -519,12 +521,14 @@ async function onInit() {
sdk.log.debug('file.drop', `Error triggering replication update after peer connection: ${err.message}`); sdk.log.debug('file.drop', `Error triggering replication update after peer connection: ${err.message}`);
} }
})(); })();
}); };
sdk.events.on('peer-connected', peerConnectedHandler);
sdk.events.on('peer-disconnected', async (data) => { peerDisconnectedHandler = async (data) => {
// Peer disconnection handling - minimal implementation for consistency // Peer disconnection handling - minimal implementation for consistency
sdk.log.debug('file.drop', `Peer disconnected: ${data.peerId.slice(0, 16)}...`); sdk.log.debug('file.drop', `Peer disconnected: ${data.peerId.slice(0, 16)}...`);
}); };
sdk.events.on('peer-disconnected', peerDisconnectedHandler);
// Run initial cleanup // Run initial cleanup
await cleanupExpiredFiles(); await cleanupExpiredFiles();
@@ -542,6 +546,16 @@ async function onShutdown() {
clearInterval(cleanupTimer); clearInterval(cleanupTimer);
cleanupTimer = null; cleanupTimer = null;
} }
teardownDatabaseWatcher();
if (peerConnectedHandler) {
sdk.events.off('peer-connected', peerConnectedHandler);
peerConnectedHandler = null;
}
if (peerDisconnectedHandler) {
sdk.events.off('peer-disconnected', peerDisconnectedHandler);
peerDisconnectedHandler = null;
}
await sdk.drives.closeAll(); await sdk.drives.closeAll();
} }
+18 -3
View File
@@ -6,6 +6,8 @@
const sdk = require('../../includes/plugins/sdk'); const sdk = require('../../includes/plugins/sdk');
let dbWatchCallback = null;
/** /**
* Watch for database changes and log updates * Watch for database changes and log updates
*/ */
@@ -16,7 +18,7 @@ async function setupDatabaseWatcher() {
try { try {
await sdk.db.ready(); await sdk.db.ready();
if (!sdk.db.closed) { if (!sdk.db.closed) {
sdk.db.watch(async (...args) => { dbWatchCallback = async (...args) => {
try { try {
const update = args[0]; const update = args[0];
@@ -42,7 +44,8 @@ async function setupDatabaseWatcher() {
} catch (err) { } catch (err) {
sdk.log.error('file.drop', `Error in database watcher: ${err.message}`); sdk.log.error('file.drop', `Error in database watcher: ${err.message}`);
} }
}); };
sdk.db.watch(dbWatchCallback);
sdk.log.info('file.drop', 'Database watcher set up'); sdk.log.info('file.drop', 'Database watcher set up');
return; return;
} }
@@ -64,8 +67,20 @@ async function setupDatabaseWatcher() {
} }
} }
function teardownDatabaseWatcher() {
if (dbWatchCallback) {
try {
sdk.db.unwatch(dbWatchCallback);
} catch (err) {
sdk.log.debug('file.drop', `Failed to unwatch DB: ${err.message}`);
}
dbWatchCallback = null;
}
}
module.exports = { module.exports = {
setupDatabaseWatcher setupDatabaseWatcher,
teardownDatabaseWatcher
}; };
+1 -9
View File
@@ -21,24 +21,16 @@ exports.mapDisplayNameToLower = (record, context) => {
* @returns {Array<string>} Array of tag strings * @returns {Array<string>} Array of tag strings
*/ */
exports.mapTagsToArray = (record, context) => { exports.mapTagsToArray = (record, context) => {
console.log('[mapTagsToArray] Called with record.tags:', record?.tags);
if (!record || !record.tags) { if (!record || !record.tags) {
console.log('[mapTagsToArray] No tags, returning []');
return []; return [];
} }
try { try {
const tags = typeof record.tags === 'string' ? JSON.parse(record.tags) : record.tags; const tags = typeof record.tags === 'string' ? JSON.parse(record.tags) : record.tags;
console.log('[mapTagsToArray] Parsed tags:', tags);
if (Array.isArray(tags)) { if (Array.isArray(tags)) {
const result = tags.map(tag => tag.toLowerCase().trim()).filter(tag => tag.length > 0); return tags.map(tag => tag.toLowerCase().trim()).filter(tag => tag.length > 0);
console.log('[mapTagsToArray] Returning:', result);
return result;
} }
console.log('[mapTagsToArray] Not an array, returning []');
return []; return [];
} catch (err) { } catch (err) {
console.log('[mapTagsToArray] Error:', err.message);
return []; return [];
} }
}; };
+21 -7
View File
@@ -12,8 +12,8 @@ const sdk = require('../../includes/plugins/sdk');
// Import modules // Import modules
const { getProfileFromDB, saveProfileToDB } = require('./database'); const { getProfileFromDB, saveProfileToDB } = require('./database');
const { broadcastProfileUpdate, setupWebSocketHandlers, getReplicationStatus } = require('./websocket'); const { broadcastProfileUpdate, setupWebSocketHandlers, teardownWebSocketHandlers, getReplicationStatus } = require('./websocket');
const { setupDatabaseWatcher } = require('./watcher'); const { setupDatabaseWatcher, teardownDatabaseWatcher } = require('./watcher');
const { handleProfileRoutes } = require('./routes/profile'); const { handleProfileRoutes } = require('./routes/profile');
const { handleAvatarRoutes } = require('./routes/avatar'); const { handleAvatarRoutes } = require('./routes/avatar');
const { handleProfilesRoutes } = require('./routes/profiles'); const { handleProfilesRoutes } = require('./routes/profiles');
@@ -21,6 +21,9 @@ const { handleFieldsRoutes } = require('./routes/fields');
const { handleStatsRoutes } = require('./routes/stats'); const { handleStatsRoutes } = require('./routes/stats');
const { handleDocsRoutes } = require('./routes/docs'); const { handleDocsRoutes } = require('./routes/docs');
let peerConnectedHandler = null;
let peerDisconnectedHandler = null;
/** /**
* Plugin Handler * Plugin Handler
*/ */
@@ -120,7 +123,7 @@ async function onInit() {
}); });
// Watch for peer connections/disconnections to update online status // Watch for peer connections/disconnections to update online status
sdk.events.on('peer-connected', async (data) => { peerConnectedHandler = async (data) => {
const { getProfileFromDB } = require('./database'); const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId); const profile = await getProfileFromDB(data.peerId);
if (profile) { if (profile) {
@@ -164,9 +167,10 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.debug('global.profile', `Error triggering replication update after peer connection: ${err.message}`); sdk.log.debug('global.profile', `Error triggering replication update after peer connection: ${err.message}`);
} }
}); };
sdk.events.on('peer-connected', peerConnectedHandler);
sdk.events.on('peer-disconnected', async (data) => { peerDisconnectedHandler = async (data) => {
const { getProfileFromDB } = require('./database'); const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId); const profile = await getProfileFromDB(data.peerId);
if (profile) { if (profile) {
@@ -184,7 +188,8 @@ async function onInit() {
profile: profile profile: profile
}); });
} }
}); };
sdk.events.on('peer-disconnected', peerDisconnectedHandler);
// Verify replication is enabled // Verify replication is enabled
try { try {
@@ -220,7 +225,16 @@ async function onShutdown() {
sdk.log.info('global.profile', 'Shutting down Global Profile plugin...'); sdk.log.info('global.profile', 'Shutting down Global Profile plugin...');
try { try {
// Close WebSocket connections teardownWebSocketHandlers();
teardownDatabaseWatcher();
if (peerConnectedHandler) {
sdk.events.off('peer-connected', peerConnectedHandler);
peerConnectedHandler = null;
}
if (peerDisconnectedHandler) {
sdk.events.off('peer-disconnected', peerDisconnectedHandler);
peerDisconnectedHandler = null;
}
sdk.websocket.close(); sdk.websocket.close();
sdk.log.info('global.profile', 'Plugin shutdown complete'); sdk.log.info('global.profile', 'Plugin shutdown complete');
+18 -3
View File
@@ -8,6 +8,8 @@ const sdk = require('../../includes/plugins/sdk');
const { getProfileFromDB } = require('./database'); const { getProfileFromDB } = require('./database');
const { broadcastProfileUpdate } = require('./websocket'); const { broadcastProfileUpdate } = require('./websocket');
let dbWatchCallback = null;
/** /**
* Watch for database changes and broadcast updates * Watch for database changes and broadcast updates
*/ */
@@ -18,7 +20,7 @@ async function setupDatabaseWatcher() {
try { try {
await sdk.db.ready(); await sdk.db.ready();
if (!sdk.db.closed) { if (!sdk.db.closed) {
sdk.db.watch(async (...args) => { dbWatchCallback = async (...args) => {
try { try {
const update = args[0]; const update = args[0];
@@ -51,7 +53,8 @@ async function setupDatabaseWatcher() {
} catch (err) { } catch (err) {
sdk.log.error('global.profile', `Error in database watcher: ${err.message}`); sdk.log.error('global.profile', `Error in database watcher: ${err.message}`);
} }
}); };
sdk.db.watch(dbWatchCallback);
sdk.log.info('global.profile', 'Database watcher set up'); sdk.log.info('global.profile', 'Database watcher set up');
return; return;
} }
@@ -73,8 +76,20 @@ async function setupDatabaseWatcher() {
} }
} }
function teardownDatabaseWatcher() {
if (dbWatchCallback) {
try {
sdk.db.unwatch(dbWatchCallback);
} catch (err) {
sdk.log.debug('global.profile', `Failed to unwatch DB: ${err.message}`);
}
dbWatchCallback = null;
}
}
module.exports = { module.exports = {
setupDatabaseWatcher setupDatabaseWatcher,
teardownDatabaseWatcher
}; };
+17 -2
View File
@@ -7,6 +7,8 @@
const sdk = require('../../includes/plugins/sdk'); const sdk = require('../../includes/plugins/sdk');
const { getProfileFromDB, getAllProfilesFromDB } = require('./database'); const { getProfileFromDB, getAllProfilesFromDB } = require('./database');
let replicationStatusInterval = null;
/** /**
* Broadcast profile update to WebSocket clients and all peers * Broadcast profile update to WebSocket clients and all peers
*/ */
@@ -76,6 +78,11 @@ function setupWebSocketHandlers() {
// Track previous replication status to detect changes // Track previous replication status to detect changes
let previousReplicationStatus = JSON.stringify(getReplicationStatus()); let previousReplicationStatus = JSON.stringify(getReplicationStatus());
if (replicationStatusInterval) {
clearInterval(replicationStatusInterval);
replicationStatusInterval = null;
}
sdk.websocket.on('connection', async (ws) => { sdk.websocket.on('connection', async (ws) => {
const clientCount = sdk.websocket.getClientCount(); const clientCount = sdk.websocket.getClientCount();
sdk.log.info('global.profile', `WebSocket client connected (${clientCount} total)`); sdk.log.info('global.profile', `WebSocket client connected (${clientCount} total)`);
@@ -129,7 +136,7 @@ function setupWebSocketHandlers() {
}); });
// Broadcast replication status updates periodically // Broadcast replication status updates periodically
setInterval(() => { replicationStatusInterval = setInterval(() => {
const clientCount = sdk.websocket.getClientCount(); const clientCount = sdk.websocket.getClientCount();
if (clientCount > 0) { if (clientCount > 0) {
const currentStatus = getReplicationStatus(); const currentStatus = getReplicationStatus();
@@ -149,10 +156,18 @@ function setupWebSocketHandlers() {
}, 2000); // Check every 2 seconds }, 2000); // Check every 2 seconds
} }
function teardownWebSocketHandlers() {
if (replicationStatusInterval) {
clearInterval(replicationStatusInterval);
replicationStatusInterval = null;
}
}
module.exports = { module.exports = {
broadcastProfileUpdate, broadcastProfileUpdate,
getReplicationStatus, getReplicationStatus,
setupWebSocketHandlers setupWebSocketHandlers,
teardownWebSocketHandlers
}; };
+1 -1
View File
@@ -272,7 +272,7 @@ async function loadProfiles() {
} catch (err) { } catch (err) {
console.error('Error loading profiles:', err); console.error('Error loading profiles:', err);
const profilesList = document.getElementById('profiles-list'); const profilesList = document.getElementById('profiles-list');
profilesList.innerHTML = `<div class="loading">Failed to load profiles: ${err.message || 'Unknown error'}</div>`; profilesList.innerHTML = `<div class="loading">Failed to load profiles: ${escapeHtml(err.message || 'Unknown error')}</div>`;
} }
} }
+49 -8
View File
@@ -16,6 +16,12 @@ const sdk = require('../../includes/plugins/sdk');
let lastDomainStates = new Map(); // Track last known domain states let lastDomainStates = new Map(); // Track last known domain states
let debounceTimer = null; // Debounce timer for domain change detection let debounceTimer = null; // Debounce timer for domain change detection
let updateInterval = null; // Periodic update interval as fallback let updateInterval = null; // Periodic update interval as fallback
let dnsPassUpdateHandler = null;
let coreAppendHandler = null;
let dnsReadyCheckTimeout = null;
let dnsReadyCheckStopped = false;
let domainAddedHandler = null;
let domainRemovedHandler = null;
/** /**
* Categorize and collect all domains from the P2NS system * Categorize and collect all domains from the P2NS system
@@ -568,7 +574,7 @@ async function onInit() {
setupWebSocketHandlers(); setupWebSocketHandlers();
// Subscribe to domain-related events (non-blocking) // Subscribe to domain-related events (non-blocking)
sdk.events.on('domain-added', async (data) => { domainAddedHandler = async (data) => {
try { try {
sdk.log.debug('peer.directory', `Domain added: ${data.domain}`); sdk.log.debug('peer.directory', `Domain added: ${data.domain}`);
// Trigger domain check after a short delay to allow DNS to update // Trigger domain check after a short delay to allow DNS to update
@@ -578,9 +584,10 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('peer.directory', `Error handling domain-added event: ${err.message}`); sdk.log.error('peer.directory', `Error handling domain-added event: ${err.message}`);
} }
}); };
sdk.events.on('domain-added', domainAddedHandler);
sdk.events.on('domain-removed', async (data) => { domainRemovedHandler = async (data) => {
try { try {
sdk.log.debug('peer.directory', `Domain removed: ${data.domain}`); sdk.log.debug('peer.directory', `Domain removed: ${data.domain}`);
lastDomainStates.delete(data.domain); lastDomainStates.delete(data.domain);
@@ -591,14 +598,12 @@ async function onInit() {
} catch (err) { } catch (err) {
sdk.log.error('peer.directory', `Error handling domain-removed event: ${err.message}`); sdk.log.error('peer.directory', `Error handling domain-removed event: ${err.message}`);
} }
}); };
sdk.events.on('domain-removed', domainRemovedHandler);
// Listen to DNS pass updates for real-time domain changes // Listen to DNS pass updates for real-time domain changes
// This is the key to making it fully live - we detect when DNS entries change // This is the key to making it fully live - we detect when DNS entries change
// Wait for DNS to be ready before setting up listeners // Wait for DNS to be ready before setting up listeners
let dnsPassUpdateHandler = null;
let coreAppendHandler = null;
const setupDNSListeners = () => { const setupDNSListeners = () => {
const dnsPass = sdk.state.dnsPass; const dnsPass = sdk.state.dnsPass;
const core = sdk.state.core; const core = sdk.state.core;
@@ -634,10 +639,11 @@ async function onInit() {
} else { } else {
// Wait for DNS to be ready // Wait for DNS to be ready
const checkDNSReady = () => { const checkDNSReady = () => {
if (dnsReadyCheckStopped) return;
if (sdk.utils.isDNSReady()) { if (sdk.utils.isDNSReady()) {
setupDNSListeners(); setupDNSListeners();
} else { } else {
setTimeout(checkDNSReady, 1000); // Check every second dnsReadyCheckTimeout = setTimeout(checkDNSReady, 1000); // Check every second
} }
}; };
checkDNSReady(); checkDNSReady();
@@ -698,6 +704,41 @@ async function onInit() {
async function onShutdown() { async function onShutdown() {
sdk.log.info('peer.directory', 'Plugin shutting down'); sdk.log.info('peer.directory', 'Plugin shutting down');
dnsReadyCheckStopped = true;
if (dnsReadyCheckTimeout) {
clearTimeout(dnsReadyCheckTimeout);
dnsReadyCheckTimeout = null;
}
if (domainAddedHandler) {
sdk.events.off('domain-added', domainAddedHandler);
domainAddedHandler = null;
}
if (domainRemovedHandler) {
sdk.events.off('domain-removed', domainRemovedHandler);
domainRemovedHandler = null;
}
try {
const dnsPass = sdk.state.dnsPass;
if (dnsPass && dnsPassUpdateHandler) {
dnsPass.removeListener('update', dnsPassUpdateHandler);
dnsPassUpdateHandler = null;
}
} catch (err) {
sdk.log.debug('peer.directory', `Error removing DNS pass listeners: ${err.message}`);
}
try {
const core = sdk.state.core;
if (core && coreAppendHandler) {
core.removeListener('append', coreAppendHandler);
coreAppendHandler = null;
}
} catch (err) {
sdk.log.debug('peer.directory', `Error removing core listeners: ${err.message}`);
}
// Clean up WebSocket resources // Clean up WebSocket resources
if (updateInterval) { if (updateInterval) {
clearInterval(updateInterval); clearInterval(updateInterval);
+7 -7
View File
@@ -772,7 +772,7 @@ async function getStats(res) {
function renderPastePage(paste) { function renderPastePage(paste) {
const safePaste = normalizeStoredPaste(paste); const safePaste = normalizeStoredPaste(paste);
let attachments = []; let attachments = [];
try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch {} try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch (err) { sdk.log.debug('peer.paste', `Failed to parse attachment refs: ${err.message}`); }
const encryptedPayload = decodeEncryptedContent(paste.content); const encryptedPayload = decodeEncryptedContent(paste.content);
if (encryptedPayload) { if (encryptedPayload) {
const { aside, hasAside } = renderAttachmentsAside(attachments); const { aside, hasAside } = renderAttachmentsAside(attachments);
@@ -843,7 +843,7 @@ function renderPastePage(paste) {
out.innerHTML = sanitize(marked.parse(text)); out.innerHTML = sanitize(marked.parse(text));
if (window.hljs) { if (window.hljs) {
out.querySelectorAll('pre code').forEach((block) => { out.querySelectorAll('pre code').forEach((block) => {
try { hljs.highlightElement(block); } catch {} try { hljs.highlightElement(block); } catch (err) { sdk.log.debug('peer.paste', `Highlight failed: ${err.message}`); }
}); });
} }
} else { } else {
@@ -875,7 +875,7 @@ function renderPastePage(paste) {
btn.textContent = 'Copied'; btn.textContent = 'Copied';
btn.classList.add('ok'); btn.classList.add('ok');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('ok'); }, 900); setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('ok'); }, 900);
} catch {} } catch (e) { console.debug('clipboard failed', e); }
} }
function downloadCurrent() { function downloadCurrent() {
const btn = document.getElementById('download-btn'); const btn = document.getElementById('download-btn');
@@ -963,7 +963,7 @@ function renderPastePage(paste) {
copyBtn.textContent = 'Copied'; copyBtn.textContent = 'Copied';
copyBtn.classList.add('ok'); copyBtn.classList.add('ok');
setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('ok'); }, 900); setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('ok'); }, 900);
} catch {} } catch (e) { console.debug('clipboard failed', e); }
}); });
downloadBtn.addEventListener('click', () => { downloadBtn.addEventListener('click', () => {
const blob = new Blob([sourceText], { type: 'text/markdown;charset=utf-8' }); const blob = new Blob([sourceText], { type: 'text/markdown;charset=utf-8' });
@@ -989,7 +989,7 @@ function renderPastePage(paste) {
el.innerHTML = sanitize(marked.parse(src)); el.innerHTML = sanitize(marked.parse(src));
if (window.hljs) { if (window.hljs) {
el.querySelectorAll('pre code').forEach((block) => { el.querySelectorAll('pre code').forEach((block) => {
try { hljs.highlightElement(block); } catch {} try { hljs.highlightElement(block); } catch (e) { console.debug('highlight failed', e); }
}); });
} }
</script>` </script>`
@@ -1004,7 +1004,7 @@ function renderPastePage(paste) {
copyBtn.textContent = 'Copied'; copyBtn.textContent = 'Copied';
copyBtn.classList.add('ok'); copyBtn.classList.add('ok');
setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('ok'); }, 900); setTimeout(() => { copyBtn.textContent = 'Copy'; copyBtn.classList.remove('ok'); }, 900);
} catch {} } catch (e) { console.debug('clipboard failed', e); }
}); });
downloadBtn.addEventListener('click', () => { downloadBtn.addEventListener('click', () => {
const blob = new Blob([sourceText], { type: 'text/plain;charset=utf-8' }); const blob = new Blob([sourceText], { type: 'text/plain;charset=utf-8' });
@@ -1030,7 +1030,7 @@ function renderPastePage(paste) {
} else { } else {
hljs.highlightElement(block); hljs.highlightElement(block);
} }
} catch {} } catch (e) { console.debug('highlight failed', e); }
} }
</script>` </script>`
}); });
+10 -3
View File
@@ -589,6 +589,10 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function escapeJsAttr(text) {
return String(text || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
/** /**
* Update peer details list * Update peer details list
*/ */
@@ -596,9 +600,11 @@ function updatePeerDetailsList(peers) {
const peerList = document.getElementById('peerList'); const peerList = document.getElementById('peerList');
if (!peerList) return; if (!peerList) return;
peerList.innerHTML = peers.map(peer => ` peerList.innerHTML = peers.map(peer => {
const peerIdAttr = escapeJsAttr(peer.id);
return `
<div class="peer-item ${peer.connected ? 'connected' : 'disconnected'}" <div class="peer-item ${peer.connected ? 'connected' : 'disconnected'}"
onclick="showPeerDetails('${peer.id}')"> onclick="showPeerDetails('${peerIdAttr}')">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<div style="flex: 1;"> <div style="flex: 1;">
${renderPeerWithProfile(peer, 32)} ${renderPeerWithProfile(peer, 32)}
@@ -612,7 +618,8 @@ function updatePeerDetailsList(peers) {
</div> </div>
</div> </div>
</div> </div>
`).join(''); `;
}).join('');
} }
/** /**
+20 -18
View File
@@ -14,6 +14,7 @@ const process = require('process');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { parseMinutesToMs, parseSecondsToMs, secondsToMs, loadOrCreateKeypair, setupCache, getPersistentPublicKey, isSecureHolesailKey } = require('../includes/infrastructure/utils'); const { parseMinutesToMs, parseSecondsToMs, secondsToMs, loadOrCreateKeypair, setupCache, getPersistentPublicKey, isSecureHolesailKey } = require('../includes/infrastructure/utils');
const { validateDomainDetailed } = require('../includes/infrastructure/validation');
// ============================================================================ // ============================================================================
// Configuration // Configuration
@@ -49,6 +50,8 @@ const config = {
// Domain validation // Domain validation
MAX_DOMAIN_LENGTH: 253, MAX_DOMAIN_LENGTH: 253,
MAX_DOMAIN_CACHE_SIZE: parseInt(process.env.MAX_DOMAIN_CACHE_SIZE || '5000', 10),
MAX_METRIC_MAP_SIZE: parseInt(process.env.MAX_METRIC_MAP_SIZE || '1000', 10),
DOMAIN_REGEX: /^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?)*$/i DOMAIN_REGEX: /^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?)*$/i
}; };
@@ -137,15 +140,25 @@ function recordMetric(type, value = 1) {
} }
} }
function evictOldestMapEntry(map, maxSize) {
if (map.size <= maxSize) return;
const firstKey = map.keys().next().value;
if (firstKey !== undefined) {
map.delete(firstKey);
}
}
function recordError(type, message) { function recordError(type, message) {
const count = metrics.errorsByType.get(type) || 0; const count = metrics.errorsByType.get(type) || 0;
metrics.errorsByType.set(type, count + 1); metrics.errorsByType.set(type, count + 1);
evictOldestMapEntry(metrics.errorsByType, config.MAX_METRIC_MAP_SIZE);
recordMetric('error'); recordMetric('error');
} }
function recordDomainRequest(domain) { function recordDomainRequest(domain) {
const count = metrics.requestsByDomain.get(domain) || 0; const count = metrics.requestsByDomain.get(domain) || 0;
metrics.requestsByDomain.set(domain, count + 1); metrics.requestsByDomain.set(domain, count + 1);
evictOldestMapEntry(metrics.requestsByDomain, config.MAX_METRIC_MAP_SIZE);
} }
function getMetrics() { function getMetrics() {
@@ -237,24 +250,7 @@ setInterval(() => {
// Input Validation // Input Validation
// ============================================================================ // ============================================================================
function validateDomain(domain) { function validateDomain(domain) {
if (!domain || typeof domain !== 'string') { return validateDomainDetailed(domain, { maxLength: config.MAX_DOMAIN_LENGTH });
return { valid: false, error: 'Domain must be a non-empty string' };
}
if (domain.length > config.MAX_DOMAIN_LENGTH) {
return { valid: false, error: `Domain exceeds maximum length of ${config.MAX_DOMAIN_LENGTH}` };
}
if (!config.DOMAIN_REGEX.test(domain)) {
return { valid: false, error: 'Domain contains invalid characters' };
}
// Check for path traversal attempts
if (domain.includes('..') || domain.includes('/') || domain.includes('\\')) {
return { valid: false, error: 'Domain contains invalid path characters' };
}
return { valid: true };
} }
// ============================================================================ // ============================================================================
@@ -274,6 +270,12 @@ function getCachedHash(domain) {
} }
function setCachedHash(domain, hash) { function setCachedHash(domain, hash) {
if (domainCache.size >= config.MAX_DOMAIN_CACHE_SIZE) {
const firstKey = domainCache.keys().next().value;
if (firstKey !== undefined) {
domainCache.delete(firstKey);
}
}
domainCache.set(domain, { domainCache.set(domain, {
hash, hash,
expires: Date.now() + config.CACHE_TTL expires: Date.now() + config.CACHE_TTL