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.
244 lines
7.4 KiB
JavaScript
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 };
|