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
595 lines
19 KiB
JavaScript
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 };
|
|
|