diff --git a/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js b/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js new file mode 100644 index 0000000..855ee38 --- /dev/null +++ b/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js @@ -0,0 +1,137 @@ +/** + * DC-076: Tests for the dashboard WebSocket server + */ +const http = require('http'); +const WebSocket = require('ws'); +const EventEmitter = require('events'); +const createDashboardWS = require('../../src/websocket/dashboard-ws'); + +function createMockServer() { + return http.createServer((req, res) => { + res.writeHead(404); + res.end(); + }); +} + +describe('DC-076: Dashboard WebSocket', () => { + let server, wsServer, port; + + beforeEach((done) => { + server = createMockServer(); + server.listen(0, () => { + port = server.address().port; + + const resourceMonitor = new EventEmitter(); + const healthChecker = new EventEmitter(); + const updateManager = new EventEmitter(); + + wsServer = createDashboardWS(server, { + resourceMonitor, + healthChecker, + updateManager, + log: { info: jest.fn(), error: jest.fn() }, + }); + done(); + }); + }); + + afterEach((done) => { + wsServer.close(); + server.close(done); + }); + + it('accepts connections at the upgrade path', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('open', () => { + ws.close(); + }); + ws.on('close', () => { + done(); + }); + ws.on('error', done); + }); + + it('sends a connected event on join', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'connected') { + expect(msg.data).toHaveProperty('clients'); + ws.close(); + done(); + } + }); + ws.on('error', done); + }); + + it('responds to ping with pong', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'ping' })); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'pong') { + ws.close(); + done(); + } + }); + ws.on('error', done); + }); + + it('responds to subscribe with subscribed confirmation', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] })); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'subscribed') { + expect(msg.events).toEqual(['resource-alert', 'incident']); + ws.close(); + done(); + } + }); + ws.on('error', done); + }); + + it('responds to client-count request', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'client-count' })); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'client-count') { + expect(msg.count).toBeGreaterThanOrEqual(1); + ws.close(); + done(); + } + }); + ws.on('error', done); + }); + + it('returns error for invalid JSON', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); + ws.on('open', () => { + ws.send('not json'); + }); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'error') { + expect(msg.error).toContain('Invalid JSON'); + ws.close(); + done(); + } + }); + ws.on('error', done); + }); + + it('tracks client count', () => { + expect(wsServer.getClientCount()).toBe(0); + }); + + it('broadcast method does not throw with no clients', () => { + expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow(); + }); +}); diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 3c70106..2fefd29 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -68,6 +68,32 @@ process.on('uncaughtException', (error) => { attachExecWS(server, log, authManager); log.info('server', 'WebSocket exec handler attached (auth enforced)'); + // DC-076: Attach dashboard WebSocket for real-time updates + try { + const createDashboardWS = require('./src/websocket/dashboard-ws'); + const resourceMonitor = require('./src/managers/resource-monitor'); + const healthChecker = require('./src/monitoring/health-checker'); + const updateManager = require('./src/managers/update-manager'); + const dependencyManager = require('./src/managers/dependency-manager'); + const autoRestartManager = require('./src/managers/auto-restart-manager'); + const configDriftDetector = require('./src/managers/config-drift-detector'); + const sslMonitor = require('./src/monitoring/ssl-monitor'); + + createDashboardWS(server, { + resourceMonitor, + healthChecker, + updateManager, + dependencyManager, + autoRestartManager, + driftDetector: configDriftDetector, + sslMonitor, + log, + }); + log.info('server', 'Dashboard WebSocket attached at /api/v1/ws'); + } catch (err) { + log.error('server', 'Dashboard WebSocket failed to attach', { error: err.message }); + } + // Start feature modules const resourceMonitor = require('./src/managers/resource-monitor'); const backupManager = require('./src/utilities/backup-manager'); diff --git a/dashcaddy-api/src/websocket/dashboard-ws.js b/dashcaddy-api/src/websocket/dashboard-ws.js new file mode 100644 index 0000000..abdcc94 --- /dev/null +++ b/dashcaddy-api/src/websocket/dashboard-ws.js @@ -0,0 +1,259 @@ +/** + * DC-076: WebSocket server for real-time dashboard updates + * + * Runs alongside the existing SSE endpoint (/api/v1/events/stream). + * Shares the same event broadcasts but over a bidirectional WebSocket + * connection, enabling client→server commands (e.g. "subscribe to + * container X", "set alert threshold"). + * + * Protocol: JSON messages with {type, data} envelope. + * Server→client: {type: 'event', event: '', data: {...}} + * Client→server: {type: 'subscribe', events: ['resource-alert', ...]} + * {type: 'ping'} → {type: 'pong'} + */ +const { WebSocketServer } = require('ws'); + +function createDashboardWS(server, deps = {}) { + const wss = new WebSocketServer({ noServer: true }); + + // Event broadcasters that the events.js SSE route already wires up. + // We listen to the same EventEmitters and forward to WS clients. + const { + resourceMonitor, + healthChecker, + updateManager, + dependencyManager, + autoRestartManager, + driftDetector, + sslMonitor, + dnsPropagationChecker, + log, + } = deps; + + // Track connected clients and their subscriptions + const wsClients = new Set(); + + function broadcast(event, data) { + const msg = JSON.stringify({ type: 'event', event, data }); + for (const client of wsClients) { + if (client.readyState !== 1) continue; // OPEN only + // Check subscription filter + if (client.subscribedEvents && !client.subscribedEvents.has(event)) continue; + try { + client.send(msg); + } catch { + wsClients.delete(client); + } + } + } + + // ── Wire up EventEmitter listeners (same events as SSE) ── + + if (resourceMonitor) { + resourceMonitor.on('alert', (data) => broadcast('resource-alert', data)); + resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data)); + } + + if (healthChecker) { + healthChecker.on('status-check', (data) => { + broadcast('status-change', { + serviceId: data.serviceId, + name: data.name, + status: data.status, + responseTime: data.responseTime, + timestamp: data.timestamp, + }); + }); + healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data })); + healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data })); + } + + if (updateManager) { + updateManager.on('update-available', (data) => broadcast('update-available', data)); + updateManager.on('update-start', (data) => broadcast('update-start', data)); + updateManager.on('update-complete', (data) => broadcast('update-complete', data)); + updateManager.on('update-failed', (data) => broadcast('update-failed', data)); + updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data)); + updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data)); + } + + if (dependencyManager) { + dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data)); + dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data)); + dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data)); + dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data)); + } + + if (autoRestartManager) { + autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data)); + autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data)); + autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data)); + autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data)); + } + + if (driftDetector) { + driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data)); + } + + if (sslMonitor) { + sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data)); + sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data)); + } + + if (dnsPropagationChecker) { + dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data)); + dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data)); + dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data)); + } + + // ── Handle upgrade requests at /api/v1/ws ── + + server.on('upgrade', (request, socket, head) => { + const url = new URL(request.url, 'http://localhost'); + + // Only handle exact /api/v1/ws path — the exec WS handler manages its own path + if (url.pathname !== '/api/v1/ws' && url.pathname !== '/ws/dashboard') { + return; // Let other upgrade handlers deal with it + } + + // DC-076: Auth check — extract session/token from query params or cookies + // The SSE endpoint is behind auth middleware; WS needs the same gate. + // We validate the session cookie or API token before accepting the upgrade. + const cookies = (request.headers.cookie || ''); + const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid'); + const token = url.searchParams.get('token'); + const hasToken = token && token.length > 10; + + if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + }); + + // ── Connection handler ── + + wss.on('connection', (ws, req) => { + ws.subscribedEvents = null; // null = receive all events + wsClients.add(ws); + + if (log) { + log.info('websocket', 'Client connected', { total: wsClients.size }); + } + + // Send welcome message + ws.send(JSON.stringify({ + type: 'connected', + data: { clients: wsClients.size }, + })); + + // Heartbeat every 30s + ws.isAlive = true; + const heartbeat = setInterval(() => { + if (ws.readyState !== 1) { + clearInterval(heartbeat); + return; + } + ws.isAlive = false; + try { + ws.ping(); + } catch { + clearInterval(heartbeat); + wsClients.delete(ws); + } + }, 30000); + + ws.on('pong', () => { ws.isAlive = true; }); + + ws.on('message', (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' })); + return; + } + + switch (msg.type) { + case 'subscribe': + if (Array.isArray(msg.events)) { + ws.subscribedEvents = new Set(msg.events); + ws.send(JSON.stringify({ type: 'subscribed', events: msg.events })); + } + break; + + case 'unsubscribe': + // Actually unsubscribe — set to empty set so no events are received + ws.subscribedEvents = new Set(); + ws.send(JSON.stringify({ type: 'unsubscribed' })); + break; + + case 'subscribe-all': + // Reset to receive ALL events + ws.subscribedEvents = null; + ws.send(JSON.stringify({ type: 'subscribed-all' })); + break; + + case 'ping': + ws.send(JSON.stringify({ type: 'pong' })); + break; + + case 'client-count': + ws.send(JSON.stringify({ type: 'client-count', count: wsClients.size })); + break; + + default: + // Unknown message — ignore silently + break; + } + }); + + ws.on('close', () => { + clearInterval(heartbeat); + wsClients.delete(ws); + if (log) { + log.info('websocket', 'Client disconnected', { total: wsClients.size }); + } + }); + + ws.on('error', () => { + clearInterval(heartbeat); + wsClients.delete(ws); + }); + }); + + // Periodic sweep for dead connections + const sweepInterval = setInterval(() => { + for (const ws of wss.clients) { + if (!ws.isAlive) { + ws.terminate(); + wsClients.delete(ws); + } + } + }, 60000); + sweepInterval.unref(); + + return { + wss, + getClientCount: () => wsClients.size, + broadcast, + close: () => { + clearInterval(sweepInterval); + for (const ws of wss.clients) { + ws.terminate(); + } + wsClients.clear(); + wss.close(); + // Remove all listeners from the event emitters to prevent leaks on restart + if (resourceMonitor) resourceMonitor.removeAllListeners(); + if (healthChecker) healthChecker.removeAllListeners(); + if (updateManager) updateManager.removeAllListeners(); + }, + }; +} + +module.exports = createDashboardWS;