Files
p2ns/includes/admin/admin-backend/routes/diagnostics.js
T
Raven Scott ea9124c429 BREAKING EXPERIMENTAL: Holepunch-native hard migration (RPC core, shared storage, SDK v2)
BREAKING: All nodes must upgrade together. Legacy p2ns.core-request string
messages are rejected; invite/consensus control plane uses protomux-rpc
(invite.request, invite.ack, invite.relay*, consensus.*). Shared Corestore
namespaces are the default for plugin DBs and drives; USE_SHARED_CORESTORE_NAMESPACES=false
is debug-only. Admin plugin actions with params are validated before run.

EXPERIMENTAL: End-to-end RPC invite path reuses existing handlers via adapters;
invite wire still ships on the invite channel. Multi-peer invite/relay
integration tests are not in CI yet (core-rpc-smoke only).

SDK & channels:
- channel-rpc.js, sdk.channels.rpc (register/request/event)
- core-rpc.js for p2ns.core; action-params + plugin route validation
- sdk.db.getCore/reopen, sdk.state.getPeerChannelSnapshot,
  sdk.metrics.getHolepunchStats (schema v1)

Runtime:
- p2ns.js: RPC-first core invite/consensus; Hyperswarm firewall/reconnect
- channel-manager: required protomux-rpc per peer
- db-shared-namespace-migration; drive-manager shared namespaces
- proxy-server: invite.request RPC for joiners

Plugins: file.drop, global.profile, peer.directory, domain.consensus,
peer.visualize, example.plugin (RPC demo); peer.directory UI polish

Also: CI/smoke scripts, diagnostics hardening, plugin config schema validation,
admin action param modal, docs/RFCS, package-lock + engines.node >= 18
2026-05-28 10:51:19 -04:00

595 lines
19 KiB
JavaScript

const dns = require('dns').promises;
const { spawn } = require('child_process');
const net = require('net');
const os = require('os');
const { logError, logDebug } = require('../../../infrastructure/logger');
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
const { createErrorResponse } = require('../../../infrastructure/error_handler');
const { validateIP, validateDomain, sanitizeInput, validatePort } = require('../../../infrastructure/validation');
const state = require('../../../infrastructure/state');
const MIN_DIAGNOSTIC_COUNT = 1;
const MAX_DIAGNOSTIC_COUNT = 10;
const PING_TIMEOUT_MS = 30000;
const TRACEROUTE_TIMEOUT_MS = 60000;
const MAX_DIAGNOSTIC_TARGET_LENGTH = 253;
function isValidHostname(target) {
if (!target || typeof target !== 'string') return false;
// Accept simple hostnames/FQDNs used in local network diagnostics.
return /^(?=.{1,253}$)(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.?$/.test(target);
}
function validateDiagnosticTarget(target) {
const sanitized = sanitizeInput(target);
if (!sanitized || sanitized.length > MAX_DIAGNOSTIC_TARGET_LENGTH) {
return null;
}
if (validateIP(sanitized) || validateDomain(sanitized) || isValidHostname(sanitized)) {
return sanitized;
}
return null;
}
function normalizeCount(count) {
const parsed = Number.parseInt(count, 10);
if (Number.isNaN(parsed)) return 4;
return Math.min(MAX_DIAGNOSTIC_COUNT, Math.max(MIN_DIAGNOSTIC_COUNT, parsed));
}
function runCommand(command, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { timeout: timeoutMs });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', reject);
child.on('close', (code, signal) => {
resolve({ code, signal, stdout, stderr });
});
});
}
async function handleDiagnosticsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
// POST /api/diagnostics/dns-lookup
if (method === 'POST' && urlPath === '/api/diagnostics/dns-lookup') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { domain, type = 'A' } = data;
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'domain is required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
let results = [];
try {
switch (type.toUpperCase()) {
case 'A':
results = await dns.resolve4(domain);
break;
case 'AAAA':
results = await dns.resolve6(domain);
break;
case 'MX':
results = await dns.resolveMx(domain);
break;
case 'TXT':
results = await dns.resolveTxt(domain);
break;
case 'NS':
results = await dns.resolveNs(domain);
break;
case 'CNAME':
results = await dns.resolveCname(domain);
break;
case 'SRV':
results = await dns.resolveSrv(domain);
break;
case 'PTR':
results = await dns.resolvePtr(domain);
break;
case 'SOA':
results = await dns.resolveSoa(domain);
break;
default:
throw new Error(`Unsupported DNS record type: ${type}`);
}
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
domain,
type,
results: Array.isArray(results) ? results : [results],
responseTime
}));
} catch (dnsErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
domain,
type,
error: dnsErr.message,
results: [],
responseTime
}));
}
} catch (err) {
logError('Admin', `DNS lookup failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/ping
if (method === 'POST' && urlPath === '/api/diagnostics/ping') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { target, count = 4, stream = false } = data;
const safeTarget = validateDiagnosticTarget(target);
if (!safeTarget) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'A valid diagnostic target is required' }));
trackRequest(urlPath, false);
return true;
}
const safeCount = normalizeCount(count);
// Streaming mode
if (stream) {
const startTime = Date.now();
const platform = os.platform();
const pingArgs = platform === 'win32'
? ['-n', safeCount.toString(), safeTarget]
: ['-c', safeCount.toString(), safeTarget];
const pingProcess = spawn('ping', pingArgs);
// Set up streaming response
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let output = '';
let errorOutput = '';
pingProcess.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Send each line as it arrives
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
res.write(JSON.stringify({
type: 'output',
data: line,
timestamp: Date.now()
}) + '\n');
}
});
pingProcess.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
res.write(JSON.stringify({
type: 'error',
data: text,
timestamp: Date.now()
}) + '\n');
});
pingProcess.on('close', (code) => {
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.write(JSON.stringify({
type: 'complete',
success,
exitCode: code,
output,
error: errorOutput || null,
responseTime
}) + '\n');
res.end();
});
pingProcess.on('error', (err) => {
res.write(JSON.stringify({
type: 'error',
error: err.message,
timestamp: Date.now()
}) + '\n');
res.end();
trackRequest(urlPath, false);
});
return true;
}
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const pingArgs = platform === 'win32'
? ['-n', safeCount.toString(), safeTarget]
: ['-c', safeCount.toString(), safeTarget];
try {
const { stdout, stderr, code } = await runCommand('ping', pingArgs, PING_TIMEOUT_MS);
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success,
target: safeTarget,
count: safeCount,
output: stdout,
error: stderr || null,
responseTime
}));
} catch (execErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target: safeTarget,
count: safeCount,
output: '',
error: execErr.message,
responseTime
}));
}
} catch (err) {
logError('Admin', `Ping failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/traceroute
if (method === 'POST' && urlPath === '/api/diagnostics/traceroute') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { target, stream = false } = data;
const safeTarget = validateDiagnosticTarget(target);
if (!safeTarget) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'A valid diagnostic target is required' }));
trackRequest(urlPath, false);
return true;
}
// Streaming mode
if (stream) {
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
const tracerouteArgs = [safeTarget];
const tracerouteProcess = spawn(tracerouteCmd, tracerouteArgs);
// Set up streaming response
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let output = '';
let errorOutput = '';
tracerouteProcess.stdout.on('data', (data) => {
const text = data.toString();
output += text;
// Send each line as it arrives
const lines = text.split('\n').filter(line => line.trim());
for (const line of lines) {
res.write(JSON.stringify({
type: 'output',
data: line,
timestamp: Date.now()
}) + '\n');
}
});
tracerouteProcess.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
res.write(JSON.stringify({
type: 'error',
data: text,
timestamp: Date.now()
}) + '\n');
});
tracerouteProcess.on('close', (code) => {
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.write(JSON.stringify({
type: 'complete',
success,
exitCode: code,
output,
error: errorOutput || null,
responseTime
}) + '\n');
res.end();
});
tracerouteProcess.on('error', (err) => {
res.write(JSON.stringify({
type: 'error',
error: err.message,
timestamp: Date.now()
}) + '\n');
res.end();
trackRequest(urlPath, false);
});
return true;
}
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
const tracerouteArgs = [safeTarget];
try {
const { stdout, stderr, code } = await runCommand(tracerouteCmd, tracerouteArgs, TRACEROUTE_TIMEOUT_MS);
const responseTime = Date.now() - startTime;
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success,
target: safeTarget,
output: stdout,
error: stderr || null,
responseTime
}));
} catch (execErr) {
const responseTime = Date.now() - startTime;
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target: safeTarget,
output: '',
error: execErr.message,
responseTime
}));
}
} catch (err) {
logError('Admin', `Traceroute failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// POST /api/diagnostics/connection-test
if (method === 'POST' && urlPath === '/api/diagnostics/connection-test') {
try {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
const data = JSON.parse(body);
const { domain, port } = data;
if (!domain || !port) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'domain and port are required' }));
trackRequest(urlPath, false);
return true;
}
const startTime = Date.now();
const safeDomain = validateDiagnosticTarget(domain);
const safePort = Number.parseInt(port, 10);
if (!safeDomain || !validatePort(safePort)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Valid domain and port are required' }));
trackRequest(urlPath, false);
return true;
}
// First resolve domain to IP
let ip;
try {
const addresses = await dns.resolve4(safeDomain);
ip = addresses[0];
} catch (dnsErr) {
trackRequest(urlPath, false);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
domain: safeDomain,
port: safePort,
error: `DNS resolution failed: ${dnsErr.message}`,
responseTime: Date.now() - startTime
}));
return true;
}
// Test TCP connection
const testConnection = () => {
return new Promise((resolve) => {
const socket = new net.Socket();
const timeout = 5000;
let connected = false;
socket.setTimeout(timeout);
socket.on('connect', () => {
connected = true;
socket.destroy();
resolve({ success: true, latency: Date.now() - startTime });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ success: false, error: 'Connection timeout' });
});
socket.on('error', (err) => {
resolve({ success: false, error: err.message });
});
socket.connect(safePort, ip);
});
};
const result = await testConnection();
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, result.success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...result,
domain: safeDomain,
ip,
port: safePort,
responseTime
}));
} catch (err) {
logError('Admin', `Connection test failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/diagnostics/bandwidth
if (method === 'GET' && urlPath === '/api/diagnostics/bandwidth') {
try {
const startTime = Date.now();
const networkInterfaces = os.networkInterfaces();
const stats = {};
for (const [name, addresses] of Object.entries(networkInterfaces)) {
if (!addresses) continue;
let totalBytes = 0;
let totalPackets = 0;
for (const addr of addresses) {
if (addr.family === 'IPv4' || addr.family === 'IPv6') {
// Note: Node.js doesn't provide real-time bandwidth stats
// This is a placeholder structure
stats[name] = {
name,
addresses: addresses.map(a => ({
address: a.address,
netmask: a.netmask,
family: a.family,
mac: a.mac || 'N/A',
internal: a.internal
})),
// These would need system-specific tools to get real values
bytesReceived: 0,
bytesSent: 0,
packetsReceived: 0,
packetsSent: 0
};
}
}
}
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
interfaces: stats,
note: 'Bandwidth statistics require system-specific tools. Interface information only.',
responseTime
}));
} catch (err) {
logError('Admin', `Bandwidth stats failed: ${err.message}`);
trackRequest(urlPath, false);
const errorResponse = createErrorResponse(err, 500);
res.writeHead(errorResponse.statusCode, errorResponse.headers);
res.end(errorResponse.body);
}
return true;
}
// GET /api/diagnostics/invites
if (method === 'GET' && urlPath === '/api/diagnostics/invites') {
try {
if (!state.diagnoseInviteIssues) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invite diagnostics not available - system still initializing' }));
trackRequest(urlPath, false);
return true;
}
const diagnostics = state.diagnoseInviteIssues();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(diagnostics));
trackRequest(urlPath, true);
return true;
} catch (err) {
logError('DiagnosticsRoute', `Invite diagnostics error: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
trackRequest(urlPath, false);
return true;
}
}
return false;
}
module.exports = { handleDiagnosticsRoutes };