You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
535 lines
16 KiB
535 lines
16 KiB
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */ |
|
|
|
'use strict'; |
|
|
|
const EventEmitter = require('events'); |
|
const http = require('http'); |
|
const https = require('https'); |
|
const net = require('net'); |
|
const tls = require('tls'); |
|
const { createHash } = require('crypto'); |
|
|
|
const extension = require('./extension'); |
|
const PerMessageDeflate = require('./permessage-deflate'); |
|
const subprotocol = require('./subprotocol'); |
|
const WebSocket = require('./websocket'); |
|
const { GUID, kWebSocket } = require('./constants'); |
|
|
|
const keyRegex = /^[+/0-9A-Za-z]{22}==$/; |
|
|
|
const RUNNING = 0; |
|
const CLOSING = 1; |
|
const CLOSED = 2; |
|
|
|
/** |
|
* Class representing a WebSocket server. |
|
* |
|
* @extends EventEmitter |
|
*/ |
|
class WebSocketServer extends EventEmitter { |
|
/** |
|
* Create a `WebSocketServer` instance. |
|
* |
|
* @param {Object} options Configuration options |
|
* @param {Number} [options.backlog=511] The maximum length of the queue of |
|
* pending connections |
|
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to |
|
* track clients |
|
* @param {Function} [options.handleProtocols] A hook to handle protocols |
|
* @param {String} [options.host] The hostname where to bind the server |
|
* @param {Number} [options.maxPayload=104857600] The maximum allowed message |
|
* size |
|
* @param {Boolean} [options.noServer=false] Enable no server mode |
|
* @param {String} [options.path] Accept only connections matching this path |
|
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable |
|
* permessage-deflate |
|
* @param {Number} [options.port] The port where to bind the server |
|
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S |
|
* server to use |
|
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or |
|
* not to skip UTF-8 validation for text and close messages |
|
* @param {Function} [options.verifyClient] A hook to reject connections |
|
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` |
|
* class to use. It must be the `WebSocket` class or class that extends it |
|
* @param {Function} [callback] A listener for the `listening` event |
|
*/ |
|
constructor(options, callback) { |
|
super(); |
|
|
|
options = { |
|
maxPayload: 100 * 1024 * 1024, |
|
skipUTF8Validation: false, |
|
perMessageDeflate: false, |
|
handleProtocols: null, |
|
clientTracking: true, |
|
verifyClient: null, |
|
noServer: false, |
|
backlog: null, // use default (511 as implemented in net.js) |
|
server: null, |
|
host: null, |
|
path: null, |
|
port: null, |
|
WebSocket, |
|
...options |
|
}; |
|
|
|
if ( |
|
(options.port == null && !options.server && !options.noServer) || |
|
(options.port != null && (options.server || options.noServer)) || |
|
(options.server && options.noServer) |
|
) { |
|
throw new TypeError( |
|
'One and only one of the "port", "server", or "noServer" options ' + |
|
'must be specified' |
|
); |
|
} |
|
|
|
if (options.port != null) { |
|
this._server = http.createServer((req, res) => { |
|
const body = http.STATUS_CODES[426]; |
|
|
|
res.writeHead(426, { |
|
'Content-Length': body.length, |
|
'Content-Type': 'text/plain' |
|
}); |
|
res.end(body); |
|
}); |
|
this._server.listen( |
|
options.port, |
|
options.host, |
|
options.backlog, |
|
callback |
|
); |
|
} else if (options.server) { |
|
this._server = options.server; |
|
} |
|
|
|
if (this._server) { |
|
const emitConnection = this.emit.bind(this, 'connection'); |
|
|
|
this._removeListeners = addListeners(this._server, { |
|
listening: this.emit.bind(this, 'listening'), |
|
error: this.emit.bind(this, 'error'), |
|
upgrade: (req, socket, head) => { |
|
this.handleUpgrade(req, socket, head, emitConnection); |
|
} |
|
}); |
|
} |
|
|
|
if (options.perMessageDeflate === true) options.perMessageDeflate = {}; |
|
if (options.clientTracking) { |
|
this.clients = new Set(); |
|
this._shouldEmitClose = false; |
|
} |
|
|
|
this.options = options; |
|
this._state = RUNNING; |
|
} |
|
|
|
/** |
|
* Returns the bound address, the address family name, and port of the server |
|
* as reported by the operating system if listening on an IP socket. |
|
* If the server is listening on a pipe or UNIX domain socket, the name is |
|
* returned as a string. |
|
* |
|
* @return {(Object|String|null)} The address of the server |
|
* @public |
|
*/ |
|
address() { |
|
if (this.options.noServer) { |
|
throw new Error('The server is operating in "noServer" mode'); |
|
} |
|
|
|
if (!this._server) return null; |
|
return this._server.address(); |
|
} |
|
|
|
/** |
|
* Stop the server from accepting new connections and emit the `'close'` event |
|
* when all existing connections are closed. |
|
* |
|
* @param {Function} [cb] A one-time listener for the `'close'` event |
|
* @public |
|
*/ |
|
close(cb) { |
|
if (this._state === CLOSED) { |
|
if (cb) { |
|
this.once('close', () => { |
|
cb(new Error('The server is not running')); |
|
}); |
|
} |
|
|
|
process.nextTick(emitClose, this); |
|
return; |
|
} |
|
|
|
if (cb) this.once('close', cb); |
|
|
|
if (this._state === CLOSING) return; |
|
this._state = CLOSING; |
|
|
|
if (this.options.noServer || this.options.server) { |
|
if (this._server) { |
|
this._removeListeners(); |
|
this._removeListeners = this._server = null; |
|
} |
|
|
|
if (this.clients) { |
|
if (!this.clients.size) { |
|
process.nextTick(emitClose, this); |
|
} else { |
|
this._shouldEmitClose = true; |
|
} |
|
} else { |
|
process.nextTick(emitClose, this); |
|
} |
|
} else { |
|
const server = this._server; |
|
|
|
this._removeListeners(); |
|
this._removeListeners = this._server = null; |
|
|
|
// |
|
// The HTTP/S server was created internally. Close it, and rely on its |
|
// `'close'` event. |
|
// |
|
server.close(() => { |
|
emitClose(this); |
|
}); |
|
} |
|
} |
|
|
|
/** |
|
* See if a given request should be handled by this server instance. |
|
* |
|
* @param {http.IncomingMessage} req Request object to inspect |
|
* @return {Boolean} `true` if the request is valid, else `false` |
|
* @public |
|
*/ |
|
shouldHandle(req) { |
|
if (this.options.path) { |
|
const index = req.url.indexOf('?'); |
|
const pathname = index !== -1 ? req.url.slice(0, index) : req.url; |
|
|
|
if (pathname !== this.options.path) return false; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
/** |
|
* Handle a HTTP Upgrade request. |
|
* |
|
* @param {http.IncomingMessage} req The request object |
|
* @param {(net.Socket|tls.Socket)} socket The network socket between the |
|
* server and client |
|
* @param {Buffer} head The first packet of the upgraded stream |
|
* @param {Function} cb Callback |
|
* @public |
|
*/ |
|
handleUpgrade(req, socket, head, cb) { |
|
socket.on('error', socketOnError); |
|
|
|
const key = req.headers['sec-websocket-key']; |
|
const version = +req.headers['sec-websocket-version']; |
|
|
|
if (req.method !== 'GET') { |
|
const message = 'Invalid HTTP method'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); |
|
return; |
|
} |
|
|
|
if (req.headers.upgrade.toLowerCase() !== 'websocket') { |
|
const message = 'Invalid Upgrade header'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); |
|
return; |
|
} |
|
|
|
if (!key || !keyRegex.test(key)) { |
|
const message = 'Missing or invalid Sec-WebSocket-Key header'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); |
|
return; |
|
} |
|
|
|
if (version !== 8 && version !== 13) { |
|
const message = 'Missing or invalid Sec-WebSocket-Version header'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); |
|
return; |
|
} |
|
|
|
if (!this.shouldHandle(req)) { |
|
abortHandshake(socket, 400); |
|
return; |
|
} |
|
|
|
const secWebSocketProtocol = req.headers['sec-websocket-protocol']; |
|
let protocols = new Set(); |
|
|
|
if (secWebSocketProtocol !== undefined) { |
|
try { |
|
protocols = subprotocol.parse(secWebSocketProtocol); |
|
} catch (err) { |
|
const message = 'Invalid Sec-WebSocket-Protocol header'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); |
|
return; |
|
} |
|
} |
|
|
|
const secWebSocketExtensions = req.headers['sec-websocket-extensions']; |
|
const extensions = {}; |
|
|
|
if ( |
|
this.options.perMessageDeflate && |
|
secWebSocketExtensions !== undefined |
|
) { |
|
const perMessageDeflate = new PerMessageDeflate( |
|
this.options.perMessageDeflate, |
|
true, |
|
this.options.maxPayload |
|
); |
|
|
|
try { |
|
const offers = extension.parse(secWebSocketExtensions); |
|
|
|
if (offers[PerMessageDeflate.extensionName]) { |
|
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); |
|
extensions[PerMessageDeflate.extensionName] = perMessageDeflate; |
|
} |
|
} catch (err) { |
|
const message = |
|
'Invalid or unacceptable Sec-WebSocket-Extensions header'; |
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); |
|
return; |
|
} |
|
} |
|
|
|
// |
|
// Optionally call external client verification handler. |
|
// |
|
if (this.options.verifyClient) { |
|
const info = { |
|
origin: |
|
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], |
|
secure: !!(req.socket.authorized || req.socket.encrypted), |
|
req |
|
}; |
|
|
|
if (this.options.verifyClient.length === 2) { |
|
this.options.verifyClient(info, (verified, code, message, headers) => { |
|
if (!verified) { |
|
return abortHandshake(socket, code || 401, message, headers); |
|
} |
|
|
|
this.completeUpgrade( |
|
extensions, |
|
key, |
|
protocols, |
|
req, |
|
socket, |
|
head, |
|
cb |
|
); |
|
}); |
|
return; |
|
} |
|
|
|
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); |
|
} |
|
|
|
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); |
|
} |
|
|
|
/** |
|
* Upgrade the connection to WebSocket. |
|
* |
|
* @param {Object} extensions The accepted extensions |
|
* @param {String} key The value of the `Sec-WebSocket-Key` header |
|
* @param {Set} protocols The subprotocols |
|
* @param {http.IncomingMessage} req The request object |
|
* @param {(net.Socket|tls.Socket)} socket The network socket between the |
|
* server and client |
|
* @param {Buffer} head The first packet of the upgraded stream |
|
* @param {Function} cb Callback |
|
* @throws {Error} If called more than once with the same socket |
|
* @private |
|
*/ |
|
completeUpgrade(extensions, key, protocols, req, socket, head, cb) { |
|
// |
|
// Destroy the socket if the client has already sent a FIN packet. |
|
// |
|
if (!socket.readable || !socket.writable) return socket.destroy(); |
|
|
|
if (socket[kWebSocket]) { |
|
throw new Error( |
|
'server.handleUpgrade() was called more than once with the same ' + |
|
'socket, possibly due to a misconfiguration' |
|
); |
|
} |
|
|
|
if (this._state > RUNNING) return abortHandshake(socket, 503); |
|
|
|
const digest = createHash('sha1') |
|
.update(key + GUID) |
|
.digest('base64'); |
|
|
|
const headers = [ |
|
'HTTP/1.1 101 Switching Protocols', |
|
'Upgrade: websocket', |
|
'Connection: Upgrade', |
|
`Sec-WebSocket-Accept: ${digest}` |
|
]; |
|
|
|
const ws = new this.options.WebSocket(null); |
|
|
|
if (protocols.size) { |
|
// |
|
// Optionally call external protocol selection handler. |
|
// |
|
const protocol = this.options.handleProtocols |
|
? this.options.handleProtocols(protocols, req) |
|
: protocols.values().next().value; |
|
|
|
if (protocol) { |
|
headers.push(`Sec-WebSocket-Protocol: ${protocol}`); |
|
ws._protocol = protocol; |
|
} |
|
} |
|
|
|
if (extensions[PerMessageDeflate.extensionName]) { |
|
const params = extensions[PerMessageDeflate.extensionName].params; |
|
const value = extension.format({ |
|
[PerMessageDeflate.extensionName]: [params] |
|
}); |
|
headers.push(`Sec-WebSocket-Extensions: ${value}`); |
|
ws._extensions = extensions; |
|
} |
|
|
|
// |
|
// Allow external modification/inspection of handshake headers. |
|
// |
|
this.emit('headers', headers, req); |
|
|
|
socket.write(headers.concat('\r\n').join('\r\n')); |
|
socket.removeListener('error', socketOnError); |
|
|
|
ws.setSocket(socket, head, { |
|
maxPayload: this.options.maxPayload, |
|
skipUTF8Validation: this.options.skipUTF8Validation |
|
}); |
|
|
|
if (this.clients) { |
|
this.clients.add(ws); |
|
ws.on('close', () => { |
|
this.clients.delete(ws); |
|
|
|
if (this._shouldEmitClose && !this.clients.size) { |
|
process.nextTick(emitClose, this); |
|
} |
|
}); |
|
} |
|
|
|
cb(ws, req); |
|
} |
|
} |
|
|
|
module.exports = WebSocketServer; |
|
|
|
/** |
|
* Add event listeners on an `EventEmitter` using a map of <event, listener> |
|
* pairs. |
|
* |
|
* @param {EventEmitter} server The event emitter |
|
* @param {Object.<String, Function>} map The listeners to add |
|
* @return {Function} A function that will remove the added listeners when |
|
* called |
|
* @private |
|
*/ |
|
function addListeners(server, map) { |
|
for (const event of Object.keys(map)) server.on(event, map[event]); |
|
|
|
return function removeListeners() { |
|
for (const event of Object.keys(map)) { |
|
server.removeListener(event, map[event]); |
|
} |
|
}; |
|
} |
|
|
|
/** |
|
* Emit a `'close'` event on an `EventEmitter`. |
|
* |
|
* @param {EventEmitter} server The event emitter |
|
* @private |
|
*/ |
|
function emitClose(server) { |
|
server._state = CLOSED; |
|
server.emit('close'); |
|
} |
|
|
|
/** |
|
* Handle socket errors. |
|
* |
|
* @private |
|
*/ |
|
function socketOnError() { |
|
this.destroy(); |
|
} |
|
|
|
/** |
|
* Close the connection when preconditions are not fulfilled. |
|
* |
|
* @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request |
|
* @param {Number} code The HTTP response status code |
|
* @param {String} [message] The HTTP response body |
|
* @param {Object} [headers] Additional HTTP response headers |
|
* @private |
|
*/ |
|
function abortHandshake(socket, code, message, headers) { |
|
// |
|
// The socket is writable unless the user destroyed or ended it before calling |
|
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user |
|
// error. Handling this does not make much sense as the worst that can happen |
|
// is that some of the data written by the user might be discarded due to the |
|
// call to `socket.end()` below, which triggers an `'error'` event that in |
|
// turn causes the socket to be destroyed. |
|
// |
|
message = message || http.STATUS_CODES[code]; |
|
headers = { |
|
Connection: 'close', |
|
'Content-Type': 'text/html', |
|
'Content-Length': Buffer.byteLength(message), |
|
...headers |
|
}; |
|
|
|
socket.once('finish', socket.destroy); |
|
|
|
socket.end( |
|
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + |
|
Object.keys(headers) |
|
.map((h) => `${h}: ${headers[h]}`) |
|
.join('\r\n') + |
|
'\r\n\r\n' + |
|
message |
|
); |
|
} |
|
|
|
/** |
|
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least |
|
* one listener for it, otherwise call `abortHandshake()`. |
|
* |
|
* @param {WebSocketServer} server The WebSocket server |
|
* @param {http.IncomingMessage} req The request object |
|
* @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request |
|
* @param {Number} code The HTTP response status code |
|
* @param {String} message The HTTP response body |
|
* @private |
|
*/ |
|
function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) { |
|
if (server.listenerCount('wsClientError')) { |
|
const err = new Error(message); |
|
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); |
|
|
|
server.emit('wsClientError', err, socket, req); |
|
} else { |
|
abortHandshake(socket, code, message); |
|
} |
|
}
|
|
|