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
This commit is contained in:
Raven Scott
2026-05-28 10:51:19 -04:00
parent 4b783801c5
commit ea9124c429
49 changed files with 6576 additions and 215 deletions
@@ -1,15 +1,63 @@
const dns = require('dns').promises;
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
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 execAsync = promisify(exec);
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;
@@ -110,20 +158,22 @@ async function handleDiagnosticsRoutes(req, res) {
const data = JSON.parse(body);
const { target, count = 4, stream = false } = data;
if (!target) {
const safeTarget = validateDiagnosticTarget(target);
if (!safeTarget) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
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', count.toString(), target]
: ['-c', count.toString(), target];
const pingArgs = platform === 'win32'
? ['-n', safeCount.toString(), safeTarget]
: ['-c', safeCount.toString(), safeTarget];
const pingProcess = spawn('ping', pingArgs);
// Set up streaming response
@@ -193,19 +243,20 @@ async function handleDiagnosticsRoutes(req, res) {
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const pingCmd = platform === 'win32'
? `ping -n ${count} ${target}`
: `ping -c ${count} ${target}`;
const pingArgs = platform === 'win32'
? ['-n', safeCount.toString(), safeTarget]
: ['-c', safeCount.toString(), safeTarget];
try {
const { stdout, stderr } = await execAsync(pingCmd, { timeout: 30000 });
const { stdout, stderr, code } = await runCommand('ping', pingArgs, PING_TIMEOUT_MS);
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
target,
count,
success,
target: safeTarget,
count: safeCount,
output: stdout,
error: stderr || null,
responseTime
@@ -216,10 +267,10 @@ async function handleDiagnosticsRoutes(req, res) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target,
count,
output: execErr.stdout || '',
error: execErr.stderr || execErr.message,
target: safeTarget,
count: safeCount,
output: '',
error: execErr.message,
responseTime
}));
}
@@ -243,9 +294,10 @@ async function handleDiagnosticsRoutes(req, res) {
const data = JSON.parse(body);
const { target, stream = false } = data;
if (!target) {
const safeTarget = validateDiagnosticTarget(target);
if (!safeTarget) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
res.end(JSON.stringify({ error: 'A valid diagnostic target is required' }));
trackRequest(urlPath, false);
return true;
}
@@ -255,7 +307,7 @@ async function handleDiagnosticsRoutes(req, res) {
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
const tracerouteArgs = platform === 'win32' ? [target] : [target];
const tracerouteArgs = [safeTarget];
const tracerouteProcess = spawn(tracerouteCmd, tracerouteArgs);
// Set up streaming response
@@ -325,18 +377,18 @@ async function handleDiagnosticsRoutes(req, res) {
// Non-streaming mode (backward compatibility)
const startTime = Date.now();
const platform = os.platform();
const tracerouteCmd = platform === 'win32'
? `tracert ${target}`
: `traceroute ${target}`;
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
const tracerouteArgs = [safeTarget];
try {
const { stdout, stderr } = await execAsync(tracerouteCmd, { timeout: 60000 });
const { stdout, stderr, code } = await runCommand(tracerouteCmd, tracerouteArgs, TRACEROUTE_TIMEOUT_MS);
const responseTime = Date.now() - startTime;
trackRequestWithTiming(urlPath, true, responseTime);
const success = code === 0;
trackRequestWithTiming(urlPath, success, responseTime);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
target,
success,
target: safeTarget,
output: stdout,
error: stderr || null,
responseTime
@@ -347,9 +399,9 @@ async function handleDiagnosticsRoutes(req, res) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
target,
output: execErr.stdout || '',
error: execErr.stderr || execErr.message,
target: safeTarget,
output: '',
error: execErr.message,
responseTime
}));
}
@@ -381,19 +433,27 @@ async function handleDiagnosticsRoutes(req, res) {
}
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(domain);
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,
port,
domain: safeDomain,
port: safePort,
error: `DNS resolution failed: ${dnsErr.message}`,
responseTime: Date.now() - startTime
}));
@@ -424,7 +484,7 @@ async function handleDiagnosticsRoutes(req, res) {
resolve({ success: false, error: err.message });
});
socket.connect(port, ip);
socket.connect(safePort, ip);
});
};
@@ -435,9 +495,9 @@ async function handleDiagnosticsRoutes(req, res) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...result,
domain,
domain: safeDomain,
ip,
port,
port: safePort,
responseTime
}));
} catch (err) {
@@ -580,9 +580,17 @@ async function savePluginSettings(domain, settings) {
const params = body ? JSON.parse(body) : {};
const { validatePluginActionParams } = require('../../../plugins/plugin-handler');
const validation = validatePluginActionParams(domain, actionName, params);
if (!validation.valid) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid action parameters', details: validation.errors }));
return true;
}
// Execute the action (handler may be a proxy function for child process)
try {
const result = await action.handler(params);
const result = await action.handler(validation.params);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
@@ -41,6 +41,14 @@ async function handleStatsRoutes(req, res) {
try {
const startTime = Date.now();
const stats = getMetrics();
if (state.hypercoreStats && typeof state.hypercoreStats.toJson === 'function') {
try {
const { normalizeHolepunchStats } = require('../../../infrastructure/holepunch-stats-schema');
stats.holepunch = normalizeHolepunchStats(state.hypercoreStats.toJson());
} catch (err) {
logDebug('Admin', `Failed to collect hypercore stats: ${err.message}`);
}
}
const holesailChildren = [];
const pidStatsPromises = [];