diff --git a/server/monitor-types/websocket-upgrade.js b/server/monitor-types/websocket-upgrade.js
index 762e8df3b..32c7c15da 100644
--- a/server/monitor-types/websocket-upgrade.js
+++ b/server/monitor-types/websocket-upgrade.js
@@ -1,6 +1,26 @@
const { MonitorType } = require("./monitor-type");
const WebSocket = require("ws");
const { UP } = require("../../src/util");
+const { checkStatusCode } = require("../util-server");
+// Define closing error codes https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
+const WS_ERR_CODE = {
+ 1002: "Protocol error",
+ 1003: "Unsupported Data",
+ 1005: "No Status Received",
+ 1006: "Abnormal Closure",
+ 1007: "Invalid frame payload data",
+ 1008: "Policy Violation",
+ 1009: "Message Too Big",
+ 1010: "Mandatory Extension Missing",
+ 1011: "Internal Error",
+ 1012: "Service Restart",
+ 1013: "Try Again Later",
+ 1014: "Bad Gateway",
+ 1015: "TLS Handshake Failed",
+ 3000: "Unauthorized",
+ 3003: "Forbidden",
+ 3008: "Timeout",
+};
class WebSocketMonitorType extends MonitorType {
name = "websocket-upgrade";
@@ -11,24 +31,36 @@ class WebSocketMonitorType extends MonitorType {
async check(monitor, heartbeat, _server) {
const [ message, code ] = await this.attemptUpgrade(monitor);
- if (code === 1000) {
- heartbeat.status = UP;
- heartbeat.msg = message;
- } else {
- throw new Error(message);
+ if (typeof code !== "undefined") {
+ // If returned status code matches user controlled accepted status code(default 1000), return success
+ if (checkStatusCode(code, JSON.parse(monitor.accepted_statuscodes_json))) {
+ heartbeat.status = UP;
+ heartbeat.msg = message;
+ return; // success at this point
+ }
+
+ // Throw an error using friendly name if defined, fallback to generic msg
+ throw new Error(WS_ERR_CODE[code] || `Unexpected status code: ${code}`);
}
+ // If no close code, then an error has occurred, display to user
+ if (typeof message !== "undefined") {
+ throw new Error(`${message}`);
+ }
+ // Throw generic error if nothing is defined, should never happen
+ throw new Error("Unknown Websocket Error");
}
/**
- * Uses the builtin Websocket API to establish a connection to target server
+ * Uses the ws Node.js library 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) => {
- let ws;
- //If user selected a subprotocol, sets Sec-WebSocket-Protocol header. Subprotocol Identifier column: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name
- ws = monitor.wsSubprotocol === "" ? new WebSocket(monitor.url) : new WebSocket(monitor.url, monitor.wsSubprotocol);
+ const timeoutMs = (monitor.timeout ?? 20) * 1000;
+ // If user inputs subprotocol(s), convert to array, set Sec-WebSocket-Protocol header, timeout in ms. Subprotocol Identifier column: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name
+ const subprotocol = monitor.wsSubprotocol ? monitor.wsSubprotocol.replace(/\s/g, "").split(",") : undefined;
+ const ws = new WebSocket(monitor.url, subprotocol, { handshakeTimeout: timeoutMs });
ws.addEventListener("open", (event) => {
// Immediately close the connection
@@ -36,9 +68,10 @@ class WebSocketMonitorType extends MonitorType {
});
ws.onerror = (error) => {
- // Give user the choice to ignore Sec-WebSocket-Accept header
+ // Give user the choice to ignore Sec-WebSocket-Accept header for non compliant servers
+ // Header in HTTP 101 Switching Protocols response from server, technically already upgraded to WS
if (monitor.wsIgnoreSecWebsocketAcceptHeader && error.message === "Invalid Sec-WebSocket-Accept header") {
- resolve([ "101 - OK", 1000 ]);
+ resolve([ "1000 - OK", 1000 ]);
return;
}
// Upgrade failed, return message to user
@@ -46,8 +79,8 @@ class WebSocketMonitorType extends MonitorType {
};
ws.onclose = (event) => {
- // Upgrade success, connection closed successfully
- resolve([ "101 - OK", event.code ]);
+ // Return the close code, if connection didn't close cleanly, return the reason if present
+ resolve([ event.wasClean ? event.code.toString() + " - OK" : event.reason, event.code ]);
};
});
}
diff --git a/src/lang/en.json b/src/lang/en.json
index dc7f5daa8..3d01267e7 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -90,39 +90,9 @@
"upsideDownModeDescription": "Flip the status upside down. If the service is reachable, it is DOWN.",
"ignoreSecWebsocketAcceptHeaderDescription": "Allows the server to not reply with Sec-WebSocket-Accept header, if the websocket upgrade succeeds.",
"Ignore Sec-WebSocket-Accept header": "Ignore {0} header",
- "wsSubprotocolDescription": "For more information on subprotocols, please consult the {documentation}",
- "WebSocket Application Messaging Protocol": "WAMP (The WebSocket Application Messaging Protocol)",
- "Session Initiation Protocol": "WebSocket Transport for SIP (Session Initiation Protocol)",
- "Subprotocol": "Subprotocol",
- "Network API for Notification Channel": "OMA RESTful Network API for Notification Channel",
- "Web Process Control Protocol": "Web Process Control Protocol (WPCP)",
- "Advanced Message Queuing Protocol": "Advanced Message Queuing Protocol (AMQP) 1.0+",
- "jsflow": "jsFlow pubsub/queue Protocol",
- "Reverse Web Process Control": "Reverse Web Process Control Protocol (RWPCP)",
- "Extensible Messaging and Presence Protocol": "WebSocket Transport for the Extensible Messaging and Presence Protocol (XMPP)",
- "Smart Home IP": "SHIP - Smart Home IP",
- "Miele Cloud Connect Protocol": "Miele Cloud Connect Protocol",
- "Push Channel Protocol": "Push Channel Protocol",
- "Message Session Relay Protocol": "WebSocket Transport for MSRP (Message Session Relay Protocol)",
- "Binary Floor Control Protocol": "WebSocket Transport for BFCP (Binary Floor Control Protocol)",
- "Softvelum Low Delay Protocol": "Softvelum Low Delay Protocol",
- "OPC UA Connection Protocol": "OPC UA Connection Protocol",
- "OPC UA JSON Encoding": "OPC UA JSON Encoding",
- "Swindon Web Server Protocol": "Swindon Web Server Protocol (JSON encoding)",
- "Broadband Forum User Services Platform": "USP (Broadband Forum User Services Platform)",
- "Constrained Application Protocol": "Constrained Application Protocol (CoAP)",
- "Softvelum WebSocket signaling protocol": "Softvelum WebSocket Signaling Protocol",
- "Cobra Real Time Messaging Protocol": "Cobra Real Time Messaging Protocol",
- "Declarative Resource Protocol": "Declarative Resource Protocol",
- "BACnet Secure Connect Hub Connection": "BACnet Secure Connect Hub Connection",
- "BACnet Secure Connect Direct Connection": "BACnet Secure Connect Direct Connection",
- "WebSocket Transport for JMAP": "WebSocket Transport for JMAP (JSON Meta Application Protocol)",
- "ITU-T T.140 Real-Time Text": "ITU-T T.140 Real-Time Text",
- "Done.best IoT Protocol": "Done.best IoT Protocol",
- "Collection Update": "The Collection Update Websocket Subprotocol",
- "Text IRC Protocol": "Text IRC Protocol",
- "Binary IRC Protocol": "Binary IRC Protocol",
- "Penguin Statistics Live Protocol v3": "Penguin Statistics Live Protocol v3 (Protobuf encoding)",
+ "wsSubprotocolDescription": "Enter a comma delimited list of subprotocols. For more information on subprotocols, please consult the {documentation}",
+ "wsCodeDescription": "For more information on status codes, please consult {rfc6455}",
+ "Subprotocol(s)": "Subprotocol(s)",
"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 8c7e5cd21..2927b9dd2 100644
--- a/src/pages/EditMonitor.vue
+++ b/src/pages/EditMonitor.vue
@@ -142,73 +142,8 @@
-
-
+
+
{{ $t('documentationOf', ['IANA']) }}
@@ -767,8 +702,8 @@
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ $t("acceptedStatusCodesDescription") }}
+
+
+
+ RFC 6455
+
+
+
+
+
@@ -1398,6 +1363,7 @@ export default {
},
hasDomain: false,
acceptedStatusCodeOptions: [],
+ acceptedWebsocketCodeOptions: [],
dnsresolvetypeOptions: [],
kafkaSaslMechanismOptions: [],
ipOrHostnameRegexPattern: hostNameRegexPattern(),
@@ -1770,6 +1736,7 @@ message HealthCheckResponse {
"monitor.type"(newType, oldType) {
if (oldType && this.monitor.type === "websocket-upgrade") {
this.monitor.url = "wss://";
+ this.monitor.accepted_statuscodes = [ "1000" ];
}
if (this.monitor.type === "push") {
if (! this.monitor.pushToken) {
@@ -1877,6 +1844,8 @@ message HealthCheckResponse {
"500-599",
];
+ let acceptedWebsocketCodeOptions = [];
+
let dnsresolvetypeOptions = [
"A",
"AAAA",
@@ -1902,6 +1871,11 @@ message HealthCheckResponse {
acceptedStatusCodeOptions.push(i.toString());
}
+ for (let i = 1000; i <= 4999; i++) {
+ acceptedWebsocketCodeOptions.push(i.toString());
+ }
+
+ this.acceptedWebsocketCodeOptions = acceptedWebsocketCodeOptions;
this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
this.dnsresolvetypeOptions = dnsresolvetypeOptions;
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
diff --git a/test/backend-test/test-websocket.js b/test/backend-test/test-websocket.js
index 33660d134..3eeeb3243 100644
--- a/test/backend-test/test-websocket.js
+++ b/test/backend-test/test-websocket.js
@@ -3,15 +3,34 @@ const { describe, test } = require("node:test");
const assert = require("node:assert");
const { WebSocketMonitorType } = require("../../server/monitor-types/websocket-upgrade");
const { UP, PENDING } = require("../../src/util");
+const net = require("node:net");
+
+/**
+ * Simulates non compliant WS Server, doesnt send Sec-WebSocket-Accept header
+ * @param {number} port Port the server listens on. Defaults to 8080
+ * @returns {Promise} Promise that resolves to the created server once listening
+ */
+function nonCompliantWS(port = 8080) {
+ const srv = net.createServer((socket) => {
+ socket.once("data", (buf) => {
+ socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n\r\n");
+ socket.destroy();
+ });
+ });
+ return new Promise((resolve) => srv.listen(port, () => resolve(srv)));
+}
describe("Websocket Test", {
}, () => {
- test("Non Websocket Server", {}, async () => {
+ test("Non WS Server", {}, async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://example.org",
wsIgnoreSecWebsocketAcceptHeader: false,
+ timeout: 30,
};
const heartbeat = {
@@ -25,12 +44,14 @@ describe("Websocket Test", {
);
});
- test("Secure Websocket", async () => {
+ test("Secure WS", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -39,7 +60,7 @@ describe("Websocket Test", {
};
const expected = {
- msg: "101 - OK",
+ msg: "1000 - OK",
status: UP,
};
@@ -47,7 +68,7 @@ describe("Websocket Test", {
assert.deepStrictEqual(heartbeat, expected);
});
- test("Insecure Websocket", async (t) => {
+ test("Insecure WS", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = new WebSocketServer({ port: 8080 });
@@ -55,6 +76,8 @@ describe("Websocket Test", {
const monitor = {
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: false,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -63,7 +86,7 @@ describe("Websocket Test", {
};
const expected = {
- msg: "101 - OK",
+ msg: "1000 - OK",
status: UP,
};
@@ -71,12 +94,58 @@ describe("Websocket Test", {
assert.deepStrictEqual(heartbeat, expected);
});
- test("Non compliant WS server without IgnoreSecWebsocket", async () => {
+ test("Non compliant WS Server wrong status code", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
- url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
+ url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
+ accepted_statuscodes_json: JSON.stringify([ "1001" ]),
+ timeout: 30,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Unexpected status code: 1000")
+ );
+ });
+
+ test("Secure WS Server no status code", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://echo.websocket.org",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ accepted_statuscodes_json: JSON.stringify([ "" ]),
+ timeout: 30,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new Error("Unexpected status code: 1000")
+ );
+ });
+
+ test("Non compliant WS server without IgnoreSecWebsocket", async (t) => {
+ t.after(() => wss.close());
+ const websocketMonitor = new WebSocketMonitorType();
+ const wss = await nonCompliantWS();
+
+ const monitor = {
+ url: "ws://localhost:8080",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -90,12 +159,16 @@ describe("Websocket Test", {
);
});
- test("Non compliant WS server with IgnoreSecWebsocket", async () => {
+ test("Non compliant WS server with IgnoreSecWebsocket", async (t) => {
+ t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
+ const wss = await nonCompliantWS();
const monitor = {
- url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
+ url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: true,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -104,7 +177,7 @@ describe("Websocket Test", {
};
const expected = {
- msg: "101 - OK",
+ msg: "1000 - OK",
status: UP,
};
@@ -118,6 +191,8 @@ describe("Websocket Test", {
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: true,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -126,7 +201,7 @@ describe("Websocket Test", {
};
const expected = {
- msg: "101 - OK",
+ msg: "1000 - OK",
status: UP,
};
@@ -140,6 +215,8 @@ describe("Websocket Test", {
const monitor = {
url: "wss://example.org",
wsIgnoreSecWebsocketAcceptHeader: true,
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -153,13 +230,15 @@ describe("Websocket Test", {
);
});
- test("Secure Websocket with Subprotocol", async () => {
+ test("Secure WS no subprotocol support", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
wsSubprotocol: "ocpp1.6",
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
};
const heartbeat = {
@@ -172,4 +251,88 @@ describe("Websocket Test", {
new Error("Server sent no subprotocol")
);
});
+
+ test("Multiple subprotocols invalid input", async () => {
+ const websocketMonitor = new WebSocketMonitorType();
+
+ const monitor = {
+ url: "wss://echo.websocket.org",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ wsSubprotocol: " # & ,ocpp2.0 [] , ocpp1.6 , ,, ; ",
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ await assert.rejects(
+ websocketMonitor.check(monitor, heartbeat, {}),
+ new SyntaxError("An invalid or duplicated subprotocol was specified")
+ );
+ });
+
+ test("Insecure WS subprotocol multiple spaces", async (t) => {
+ t.after(() => wss.close());
+ const websocketMonitor = new WebSocketMonitorType();
+ const wss = new WebSocketServer({ port: 8080,
+ handleProtocols: (protocols) => {
+ return Array.from(protocols).includes("test") ? "test" : null;
+ }
+ });
+
+ const monitor = {
+ url: "ws://localhost:8080",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ wsSubprotocol: "invalid , test ",
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "1000 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
+
+ test("Insecure WS supports one subprotocol", async (t) => {
+ t.after(() => wss.close());
+ const websocketMonitor = new WebSocketMonitorType();
+ const wss = new WebSocketServer({ port: 8080,
+ handleProtocols: (protocols) => {
+ return Array.from(protocols).includes("test") ? "test" : null;
+ }
+ });
+
+ const monitor = {
+ url: "ws://localhost:8080",
+ wsIgnoreSecWebsocketAcceptHeader: false,
+ wsSubprotocol: "invalid,test",
+ accepted_statuscodes_json: JSON.stringify([ "1000" ]),
+ timeout: 30,
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ const expected = {
+ msg: "1000 - OK",
+ status: UP,
+ };
+
+ await websocketMonitor.check(monitor, heartbeat, {});
+ assert.deepStrictEqual(heartbeat, expected);
+ });
});