diff --git a/README.md b/README.md index 34e34020f..d0b1ac17e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ It is a temporary live demo, all data will be deleted after 10 minutes. Sponsore ## ⭐ Features -- Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / HTTP(s) Json Query / Ping / DNS Record / Push / Steam Game Server / Docker Containers +- Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / HTTP(s) Json Query / Websocket / Ping / DNS Record / Push / Steam Game Server / Docker Containers - Fancy, Reactive, Fast UI/UX - Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications) - 20-second intervals diff --git a/db/knex_migrations/2025-02-15-2312-add-wstest.js b/db/knex_migrations/2025-02-15-2312-add-wstest.js new file mode 100644 index 000000000..966cff13c --- /dev/null +++ b/db/knex_migrations/2025-02-15-2312-add-wstest.js @@ -0,0 +1,15 @@ +// Add websocket URL +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.text("wsurl"); + table.boolean("ws_ignore_headers").notNullable().defaultTo(false); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("wsurl"); + table.dropColumn("ws_ignore_headers"); + }); +}; diff --git a/server/model/monitor.js b/server/model/monitor.js index 3ad8cfafc..77498fa3e 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -96,6 +96,8 @@ class Monitor extends BeanModel { parent: this.parent, childrenIDs: preloadData.childrenIDs.get(this.id) || [], url: this.url, + wsurl: this.wsurl, + wsIgnoreHeaders: this.getWsIgnoreHeaders(), method: this.method, hostname: this.hostname, port: this.port, @@ -255,6 +257,14 @@ class Monitor extends BeanModel { return Boolean(this.ignoreTls); } + /** + * Parse to boolean + * @returns {boolean} Should WS headers be ignored? + */ + getWsIgnoreHeaders() { + return Boolean(this.wsIgnoreHeaders); + } + /** * Parse to boolean * @returns {boolean} Is the monitor in upside down mode? diff --git a/server/monitor-types/websocket.js b/server/monitor-types/websocket.js new file mode 100644 index 000000000..3e84b5b83 --- /dev/null +++ b/server/monitor-types/websocket.js @@ -0,0 +1,75 @@ +const { MonitorType } = require("./monitor-type"); +const WebSocket = require("ws"); +const { UP, DOWN } = require("../../src/util"); +const childProcessAsync = require("promisify-child-process"); + +class websocket extends MonitorType { + name = "websocket"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let statusCode = await this.attemptUpgrade(monitor); + //let statusCode = await this.curlTest(monitor.url); + this.updateStatus(heartbeat, statusCode); + } + + /** + * Attempts to upgrade HTTP/HTTPs connection to Websocket. Use curl to send websocket headers to server and returns response code. Close the connection after 1 second and wrap command in bash to return exit code 0 instead of 28. + * @param {string} url Full URL of Websocket server + * @returns {string} HTTP response code + */ + async curlTest(url) { + let res = await childProcessAsync.spawn("bash", [ "-c", "curl -s -o /dev/null -w '%{http_code}' --http1.1 -N --max-time 1 -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: test' -H 'Sec-WebSocket-Version: 13' " + url + " || true" ], { + timeout: 5000, + encoding: "utf8", + }); + return res.stdout.toString(); + } + + /** + * Checks if status code is 1000(Normal Closure) and sets status and message + * @param {object} heartbeat The heartbeat object to update. + * @param {[ string, int ]} status Array containing a status message and response code + * @returns {void} + */ + updateStatus(heartbeat, [ message, code ]) { + heartbeat.status = code === 1000 ? UP : DOWN; + heartbeat.msg = message; + } + + /** + * Uses the builtin Websocket API to establish a connection to target server + * @param {object} monitor The monitor object for input parameters. + * @returns {[ string, int ]} Array containing a status message and response code + */ + async attemptUpgrade(monitor) { + return new Promise((resolve) => { + const ws = new WebSocket(monitor.wsurl); + + ws.addEventListener("open", (event) => { + ws.close(1000); + }); + + ws.onerror = (error) => { + // console.log(error.message); + // Give user the choice to ignore Sec-WebSocket-Accept header + if (monitor.wsIgnoreHeaders && error.message === "Invalid Sec-WebSocket-Accept header") { + resolve([ "101 - OK", 1000 ]); + } + resolve([ error.message, error.code ]); + }; + + ws.onclose = (event) => { + // console.log(event.message); + // console.log(event.code); + resolve([ "101 - OK", event.code ]); + }; + }); + } +} + +module.exports = { + websocket, +}; diff --git a/server/server.js b/server/server.js index ec5ad49f6..0e19b6e62 100644 --- a/server/server.js +++ b/server/server.js @@ -790,6 +790,8 @@ let needSetup = false; bean.parent = monitor.parent; bean.type = monitor.type; bean.url = monitor.url; + bean.wsurl = monitor.wsurl; + bean.wsIgnoreHeaders = monitor.wsIgnoreHeaders; bean.method = monitor.method; bean.body = monitor.body; bean.headers = monitor.headers; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 062f098d7..dcf803270 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -111,6 +111,7 @@ class UptimeKumaServer { // Set Monitor Types UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); + UptimeKumaServer.monitorTypeList["websocket"] = new websocket(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); @@ -549,6 +550,7 @@ module.exports = { // Must be at the end to avoid circular dependencies const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); const { TailscalePing } = require("./monitor-types/tailscale-ping"); +const { websocket } = require("./monitor-types/websocket"); const { DnsMonitorType } = require("./monitor-types/dns"); const { MqttMonitorType } = require("./monitor-types/mqtt"); const { SNMPMonitorType } = require("./monitor-types/snmp"); diff --git a/src/lang/en.json b/src/lang/en.json index e215f1031..81adc0736 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -86,6 +86,7 @@ "ignoreTLSError": "Ignore TLS/SSL errors for HTTPS websites", "ignoreTLSErrorGeneral": "Ignore TLS/SSL error for connection", "upsideDownModeDescription": "Flip the status upside down. If the service is reachable, it is DOWN.", + "wsIgnoreHeadersDescription": "Test non compliant Websocket servers that don't respond with correct headers.", "maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.", "Upside Down Mode": "Upside Down Mode", "Max. Redirects": "Max. Redirects", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index a83f91cab..679eac298 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -46,6 +46,10 @@ HTTP(s) - Browser Engine (Chrome/Chromium) (Beta) + + + Websocket Upgrade + @@ -118,6 +122,12 @@ + + + {{ $t("URL") }} + + + {{ $t("URL") }} @@ -621,6 +631,16 @@ + + + + {{ $t("Ignore Server Headers") }} + + + {{ $t("wsIgnoreHeadersDescription") }} + + + @@ -1074,6 +1094,8 @@ const monitorDefaults = { name: "", parent: null, url: "https://", + wsurl: "wss://", + wsIgnoreHeaders: false, method: "GET", interval: 60, retryInterval: 60, @@ -1727,6 +1749,10 @@ message HealthCheckResponse { this.monitor.url = this.monitor.url.trim(); } + if (this.monitor.wsurl) { + this.monitor.wsurl = this.monitor.wsurl.trim(); + } + let createdNewParent = false; if (this.draftGroupName && this.monitor.parent === -1) { diff --git a/test/backend-test/test-websocket.js b/test/backend-test/test-websocket.js new file mode 100644 index 000000000..4ef4b412d --- /dev/null +++ b/test/backend-test/test-websocket.js @@ -0,0 +1,99 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { websocket } = require("../../server/monitor-types/websocket"); +const { UP, DOWN, PENDING } = require("../../src/util"); + +describe("Websocket Test", { +}, () => { + test("Non Websocket Server", {}, async () => { + const websocketMonitor = new websocket(); + + const monitor = { + wsurl: "wss://example.org", + wsIgnoreHeaders: false, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await websocketMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, DOWN); + assert.strictEqual(heartbeat.msg, "Unexpected server response: 200"); + }); + + test("Secure Websocket", async () => { + const websocketMonitor = new websocket(); + + const monitor = { + wsurl: "wss://echo.websocket.org", + wsIgnoreHeaders: false, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await websocketMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "101 - OK"); + }); + + test("Insecure Websocket", { + skip: !!process.env.CI, + }, async () => { + const websocketMonitor = new websocket(); + + const monitor = { + wsurl: "ws://ws.ifelse.io", + wsIgnoreHeaders: false, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await websocketMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "101 - OK"); + }); + + test("Test a non compliant WS server without ignore", async () => { + const websocketMonitor = new websocket(); + + const monitor = { + wsurl: "wss://c.img-cdn.net/yE4s7KehTFyj/", + wsIgnoreHeaders: false, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await websocketMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, DOWN); + assert.strictEqual(heartbeat.msg, "Invalid Sec-WebSocket-Accept header"); + }); + + test("Test a non compliant WS server with ignore", async () => { + const websocketMonitor = new websocket(); + + const monitor = { + wsurl: "wss://c.img-cdn.net/yE4s7KehTFyj/", + wsIgnoreHeaders: true, + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + await websocketMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "101 - OK"); + }); +});