Files
dashcaddy/dashcaddy-api/src/plugins/plugin-manager.js
T
Krystie e99413150e
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):

P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.

P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).

P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.

Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).

Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.

Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
2026-08-16 04:18:07 -07:00

244 lines
7.4 KiB
JavaScript

/**
* DC-080: Plugin/Extension system for DashCaddy
*
* Allows third-party extensions to register:
* - Custom service types with health-check logic
* - Custom notification providers
* - Custom workflow actions
* - Dashboard widgets (via manifest)
*
* Plugins are loaded from the data directory:
* {dataDir}/plugins/{plugin-name}/manifest.json
* {dataDir}/plugins/{plugin-name}/index.js
*
* The manifest.json describes capabilities and permissions.
* The index.js exports hooks that DashCaddy calls at appropriate times.
*
* Security: plugins run in the same process (no sandbox yet). The manifest
* declares required permissions, and the admin must approve on install.
*/
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const PLUGIN_DIR = process.env.PLUGIN_DIR || path.join(process.cwd(), 'data', 'plugins');
const HOOK_TYPES = [
'service:health-check', // Custom health check for a service type
'notification:provider', // Custom notification provider
'workflow:action', // Custom workflow action type
'dashboard:widget', // Custom dashboard widget manifest
'container:pre-deploy', // Hook before container deployment
'container:post-deploy', // Hook after container deployment
'config:validate', // Hook for config validation
];
class PluginManager extends EventEmitter {
constructor({ dataDir, log }) {
super();
this.pluginDir = dataDir ? path.join(dataDir, 'plugins') : PLUGIN_DIR;
this.log = log || console;
this.plugins = new Map(); // name → { manifest, module, hooks }
this.serviceTypes = new Map(); // typeName → pluginName
this.notificationProviders = new Map();
this.workflowActions = new Map();
this.dashboardWidgets = new Map();
this.loaded = false;
}
/**
* Discover and load all plugins from the plugin directory.
*/
async loadAll() {
if (this.loaded) return;
try {
if (!fs.existsSync(this.pluginDir)) {
fs.mkdirSync(this.pluginDir, { recursive: true });
this.log.info('plugins', 'Plugin directory created', { dir: this.pluginDir });
this.loaded = true;
return;
}
const entries = fs.readdirSync(this.pluginDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
try {
await this.loadOne(path.join(this.pluginDir, entry.name));
} catch (err) {
this.log.error('plugins', `Failed to load plugin: ${entry.name}`, { error: err.message });
}
}
this.loaded = true;
this.log.info('plugins', 'All plugins loaded', {
count: this.plugins.size,
serviceTypes: [...this.serviceTypes.keys()],
notificationProviders: [...this.notificationProviders.keys()],
workflowActions: [...this.workflowActions.keys()],
});
} catch (err) {
this.log.error('plugins', err, null, { note: 'Failed to scan plugin directory' });
this.loaded = true; // Don't crash — just run without plugins
}
}
/**
* Load a single plugin from its directory.
*/
async loadOne(pluginPath) {
const manifestPath = path.join(pluginPath, 'manifest.json');
const indexPath = path.join(pluginPath, 'index.js');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found');
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Validate manifest
if (!manifest.name || !manifest.version) {
throw new Error('manifest.json must have name and version');
}
if (this.plugins.has(manifest.name)) {
throw new Error(`Plugin ${manifest.name} already loaded`);
}
// Load the plugin module if it exists
let module = {};
if (fs.existsSync(indexPath)) {
delete require.cache[require.resolve(indexPath)];
module = require(indexPath);
}
// Register hooks
const hooks = {};
if (module.hooks) {
for (const [hookType, fn] of Object.entries(module.hooks)) {
if (HOOK_TYPES.includes(hookType)) {
hooks[hookType] = fn;
this._registerHook(manifest.name, hookType, fn, manifest);
}
}
}
this.plugins.set(manifest.name, { manifest, module, hooks, path: pluginPath });
this.emit('plugin-loaded', manifest);
this.log.info('plugins', `Loaded plugin: ${manifest.name} v${manifest.version}`, {
hooks: Object.keys(hooks),
});
}
_registerHook(pluginName, hookType, fn, manifest) {
switch (hookType) {
case 'service:health-check':
if (manifest.serviceType) {
this.serviceTypes.set(manifest.serviceType, pluginName);
}
break;
case 'notification:provider':
if (manifest.providerName) {
this.notificationProviders.set(manifest.providerName, { pluginName, fn });
}
break;
case 'workflow:action':
if (manifest.actionType) {
this.workflowActions.set(manifest.actionType, { pluginName, fn });
}
break;
case 'dashboard:widget':
if (manifest.widget) {
this.dashboardWidgets.set(manifest.name, { pluginName, manifest: manifest.widget });
}
break;
}
}
/**
* Unload a plugin by name.
*/
unload(name) {
const plugin = this.plugins.get(name);
if (!plugin) return false;
// Clean up registrations
for (const [type, pName] of this.serviceTypes) {
if (pName === name) this.serviceTypes.delete(type);
}
for (const [type, { pluginName }] of this.notificationProviders) {
if (pluginName === name) this.notificationProviders.delete(type);
}
for (const [type, { pluginName }] of this.workflowActions) {
if (pluginName === name) this.workflowActions.delete(type);
}
for (const [wName, { pluginName }] of this.dashboardWidgets) {
if (pluginName === name) this.dashboardWidgets.delete(wName);
}
this.plugins.delete(name);
this.emit('plugin-unloaded', name);
this.log.info('plugins', `Unloaded plugin: ${name}`);
return true;
}
/**
* Execute a plugin hook for a specific type.
*/
async executeHook(hookType, ...args) {
// Try each plugin that registered this hook
const results = [];
for (const [name, plugin] of this.plugins) {
if (plugin.hooks[hookType]) {
try {
const result = await plugin.hooks[hookType](...args);
results.push({ plugin: name, result });
} catch (err) {
this.log.error('plugins', `Hook ${hookType} failed in ${name}`, { error: err.message });
results.push({ plugin: name, error: err.message });
}
}
}
return results;
}
/**
* Get list of loaded plugins with their manifests.
*/
list() {
return [...this.plugins.values()].map(p => ({
name: p.manifest.name,
version: p.manifest.version,
description: p.manifest.description || '',
hooks: Object.keys(p.hooks),
permissions: p.manifest.permissions || [],
}));
}
/**
* Get dashboard widget manifests from plugins.
*/
getWidgets() {
return [...this.dashboardWidgets.values()].map(w => w.manifest);
}
/**
* Get registered service types.
*/
getServiceTypes() {
return [...this.serviceTypes.keys()];
}
/**
* Get registered workflow action types.
*/
getWorkflowActions() {
return [...this.workflowActions.keys()];
}
}
module.exports = { PluginManager, HOOK_TYPES };