diff options
Diffstat (limited to 'devtools/server/socket')
-rw-r--r-- | devtools/server/socket/moz.build | 11 | ||||
-rw-r--r-- | devtools/server/socket/tests/chrome/chrome.ini | 3 | ||||
-rw-r--r-- | devtools/server/socket/tests/chrome/test_websocket-server.html | 88 | ||||
-rw-r--r-- | devtools/server/socket/websocket-server.js | 250 |
4 files changed, 352 insertions, 0 deletions
diff --git a/devtools/server/socket/moz.build b/devtools/server/socket/moz.build new file mode 100644 index 0000000000..1d3766c192 --- /dev/null +++ b/devtools/server/socket/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"] + +DevToolsModules( + "websocket-server.js", +) diff --git a/devtools/server/socket/tests/chrome/chrome.ini b/devtools/server/socket/tests/chrome/chrome.ini new file mode 100644 index 0000000000..cfbb612954 --- /dev/null +++ b/devtools/server/socket/tests/chrome/chrome.ini @@ -0,0 +1,3 @@ +[DEFAULT] +tags = devtools +[test_websocket-server.html] diff --git a/devtools/server/socket/tests/chrome/test_websocket-server.html b/devtools/server/socket/tests/chrome/test_websocket-server.html new file mode 100644 index 0000000000..b809aca0a5 --- /dev/null +++ b/devtools/server/socket/tests/chrome/test_websocket-server.html @@ -0,0 +1,88 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Mozilla Bug</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +window.onload = function() { + const { require } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs"); + const WebSocketServer = require("devtools/server/socket/websocket-server"); + + const ServerSocket = Components.Constructor("@mozilla.org/network/server-socket;1", + "nsIServerSocket", "init"); + + add_task(async function() { + // Create a TCP server on auto-assigned port + const server = new ServerSocket(-1, true, -1); + ok(server, `Launched WebSocket server on port ${server.port}`); + + let input, output; + + server.asyncListen({ + async onSocketAccepted(socket, transport) { + info("Accepted incoming connection"); + input = transport.openInputStream(0, 0, 0); + output = transport.openOutputStream(0, 0, 0); + + // Perform the WebSocket handshake + const webSocket = await WebSocketServer.accept(transport, input, output); + + // Echo the received message back to the sender + webSocket.onmessage = ({ data }) => { + info("Server received message, echoing back"); + webSocket.send(data); + }; + }, + + onStopListening(socket, status) { + info(`Server stopped listening with status: ${status}`); + }, + }); + + SimpleTest.registerCleanupFunction(() => { + server.close(); + }); + + // Create client connection + const client = await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://localhost:${server.port}`); + socket.onopen = () => resolve(socket); + socket.onerror = reject; + }); + ok(client, `Created WebSocket connection to port ${server.port}`); + + // Create a promise that resolves when the WebSocket closes + const closed = new Promise(resolve => { + client.onclose = resolve; + }); + + // Send a message + const message = "hello there"; + client.send(message); + info("Sent a message to server"); + // Check that it was echoed + const echoedMessage = await new Promise((resolve, reject) => { + client.onmessage = ({ data }) => resolve(data); + client.onerror = reject; + }); + + is(echoedMessage, message, "Echoed message matches"); + + // Close the connection + client.close(); + await closed; + + // Prevent leaking the streams by closing them before test ends + input.close(); + output.close(); + }); +}; +</script> +</body> +</html> diff --git a/devtools/server/socket/websocket-server.js b/devtools/server/socket/websocket-server.js new file mode 100644 index 0000000000..4236ec2921 --- /dev/null +++ b/devtools/server/socket/websocket-server.js @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { executeSoon } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + delimitedRead, +} = require("resource://devtools/shared/transport/stream-utils.js"); +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); +const threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + +// Limit the header size to put an upper bound on allocated memory +const HEADER_MAX_LEN = 8000; + +/** + * Read a line from async input stream and return promise that resolves to the line once + * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error. + */ +function readLine(input) { + return new Promise((resolve, reject) => { + let line = ""; + const wait = () => { + input.asyncWait( + stream => { + try { + const amountToRead = HEADER_MAX_LEN - line.length; + line += delimitedRead(input, "\n", amountToRead); + + if (line.endsWith("\n")) { + resolve(line.trimRight()); + return; + } + + if (line.length >= HEADER_MAX_LEN) { + throw new Error( + `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes` + ); + } + + wait(); + } catch (ex) { + reject(ex); + } + }, + 0, + 0, + threadManager.currentThread + ); + }; + + wait(); + }); +} + +/** + * Write a string of bytes to async output stream and return promise that resolves once + * all data has been written. Doesn't do any utf-16/utf-8 conversion - the string is + * treated as an array of bytes. + */ +function writeString(output, data) { + return new Promise((resolve, reject) => { + const wait = () => { + if (data.length === 0) { + resolve(); + return; + } + + output.asyncWait( + stream => { + try { + const written = output.write(data, data.length); + data = data.slice(written); + wait(); + } catch (ex) { + reject(ex); + } + }, + 0, + 0, + threadManager.currentThread + ); + }; + + wait(); + }); +} + +/** + * Read HTTP request from async input stream. + * @return Request line (string) and Map of header names and values. + */ +const readHttpRequest = async function (input) { + let requestLine = ""; + const headers = new Map(); + + while (true) { + const line = await readLine(input); + if (!line.length) { + break; + } + + if (!requestLine) { + requestLine = line; + } else { + const colon = line.indexOf(":"); + if (colon == -1) { + throw new Error(`Malformed HTTP header: ${line}`); + } + + const name = line.slice(0, colon).toLowerCase(); + const value = line.slice(colon + 1).trim(); + headers.set(name, value); + } + } + + return { requestLine, headers }; +}; + +/** + * Write HTTP response (array of strings) to async output stream. + */ +function writeHttpResponse(output, response) { + const responseString = response.join("\r\n") + "\r\n\r\n"; + return writeString(output, responseString); +} + +/** + * Process the WebSocket handshake headers and return the key to be sent in + * Sec-WebSocket-Accept response header. + */ +function processRequest({ requestLine, headers }) { + const [method, path] = requestLine.split(" "); + if (method !== "GET") { + throw new Error("The handshake request must use GET method"); + } + + if (path !== "/") { + throw new Error("The handshake request has unknown path"); + } + + const upgrade = headers.get("upgrade"); + if (!upgrade || upgrade !== "websocket") { + throw new Error("The handshake request has incorrect Upgrade header"); + } + + const connection = headers.get("connection"); + if ( + !connection || + !connection + .split(",") + .map(t => t.trim()) + .includes("Upgrade") + ) { + throw new Error("The handshake request has incorrect Connection header"); + } + + const version = headers.get("sec-websocket-version"); + if (!version || version !== "13") { + throw new Error( + "The handshake request must have Sec-WebSocket-Version: 13" + ); + } + + // Compute the accept key + const key = headers.get("sec-websocket-key"); + if (!key) { + throw new Error( + "The handshake request must have a Sec-WebSocket-Key header" + ); + } + + return { acceptKey: computeKey(key) }; +} + +function computeKey(key) { + const str = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + const data = Array.from(str, ch => ch.charCodeAt(0)); + const hash = new CryptoHash("sha1"); + hash.update(data, data.length); + return hash.finish(true); +} + +/** + * Perform the server part of a WebSocket opening handshake on an incoming connection. + */ +const serverHandshake = async function (input, output) { + // Read the request + const request = await readHttpRequest(input); + + try { + // Check and extract info from the request + const { acceptKey } = processRequest(request); + + // Send response headers + await writeHttpResponse(output, [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptKey}`, + ]); + } catch (error) { + // Send error response in case of error + await writeHttpResponse(output, ["HTTP/1.1 400 Bad Request"]); + throw error; + } +}; + +/** + * Accept an incoming WebSocket server connection. + * Takes an established nsISocketTransport in the parameters. + * Performs the WebSocket handshake and waits for the WebSocket to open. + * Returns Promise with a WebSocket ready to send and receive messages. + */ +const accept = async function (transport, input, output) { + await serverHandshake(input, output); + + const transportProvider = { + setListener(upgradeListener) { + // The onTransportAvailable callback shouldn't be called synchronously. + executeSoon(() => { + upgradeListener.onTransportAvailable(transport, input, output); + }); + }, + }; + + return new Promise((resolve, reject) => { + const socket = WebSocket.createServerWebSocket( + null, + [], + transportProvider, + "" + ); + socket.addEventListener("close", () => { + input.close(); + output.close(); + }); + + socket.onopen = () => resolve(socket); + socket.onerror = err => reject(err); + }); +}; + +exports.accept = accept; |