forked from snxraven/p2ns
Cleanup - Bug Fixes
This commit is contained in:
+14
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
|
|
||||||
if (!state.domainToIPMap.has(data.domain)) {
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
await createInterfaceForDomain(data.domain);
|
await createInterfaceForDomain(data.domain);
|
||||||
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(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);
|
|
||||||
await fs.rm(domainDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
if (!state.domainToIPMap.has(data.domain)) {
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
await createInterfaceForDomain(data.domain);
|
await createInterfaceForDomain(data.domain);
|
||||||
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,15 +2,6 @@
|
|||||||
|
|
||||||
let pluginsData = [];
|
let pluginsData = [];
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value ?? '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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">×</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">×</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>
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 || {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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)}...`);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,9 +11,6 @@ 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);
|
||||||
@@ -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,9 +49,6 @@ 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);
|
||||||
@@ -77,9 +62,6 @@ window.sdk.utils.format = {
|
|||||||
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)',
|
||||||
@@ -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');
|
||||||
@@ -789,6 +790,18 @@ async function main() {
|
|||||||
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
|
||||||
const channelManager = require('./includes/plugins/channel-manager');
|
const channelManager = require('./includes/plugins/channel-manager');
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -570,6 +578,23 @@ async function onShutdown() {
|
|||||||
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 {
|
||||||
const dnsPass = sdk.state.dnsPass;
|
const dnsPass = sdk.state.dnsPass;
|
||||||
|
|||||||
@@ -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 ? `
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -543,6 +547,16 @@ async function onShutdown() {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user