diff --git a/client/src/App.js b/client/src/App.js index 85be7dc..a48ded1 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -129,6 +129,22 @@ function Dashboard() { /> +
+ + +
+
=4.0.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2197,6 +2208,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 13f188d..dace783 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "node-cron": "^3.0.3", "axios": "^1.6.2", "nodemailer": "^6.9.7", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "ping": "^0.4.4", + "ws": "^8.17.1" }, "devDependencies": { "nodemon": "^3.0.2", diff --git a/server/database.js b/server/database.js index baad159..a810620 100644 --- a/server/database.js +++ b/server/database.js @@ -41,4 +41,12 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id); `); +// Lightweight migration: add details column for per-check metadata if missing +try { + db.prepare("ALTER TABLE checks ADD COLUMN details TEXT").run(); + // Column added +} catch (err) { + // Ignore if the column already exists +} + module.exports = db; diff --git a/server/monitorService.js b/server/monitorService.js index 3de0fbd..ecfe799 100644 --- a/server/monitorService.js +++ b/server/monitorService.js @@ -1,26 +1,77 @@ const axios = require('axios'); +const net = require('net'); +const tls = require('tls'); +const dns = require('dns').promises; +const WebSocket = require('ws'); +const ping = require('ping'); const db = require('./database'); +const DEFAULT_PORTS = { + http: 80, + https: 443, + tcp: 80, + tls: 443, + websocket: 80, +}; + +function parseHostPort(urlString, fallbackPort) { + try { + const u = new URL(urlString.startsWith('http') || urlString.startsWith('ws') ? urlString : `http://${urlString}`); + return { host: u.hostname, port: u.port ? Number(u.port) : fallbackPort }; + } catch (err) { + // Fallback if url is just host:port + const [host, port] = urlString.split(':'); + return { host, port: port ? Number(port) : fallbackPort }; + } +} + class MonitorService { async checkResource(resource) { + const type = (resource.type || 'http').toLowerCase(); const startTime = Date.now(); - let result = { + const base = { resource_id: resource.id, status: 'down', response_time: null, status_code: null, error_message: null, + details: null, }; + switch (type) { + case 'http': + case 'https': + case 'health': + return this.checkHttp(resource, startTime, base); + case 'tcp': + return this.checkTcp(resource, startTime, base); + case 'tls': + return this.checkTls(resource, startTime, base); + case 'dns': + return this.checkDns(resource, startTime, base); + case 'websocket': + case 'ws': + case 'wss': + return this.checkWebSocket(resource, startTime, base); + case 'icmp': + case 'ping': + return this.checkIcmp(resource, startTime, base); + default: + // Fallback to HTTP if unknown + return this.checkHttp(resource, startTime, base); + } + } + + async checkHttp(resource, startTime, base) { + let result = { ...base }; try { const response = await axios.get(resource.url, { timeout: resource.timeout, - validateStatus: () => true, // Don't throw on any status + validateStatus: () => true, + maxRedirects: 5, }); - const responseTime = Date.now() - startTime; const isUp = response.status >= 200 && response.status < 400; - result = { ...result, status: isUp ? 'up' : 'down', @@ -32,21 +83,168 @@ class MonitorService { result.error_message = error.message; result.response_time = Date.now() - startTime; } - return result; } + async checkTcp(resource, startTime, base) { + const { host, port } = parseHostPort(resource.url, DEFAULT_PORTS.tcp); + return new Promise(resolve => { + const socket = new net.Socket(); + let finished = false; + + const done = (status, error_message = null) => { + if (finished) return; + finished = true; + socket.destroy(); + resolve({ + ...base, + status, + response_time: Date.now() - startTime, + status_code: null, + error_message, + details: JSON.stringify({ host, port }), + }); + }; + + socket.setTimeout(resource.timeout || 5000); + socket.once('connect', () => done('up')); + socket.once('timeout', () => done('down', 'TCP timeout')); + socket.once('error', (err) => done('down', err.message)); + socket.connect(port, host); + }); + } + + async checkTls(resource, startTime, base) { + const { host, port } = parseHostPort(resource.url, DEFAULT_PORTS.tls); + return new Promise(resolve => { + const socket = tls.connect({ host, port, servername: host, timeout: resource.timeout || 5000 }, () => { + const cert = socket.getPeerCertificate(); + const now = Date.now(); + const expires = new Date(cert.valid_to).getTime(); + const daysRemaining = Math.round((expires - now) / (1000 * 60 * 60 * 24)); + const validHost = cert.subjectaltname ? cert.subjectaltname.includes(host) : true; + + const status = daysRemaining > 0 && validHost ? 'up' : 'down'; + resolve({ + ...base, + status, + response_time: Date.now() - startTime, + error_message: status === 'up' ? null : 'TLS invalid or expired', + details: JSON.stringify({ + issuer: cert.issuer?.CN, + subject: cert.subject?.CN, + valid_to: cert.valid_to, + days_remaining: daysRemaining, + host_matches: validHost, + }), + }); + socket.end(); + }); + + socket.on('error', err => { + resolve({ + ...base, + status: 'down', + response_time: Date.now() - startTime, + error_message: err.message, + }); + }); + + socket.setTimeout(resource.timeout || 5000, () => { + resolve({ + ...base, + status: 'down', + response_time: Date.now() - startTime, + error_message: 'TLS timeout', + }); + socket.destroy(); + }); + }); + } + + async checkDns(resource, startTime, base) { + const { host } = parseHostPort(resource.url, DEFAULT_PORTS.http); + try { + const records = await dns.lookup(host, { all: true }); + return { + ...base, + status: 'up', + response_time: Date.now() - startTime, + details: JSON.stringify({ answers: records }), + }; + } catch (err) { + return { + ...base, + status: 'down', + response_time: Date.now() - startTime, + error_message: err.message, + }; + } + } + + async checkWebSocket(resource, startTime, base) { + return new Promise(resolve => { + let finished = false; + + const done = (status, error_message = null) => { + if (finished) return; + finished = true; + resolve({ + ...base, + status, + response_time: Date.now() - startTime, + error_message, + }); + }; + + try { + const ws = new WebSocket(resource.url, { handshakeTimeout: resource.timeout || 5000 }); + ws.on('open', () => { + done('up'); + ws.close(); + }); + ws.on('error', (err) => done('down', err.message)); + ws.on('close', () => done('up')); + } catch (err) { + done('down', err.message); + } + }); + } + + async checkIcmp(resource, startTime, base) { + const { host } = parseHostPort(resource.url, DEFAULT_PORTS.http); + try { + const res = await ping.promise.probe(host, { timeout: (resource.timeout || 5000) / 1000 }); + const status = res.alive ? 'up' : 'down'; + return { + ...base, + status, + response_time: res.time || Date.now() - startTime, + error_message: res.alive ? null : res.output, + details: JSON.stringify({ packetLoss: res.packetLoss, time: res.time }), + }; + } catch (err) { + return { + ...base, + status: 'down', + response_time: Date.now() - startTime, + error_message: err.message, + }; + } + } + saveCheck(check) { const stmt = db.prepare(` - INSERT INTO checks (resource_id, status, response_time, status_code, error_message) - VALUES (?, ?, ?, ?, ?) + INSERT INTO checks (resource_id, status, response_time, status_code, error_message, details) + VALUES (?, ?, ?, ?, ?, ?) `); stmt.run( check.resource_id, check.status, check.response_time, check.status_code, - check.error_message + check.error_message, + check.details || null ); }