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:
@@ -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 = [];
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
let pluginsData = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Fetch plugins from API
|
||||
async function fetchPlugins() {
|
||||
try {
|
||||
@@ -449,6 +458,89 @@ function renderSettingInput(domain, key, setting) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActionParamValue(param, rawValue) {
|
||||
if (param.type === 'number') {
|
||||
const num = Number(rawValue);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new Error(`Parameter "${param.name}" must be a number`);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
if (param.type === 'boolean') {
|
||||
return rawValue === true || rawValue === 'true' || rawValue === '1';
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
async function collectActionParameters(action) {
|
||||
if (!action.params || action.params.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const formFields = action.params.map((param, index) => {
|
||||
const inputId = `plugin-action-param-${index}`;
|
||||
const type = param.type || 'string';
|
||||
const required = param.required ? 'required' : '';
|
||||
const placeholder = param.placeholder ? `placeholder="${escapeHtml(param.placeholder)}"` : '';
|
||||
const defaultValue = param.default !== undefined ? String(param.default) : '';
|
||||
const description = param.description
|
||||
? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(param.description)}</p>`
|
||||
: '';
|
||||
|
||||
if (type === 'boolean') {
|
||||
return `
|
||||
<label class="block text-sm theme-text-primary mb-3">
|
||||
<span class="block mb-1">${escapeHtml(param.label || param.name)}</span>
|
||||
<select id="${inputId}" class="w-full p-2 theme-input rounded">
|
||||
<option value="false" ${defaultValue === 'false' ? 'selected' : ''}>False</option>
|
||||
<option value="true" ${defaultValue === 'true' ? 'selected' : ''}>True</option>
|
||||
</select>
|
||||
${description}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<label class="block text-sm theme-text-primary mb-3">
|
||||
<span class="block mb-1">${escapeHtml(param.label || param.name)}${param.required ? ' *' : ''}</span>
|
||||
<input id="${inputId}" type="${type === 'number' ? 'number' : 'text'}"
|
||||
class="w-full p-2 theme-input rounded"
|
||||
value="${escapeHtml(defaultValue)}"
|
||||
${placeholder}
|
||||
${required} />
|
||||
${description}
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const confirmed = await window.ConfirmationModal.show({
|
||||
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>`,
|
||||
type: 'info',
|
||||
confirmText: 'Run Action',
|
||||
cancelText: 'Cancel',
|
||||
allowHTML: true,
|
||||
focusConfirm: false
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
const params = {};
|
||||
for (let i = 0; i < action.params.length; i++) {
|
||||
const param = action.params[i];
|
||||
const element = document.getElementById(`plugin-action-param-${i}`);
|
||||
const rawValue = element ? element.value : '';
|
||||
if (param.required && String(rawValue).trim() === '') {
|
||||
throw new Error(`Parameter "${param.name}" is required`);
|
||||
}
|
||||
if (String(rawValue).trim() === '' && !param.required) {
|
||||
continue;
|
||||
}
|
||||
params[param.name] = normalizeActionParamValue(param, rawValue);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// Execute a plugin action
|
||||
async function executeAction(domain, actionName, action) {
|
||||
if (!action) {
|
||||
@@ -470,10 +562,13 @@ async function executeAction(domain, actionName, action) {
|
||||
}
|
||||
|
||||
// Collect parameters if any
|
||||
const params = {};
|
||||
if (action.params && action.params.length > 0) {
|
||||
// TODO: Show modal to collect parameters
|
||||
// For now, execute with empty params
|
||||
const params = await collectActionParameters(action);
|
||||
if (params === null) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/plugins/${domain}/actions/${actionName}`, {
|
||||
|
||||
Reference in New Issue
Block a user