Files
p2ns/includes/admin/admin-backend/routes/plugins.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

659 lines
24 KiB
JavaScript

const { getAllPluginDomains, getPlugin, getAllPluginRegistrations, reloadPlugin, stopPlugin, startPlugin } = require('../../../plugins/plugin-handler');
const { logError, logInfo, logDebug } = require('../../../infrastructure/logger');
const { broadcast } = require('../websocket');
const fs = require('fs').promises;
const path = require('path');
async function handlePluginsRoutes(req, res) {
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
const method = req.method;
/**
* Get plugin settings file path
* @param {string} domain - Plugin domain
* @returns {string} Settings file path
*/
function getPluginSettingsPath(domain) {
const settingsDir = path.join(process.cwd(), 'cache', 'plugin-settings');
return path.join(settingsDir, `${domain}.json`);
}
/**
* Load plugin settings from file
* @param {string} domain - Plugin domain
* @returns {Promise<Object>} Plugin settings
*/
async function loadPluginSettings(domain) {
try {
const settingsPath = getPluginSettingsPath(domain);
const data = await fs.readFile(settingsPath, 'utf8');
return JSON.parse(data);
} catch (err) {
if (err.code === 'ENOENT') {
// Settings file doesn't exist yet, return empty object
return {};
}
logError('PluginsRoute', `Error loading settings for ${domain}: ${err.message}`);
return {};
}
}
/**
* Save plugin settings to file
* @param {string} domain - Plugin domain
* @param {Object} settings - Settings to save
* @returns {Promise<void>}
*/
async function savePluginSettings(domain, settings) {
try {
const settingsPath = getPluginSettingsPath(domain);
const settingsDir = path.dirname(settingsPath);
// Ensure directory exists
await fs.mkdir(settingsDir, { recursive: true });
// Save settings to file
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
logDebug('PluginsRoute', `Saved settings for plugin ${domain}`);
} catch (err) {
logError('PluginsRoute', `Error saving settings for ${domain}: ${err.message}`);
throw err;
}
}
// GET /api/plugins - List all plugins with their info
if (method === 'GET' && urlPath === '/api/plugins') {
try {
// Get all plugin domains from disk (including stopped ones)
const pluginHandler = require('../../../plugins/plugin-handler');
// We need to get all domains from disk, not just loaded ones
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const allDomains = new Set(['p2ns.admin']);
try {
const entries = await fs.readdir(pluginSitesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const domain = entry.name;
const pluginDir = path.join(pluginSitesDir, domain);
const configPath = path.join(pluginDir, 'config.json');
try {
await fs.access(configPath);
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
if (config && config.name && config.version) {
allDomains.add(domain);
}
} catch (err) {
// No valid config
}
}
}
} catch (err) {
// Directory might not exist
}
const pluginDomains = Array.from(allDomains);
const loadedDomains = getAllPluginDomains();
const registrations = getAllPluginRegistrations();
const plugins = await Promise.all(pluginDomains.map(async (domain) => {
const plugin = getPlugin(domain);
// Plugin might be stopped, check if it exists in plugin-sites
const isLoaded = loadedDomains.includes(domain);
if (!plugin) {
// Check if plugin directory exists to show stopped plugin
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
try {
await fs.access(pluginDir);
// Plugin exists but is stopped - load config to show basic info
const configPath = path.join(pluginDir, 'config.json');
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
// Config might not exist
}
return {
domain,
name: config?.name || domain,
version: config?.version || '1.0.0',
description: config?.description || '',
author: config?.author || '',
homepage: config?.homepage || '',
license: config?.license || '',
enabled: config?.enabled !== false, // Default to true if not specified
status: 'stopped',
hasHandler: false,
hasWww: false,
hasDatabase: false,
actions: [],
settings: {}
};
} catch (err) {
return null; // Plugin directory doesn't exist
}
}
// Load saved settings
const savedSettings = await loadPluginSettings(domain);
// Merge saved settings with registered settings (saved values override defaults)
const registeredSettings = registrations[domain]?.settings || {};
const mergedSettings = {};
for (const [key, config] of Object.entries(registeredSettings)) {
mergedSettings[key] = {
...config,
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
};
}
return {
domain,
name: plugin.config?.name || domain,
version: plugin.config?.version || '1.0.0',
description: plugin.config?.description || '',
author: plugin.config?.author || '',
homepage: plugin.config?.homepage || '',
license: plugin.config?.license || '',
icon: plugin.config?.icon || null,
enabled: plugin.config?.enabled !== false, // Default to true if not specified
status: plugin.handler ? 'loaded' : 'static',
hasHandler: !!plugin.handler,
hasWww: !!plugin.wwwDir,
hasDatabase: !!plugin.db,
actions: registrations[domain]?.actions || [],
settings: mergedSettings
};
}));
const filteredPlugins = plugins.filter(Boolean);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ plugins: filteredPlugins }));
} catch (err) {
logError('PluginsRoute', `Error listing plugins: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to list plugins' }));
}
return true;
}
// GET /api/plugins/:domain - Get specific plugin info
if (method === 'GET' && urlPath.startsWith('/api/plugins/') && !urlPath.includes('/actions/') && !urlPath.includes('/settings') && !urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
const plugin = getPlugin(domain);
if (!plugin) {
// Check if plugin exists but is stopped
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
try {
await fs.access(pluginDir);
const configPath = path.join(pluginDir, 'config.json');
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
// Config might not exist
}
const pluginInfo = {
domain,
name: config?.name || domain,
version: config?.version || '1.0.0',
description: config?.description || '',
author: config?.author || '',
homepage: config?.homepage || '',
license: config?.license || '',
status: 'stopped',
hasHandler: false,
hasWww: false,
hasDatabase: false,
pluginDir,
actions: [],
settings: {}
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(pluginInfo));
return true;
} catch (err) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin not found' }));
return true;
}
}
const registrations = getAllPluginRegistrations();
// Load saved settings
const savedSettings = await loadPluginSettings(domain);
// Merge saved settings with registered settings
const registeredSettings = registrations[domain]?.settings || {};
const mergedSettings = {};
for (const [key, config] of Object.entries(registeredSettings)) {
mergedSettings[key] = {
...config,
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
};
}
const pluginInfo = {
domain,
name: plugin.config?.name || domain,
version: plugin.config?.version || '1.0.0',
description: plugin.config?.description || '',
author: plugin.config?.author || '',
homepage: plugin.config?.homepage || '',
license: plugin.config?.license || '',
status: plugin.handler ? 'loaded' : 'static',
hasHandler: !!plugin.handler,
hasWww: !!plugin.wwwDir,
hasDatabase: !!plugin.db,
pluginDir: plugin.pluginDir,
actions: registrations[domain]?.actions || [],
settings: mergedSettings
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(pluginInfo));
} catch (err) {
logError('PluginsRoute', `Error getting plugin info: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to get plugin info' }));
}
return true;
}
// POST /api/plugins/:domain/reload - Reload a plugin
if (method === 'POST' && urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/reload')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
logInfo('PluginsRoute', `Reloading plugin ${domain} via API`);
const reloadedPlugin = await reloadPlugin(domain);
if (!reloadedPlugin) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to reload plugin' }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} reloaded successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error reloading plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to reload plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/stop - Stop a plugin
if (method === 'POST' && urlPath.includes('/stop')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/stop')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Check if this is a system plugin that cannot be stopped
const SYSTEM_PLUGINS = ['global.profile'];
if (SYSTEM_PLUGINS.includes(domain)) {
logWarn('PluginsRoute', `Cannot stop system plugin ${domain}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Cannot stop system plugin: ${domain}. This plugin is required by the system.`,
domain,
isSystemPlugin: true
}));
return true;
}
logInfo('PluginsRoute', `Stopping plugin ${domain} via API`);
try {
const success = await stopPlugin(domain);
if (!success) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to stop plugin' }));
return true;
}
} catch (err) {
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} stopped successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to stop plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/toggle - Enable/disable a plugin
if (method === 'POST' && urlPath.includes('/toggle')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/toggle')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Read request body to get enabled state
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
const data = body ? JSON.parse(body) : {};
const enabled = data.enabled !== undefined ? data.enabled : true;
// Check if this is a system plugin that cannot be disabled
const SYSTEM_PLUGINS = ['global.profile'];
if (!enabled && SYSTEM_PLUGINS.includes(domain)) {
logWarn('PluginsRoute', `Cannot disable system plugin ${domain}`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Cannot disable system plugin: ${domain}. This plugin is required by the system.`,
domain,
isSystemPlugin: true
}));
return true;
}
logInfo('PluginsRoute', `${enabled ? 'Enabling' : 'Disabling'} plugin ${domain} via API`);
// Get plugin directory
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
const pluginDir = path.join(pluginSitesDir, domain);
const configPath = path.join(pluginDir, 'config.json');
// Read current config
let config = {};
try {
const configData = await fs.readFile(configPath, 'utf8');
config = JSON.parse(configData);
} catch (err) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin config.json not found' }));
return true;
}
// Update enabled flag (system plugins are always enabled)
if (SYSTEM_PLUGINS.includes(domain)) {
config.enabled = true;
} else {
config.enabled = enabled;
}
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
logInfo('PluginsRoute', `Updated config.json for ${domain}: enabled=${enabled}`);
// Apply the enabled/disabled state by reloading, starting, or stopping the plugin
const pluginHandler = require('../../../plugins/plugin-handler');
const plugin = pluginHandler.getPlugin(domain);
if (enabled) {
// Enabling the plugin
if (plugin) {
// Plugin is currently loaded - reload it to ensure it's properly enabled
// This will read the fresh config.json with enabled=true
const reloaded = await pluginHandler.reloadPlugin(domain);
if (!reloaded) {
// Reload failed (might be disabled in config still) - try starting fresh
logInfo('PluginsRoute', `Reload failed for ${domain}, attempting fresh start`);
await pluginHandler.startPlugin(domain);
}
} else {
// Plugin is not loaded - start it (this will read config.json with enabled=true)
await pluginHandler.startPlugin(domain);
}
} else {
// Disabling the plugin
if (plugin) {
// Plugin is loaded - stop it (this will clear caches and remove from internal domains)
await pluginHandler.stopPlugin(domain);
} else {
// Plugin is not loaded - just clear caches to remove from internal domains
pluginHandler.clearInternalDomainsCache();
try {
const { invalidateEntriesCache } = require('../../../core/core');
invalidateEntriesCache();
logDebug('PluginsRoute', 'DNS cache invalidated after disabling plugin');
} catch (err) {
logDebug('PluginsRoute', `Could not invalidate DNS cache: ${err.message}`);
}
// Update proxy server certificates to remove internal domain
try {
const { updateProxyServerCertificates } = require('../../../networking/internal_domains_proxy');
await updateProxyServerCertificates();
logDebug('PluginsRoute', 'Proxy server certificates updated after disabling plugin');
} catch (err) {
logWarn('PluginsRoute', `Could not update proxy server certificates: ${err.message}`);
}
}
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} ${enabled ? 'enabled' : 'disabled'} successfully`,
domain,
enabled
}));
} catch (err) {
logError('PluginsRoute', `Error toggling plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to toggle plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/start - Start a plugin
if (method === 'POST' && urlPath.includes('/start')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/start')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
logInfo('PluginsRoute', `Starting plugin ${domain} via API`);
const startedPlugin = await startPlugin(domain);
if (!startedPlugin) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to start plugin' }));
return true;
}
// Broadcast update to connected admin clients
broadcast({
type: 'update-plugins',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Plugin ${domain} started successfully`,
domain
}));
} catch (err) {
logError('PluginsRoute', `Error starting plugin: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to start plugin: ${err.message}` }));
}
return true;
}
// POST /api/plugins/:domain/actions/:actionName - Execute a plugin action
if (method === 'POST' && urlPath.includes('/actions/')) {
try {
const match = urlPath.match(/\/api\/plugins\/([^\/]+)\/actions\/(.+)$/);
if (!match) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid action path' }));
return true;
}
const domain = match[1];
const actionName = match[2];
const plugin = getPlugin(domain);
if (!plugin) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin not found' }));
return true;
}
const registrations = getAllPluginRegistrations();
const action = registrations[domain]?.actions?.find(a => a.name === actionName);
if (!action || !action.handler) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Action not found' }));
return true;
}
// Parse request body for parameters
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
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(validation.params);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
result
}));
} catch (err) {
logError('PluginsRoute', `Error executing action ${actionName} for plugin ${domain}: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: `Action execution failed: ${err.message}`
}));
}
} catch (err) {
logError('PluginsRoute', `Error handling action request: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to execute action' }));
}
return true;
}
// POST /api/plugins/:domain/settings - Update plugin settings
if (method === 'POST' && urlPath.includes('/settings') && !urlPath.includes('/actions/')) {
try {
const domain = urlPath.split('/api/plugins/')[1]?.split('/settings')[0];
if (!domain) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Domain parameter required' }));
return true;
}
// Parse request body
let body = '';
req.on('data', chunk => { body += chunk.toString(); });
await new Promise(resolve => req.on('end', resolve));
const settings = body ? JSON.parse(body) : {};
// Save settings to file
await savePluginSettings(domain, settings);
// Broadcast update
broadcast({
type: 'update-plugin-settings',
domain
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Settings updated for plugin ${domain}`
}));
} catch (err) {
logError('PluginsRoute', `Error updating plugin settings: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to update settings' }));
}
return true;
}
return false;
}
module.exports = { handlePluginsRoutes };