Files
meteor/packages/ddp-server/stream_server.js
David Glasser b9f2bb255d Don't allow websockets to indefinitely wait for DDP handshake
In general, we try to avoid allowing TCP connections to be open with no
traffic on it indefinitely.  We place timeouts on incoming HTTP
connections in webapp_server.js (which we adjust to longer values when
there's an HTTP request pending), and once a DDP connection is fully
established we require heartbeats.

However, if the incoming connection is a websocket, the faye-websocket
package used by SockJS calls setTimeout(0) on the underlying socket when
it initializes the WebSocket object:

https://github.com/faye/faye-websocket-node/blob/3148348a3/lib/faye/websocket/api.js#L111

So if a client does the WebSocket handshake with the server but never
sends a valid DDP connect message, the socket can be held open
indefinitely. (To add insult to injury, a 1MB Buffer object is retained
on such sockets due to something in the faye-websocket code, at least on
older versions of Node like 0.10.)

This commit restores a timeout on the socket for this in-between period.

(We actually saw this issue in production on the Meteor Developer
Accounts server --- hundreds of such broken connections would accumulate
over time.  This may be triggered by a particular setup we use involving
proxies for the accounts server, or it may be a more generally
applicable issue.)
2016-11-23 13:10:58 -08:00

185 lines
7.4 KiB
JavaScript

var url = Npm.require('url');
// By default, we use the permessage-deflate extension with default
// configuration. If $SERVER_WEBSOCKET_COMPRESSION is set, then it must be valid
// JSON. If it represents a falsey value, then we do not use permessage-deflate
// at all; otherwise, the JSON value is used as an argument to deflate's
// configure method; see
// https://github.com/faye/permessage-deflate-node/blob/master/README.md
//
// (We do this in an _.once instead of at startup, because we don't want to
// crash the tool during isopacket load if your JSON doesn't parse. This is only
// a problem because the tool has to load the DDP server code just in order to
// be a DDP client; see https://github.com/meteor/meteor/issues/3452 .)
var websocketExtensions = _.once(function () {
var extensions = [];
var websocketCompressionConfig = process.env.SERVER_WEBSOCKET_COMPRESSION
? JSON.parse(process.env.SERVER_WEBSOCKET_COMPRESSION) : {};
if (websocketCompressionConfig) {
extensions.push(Npm.require('permessage-deflate').configure(
websocketCompressionConfig
));
}
return extensions;
});
var pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || "";
StreamServer = function () {
var self = this;
self.registration_callbacks = [];
self.open_sockets = [];
// Because we are installing directly onto WebApp.httpServer instead of using
// WebApp.app, we have to process the path prefix ourselves.
self.prefix = pathPrefix + '/sockjs';
RoutePolicy.declare(self.prefix + '/', 'network');
// set up sockjs
var sockjs = Npm.require('sockjs');
var serverOptions = {
prefix: self.prefix,
log: function() {},
// this is the default, but we code it explicitly because we depend
// on it in stream_client:HEARTBEAT_TIMEOUT
heartbeat_delay: 45000,
// The default disconnect_delay is 5 seconds, but if the server ends up CPU
// bound for that much time, SockJS might not notice that the user has
// reconnected because the timer (of disconnect_delay ms) can fire before
// SockJS processes the new connection. Eventually we'll fix this by not
// combining CPU-heavy processing with SockJS termination (eg a proxy which
// converts to Unix sockets) but for now, raise the delay.
disconnect_delay: 60 * 1000,
// Set the USE_JSESSIONID environment variable to enable setting the
// JSESSIONID cookie. This is useful for setting up proxies with
// session affinity.
jsessionid: !!process.env.USE_JSESSIONID
};
// If you know your server environment (eg, proxies) will prevent websockets
// from ever working, set $DISABLE_WEBSOCKETS and SockJS clients (ie,
// browsers) will not waste time attempting to use them.
// (Your server will still have a /websocket endpoint.)
if (process.env.DISABLE_WEBSOCKETS) {
serverOptions.websocket = false;
} else {
serverOptions.faye_server_options = {
extensions: websocketExtensions()
};
}
self.server = sockjs.createServer(serverOptions);
// Install the sockjs handlers, but we want to keep around our own particular
// request handler that adjusts idle timeouts while we have an outstanding
// request. This compensates for the fact that sockjs removes all listeners
// for "request" to add its own.
WebApp.httpServer.removeListener(
'request', WebApp._timeoutAdjustmentRequestCallback);
self.server.installHandlers(WebApp.httpServer);
WebApp.httpServer.addListener(
'request', WebApp._timeoutAdjustmentRequestCallback);
// Support the /websocket endpoint
self._redirectWebsocketEndpoint();
self.server.on('connection', function (socket) {
// We want to make sure that if a client connects to us and does the initial
// Websocket handshake but never gets to the DDP handshake, that we
// eventually kill the socket. Once the DDP handshake happens, DDP
// heartbeating will work. And before the Websocket handshake, the timeouts
// we set at the server level in webapp_server.js will work. But
// faye-websocket calls setTimeout(0) on any socket it takes over, so there
// is an "in between" state where this doesn't happen. We work around this
// by explicitly setting the socket timeout to a relatively large time here,
// and setting it back to zero when we set up the heartbeat in
// livedata_server.js.
socket.setWebsocketTimeout = function (timeout) {
if ((socket.protocol === 'websocket' ||
socket.protocol === 'websocket-raw')
&& socket._session.recv) {
socket._session.recv.connection.setTimeout(timeout);
}
};
socket.setWebsocketTimeout(45 * 1000);
socket.send = function (data) {
socket.write(data);
};
socket.on('close', function () {
self.open_sockets = _.without(self.open_sockets, socket);
});
self.open_sockets.push(socket);
// XXX COMPAT WITH 0.6.6. Send the old style welcome message, which
// will force old clients to reload. Remove this once we're not
// concerned about people upgrading from a pre-0.7.0 release. Also,
// remove the clause in the client that ignores the welcome message
// (livedata_connection.js)
socket.send(JSON.stringify({server_id: "0"}));
// call all our callbacks when we get a new socket. they will do the
// work of setting up handlers and such for specific messages.
_.each(self.registration_callbacks, function (callback) {
callback(socket);
});
});
};
_.extend(StreamServer.prototype, {
// call my callback when a new socket connects.
// also call it for all current connections.
register: function (callback) {
var self = this;
self.registration_callbacks.push(callback);
_.each(self.all_sockets(), function (socket) {
callback(socket);
});
},
// get a list of all sockets
all_sockets: function () {
var self = this;
return _.values(self.open_sockets);
},
// Redirect /websocket to /sockjs/websocket in order to not expose
// sockjs to clients that want to use raw websockets
_redirectWebsocketEndpoint: function() {
var self = this;
// Unfortunately we can't use a connect middleware here since
// sockjs installs itself prior to all existing listeners
// (meaning prior to any connect middlewares) so we need to take
// an approach similar to overshadowListeners in
// https://github.com/sockjs/sockjs-node/blob/cf820c55af6a9953e16558555a31decea554f70e/src/utils.coffee
_.each(['request', 'upgrade'], function(event) {
var httpServer = WebApp.httpServer;
var oldHttpServerListeners = httpServer.listeners(event).slice(0);
httpServer.removeAllListeners(event);
// request and upgrade have different arguments passed but
// we only care about the first one which is always request
var newListener = function(request /*, moreArguments */) {
// Store arguments for use within the closure below
var args = arguments;
// Rewrite /websocket and /websocket/ urls to /sockjs/websocket while
// preserving query string.
var parsedUrl = url.parse(request.url);
if (parsedUrl.pathname === pathPrefix + '/websocket' ||
parsedUrl.pathname === pathPrefix + '/websocket/') {
parsedUrl.pathname = self.prefix + '/websocket';
request.url = url.format(parsedUrl);
}
_.each(oldHttpServerListeners, function(oldListener) {
oldListener.apply(httpServer, args);
});
};
httpServer.addListener(event, newListener);
});
}
});