Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions db/knex_migrations/2025-02-15-2312-add-wstest.js
Original file line number Diff line number Diff line change
@@ -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");
});
};
10 changes: 10 additions & 0 deletions server/model/monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?
Expand Down
75 changes: 75 additions & 0 deletions server/monitor-types/websocket.js
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 2 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions server/uptime-kuma-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
<option value="real-browser">
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
</option>

<option value="websocket">
Websocket Upgrade
</option>
</optgroup>

<optgroup :label="$t('Passive Monitor Type')">
Expand Down Expand Up @@ -118,6 +122,12 @@
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required data-testid="url-input">
</div>

<!-- Websocket -->
<div v-if="monitor.type === 'websocket'" class="my-3">
<label for="wsurl" class="form-label">{{ $t("URL") }}</label>
<input id="wsurl" v-model="monitor.wsurl" type="wsurl" class="form-control" pattern="wss?://.+" required data-testid="url-input">
</div>

<!-- gRPC URL -->
<div v-if="monitor.type === 'grpc-keyword' " class="my-3">
<label for="grpc-url" class="form-label">{{ $t("URL") }}</label>
Expand Down Expand Up @@ -621,6 +631,16 @@
</div>
</div>

<div v-if="monitor.type === 'websocket' " class="my-3 form-check">
<input id="ws-ignore-headers" v-model="monitor.wsIgnoreHeaders" class="form-check-input" type="checkbox">
<label class="form-check-label" for="ws-ignore-headers">
{{ $t("Ignore Server Headers") }}
</label>
<div class="form-text">
{{ $t("wsIgnoreHeadersDescription") }}
</div>
</div>

<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'redis' " class="my-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls">
Expand Down Expand Up @@ -1074,6 +1094,8 @@ const monitorDefaults = {
name: "",
parent: null,
url: "https://",
wsurl: "wss://",
wsIgnoreHeaders: false,
method: "GET",
interval: 60,
retryInterval: 60,
Expand Down Expand Up @@ -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) {
Expand Down
99 changes: 99 additions & 0 deletions test/backend-test/test-websocket.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading