From 08a91acd76cd107dc2f3914f9ea7e277bb85206e Mon Sep 17 00:00:00 2001 From: koichik Date: Mon, 9 Jan 2012 03:51:06 +0100 Subject: [PATCH] http: better support for CONNECT method. Introduces 'connect' event on both client (http.ClientRequest) and server (http.Server). Refs: #2259, #2474. Fixes #1576. --- doc/api/http.markdown | 98 ++++++++++++++++++++++++++++++++++++---- lib/http.js | 75 ++++++++++++++++++------------ test/simple/test-http-connect.js | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 37 deletions(-) create mode 100644 test/simple/test-http-connect.js diff --git a/doc/api/http.markdown b/doc/api/http.markdown index 57f13a4..c9a2dc0 100644 --- a/doc/api/http.markdown +++ b/doc/api/http.markdown @@ -66,6 +66,24 @@ request body. Note that when this event is emitted and handled, the `request` event will not be emitted. +### Event: 'connect' + +`function (request, socket, head) { }` + +Emitted each time a client requests a http CONNECT method. If this event isn't +listened for, then clients requesting a CONNECT method will have their +connections closed. + +* `request` is the arguments for the http request, as it is in the request + event. +* `socket` is the network socket between the server and client. +* `head` is an instance of Buffer, the first packet of the tunneling stream, + this may be empty. + +After this event is emitted, the request's socket will not have a `data` +event listener, meaning you will need to bind to it in order to handle data +sent to the server on that socket. + ### Event: 'upgrade' `function (request, socket, head) { }` @@ -74,9 +92,11 @@ Emitted each time a client requests a http upgrade. If this event isn't listened for, then clients requesting an upgrade will have their connections closed. -* `request` is the arguments for the http request, as it is in the request event. +* `request` is the arguments for the http request, as it is in the request + event. * `socket` is the network socket between the server and client. -* `head` is an instance of Buffer, the first packet of the upgraded stream, this may be empty. +* `head` is an instance of Buffer, the first packet of the upgraded stream, + this may be empty. After this event is emitted, the request's socket will not have a `data` event listener, meaning you will need to bind to it in order to handle data @@ -593,6 +613,69 @@ Options: Emitted after a socket is assigned to this request. +### Event: 'connect' + +`function (response, socket, head) { }` + +Emitted each time a server responds to a request with a CONNECT method. If this +event isn't being listened for, clients receiving a CONNECT method will have +their connections closed. + +A client server pair that show you how to listen for the `connect` event. + + var http = require('http'); + var net = require('net'); + var url = require('url'); + + // Create an HTTP tunneling proxy + var proxy = http.createServer(function (req, res) { + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end('okay'); + }); + proxy.on('connect', function(req, cltSocket, head) { + // connect to an origin server + var srvUrl = url.parse('http://' + req.url); + var srvSocket = net.connect(srvUrl.port, srvUrl.hostname, function() { + cltSocket.write('HTTP/1.1 200 Connection Established\r\n' + + 'Proxy-agent: Node-Proxy\r\n' + + '\r\n'); + srvSocket.write(head); + srvSocket.pipe(cltSocket); + cltSocket.pipe(srvSocket); + }); + }); + + // now that proxy is running + proxy.listen(1337, '127.0.0.1', function() { + + // make a request to a tunneling proxy + var options = { + port: 1337, + host: '127.0.0.1', + method: 'CONNECT', + path: 'www.google.com:80' + }; + + var req = http.request(options); + req.end(); + + req.on('connect', function(res, socket, head) { + console.log('got connected!'); + + // make a request over an HTTP tunnel + socket.write('GET / HTTP/1.1\r\n' + + 'Host: www.google.com:80\r\n' + + 'Connection: close\r\n' + + '\r\n'); + socket.on('data', function(chunk) { + console.log(chunk.toString()); + }); + socket.on('end', function() { + proxy.close(); + }); + }); + }); + ### Event: 'upgrade' `function (response, socket, head) { }` @@ -601,25 +684,22 @@ Emitted each time a server responds to a request with an upgrade. If this event isn't being listened for, clients receiving an upgrade header will have their connections closed. -A client server pair that show you how to listen for the `upgrade` event using `http.getAgent`: +A client server pair that show you how to listen for the `upgrade` event. var http = require('http'); - var net = require('net'); // Create an HTTP server var srv = http.createServer(function (req, res) { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('okay'); }); - srv.on('upgrade', function(req, socket, upgradeHead) { + srv.on('upgrade', function(req, socket, head) { socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' + 'Upgrade: WebSocket\r\n' + 'Connection: Upgrade\r\n' + - '\r\n\r\n'); + '\r\n'); - socket.ondata = function(data, start, end) { - socket.write(data.toString('utf8', start, end), 'utf8'); // echo back - }; + socket.pipe(socket); // echo back }); // now that server is running diff --git a/lib/http.js b/lib/http.js index a3ade73..badf1b3 100644 --- a/lib/http.js +++ b/lib/http.js @@ -95,15 +95,16 @@ var parsers = new FreeList('parsers', 1000, function() { parser.incoming.upgrade = info.upgrade; - var isHeadResponse = false; + var skipBody = false; // response to HEAD or CONNECT if (!info.upgrade) { - // For upgraded connections, we'll emit this after parser.execute + // For upgraded connections and CONNECT method request, + // we'll emit this after parser.execute // so that we can capture the first part of the new protocol - isHeadResponse = parser.onIncoming(parser.incoming, info.shouldKeepAlive); + skipBody = parser.onIncoming(parser.incoming, info.shouldKeepAlive); } - return isHeadResponse; + return skipBody; }; parser.onBody = function(b, start, len) { @@ -1072,7 +1073,7 @@ function ClientRequest(options, cb) { new Buffer(options.auth).toString('base64')); } - if (method === 'GET' || method === 'HEAD') { + if (method === 'GET' || method === 'HEAD' || method === 'CONNECT') { self.useChunkedEncodingByDefault = false; } else { self.useChunkedEncodingByDefault = true; @@ -1174,22 +1175,26 @@ ClientRequest.prototype.onSocket = function(socket) { debug('parse error'); socket.destroy(ret); } else if (parser.incoming && parser.incoming.upgrade) { + // Upgrade or CONNECT var bytesParsed = ret; - socket.ondata = null; - socket.onend = null; - var res = parser.incoming; req.res = res; + socket.ondata = null; + socket.onend = null; + parser.finish(); + parsers.free(parser); + // This is start + byteParsed - var upgradeHead = d.slice(start + bytesParsed, end); - if (req.listeners('upgrade').length) { - // Emit 'upgrade' on the Agent. - req.upgraded = true; - req.emit('upgrade', res, socket, upgradeHead); + var bodyHead = d.slice(start + bytesParsed, end); + + var eventName = req.method === 'CONNECT' ? 'connect' : 'upgrade'; + if (req.listeners(eventName).length) { + req.upgradeOrConnect = true; + req.emit(eventName, res, socket, bodyHead); socket.emit('agentRemove'); } else { - // Got upgrade header, but have no handler. + // Got Upgrade header or CONNECT method, but have no handler. socket.destroy(); } } @@ -1235,6 +1240,12 @@ ClientRequest.prototype.onSocket = function(socket) { } req.res = res; + // Responses to CONNECT request is handled as Upgrade. + if (req.method === 'CONNECT') { + res.upgrade = true; + return true; // skip body + } + // Responses to HEAD requests are crazy. // HEAD responses aren't allowed to have an entity-body // but *can* have a content-length which actually corresponds @@ -1250,7 +1261,8 @@ ClientRequest.prototype.onSocket = function(socket) { return true; } - if (req.shouldKeepAlive && res.headers.connection !== 'keep-alive' && !req.upgraded) { + if (req.shouldKeepAlive && res.headers.connection !== 'keep-alive' && + !req.upgradeOrConnect) { // Server MUST respond with Connection:keep-alive for us to enable it. // If we've been upgraded (via WebSockets) we also shouldn't try to // keep the connection open. @@ -1400,6 +1412,14 @@ function connectionListener(socket) { // abort socket._httpMessage ? } + function serverSocketCloseListener() { + debug('server socket close'); + // unref the parser for easy gc + parsers.free(parser); + + abortIncoming(); + } + debug('SERVER new http connection'); httpSocketSetup(socket); @@ -1424,19 +1444,24 @@ function connectionListener(socket) { debug('parse error'); socket.destroy(ret); } else if (parser.incoming && parser.incoming.upgrade) { + // Upgrade or CONNECT var bytesParsed = ret; + var req = parser.incoming; + socket.ondata = null; socket.onend = null; - - var req = parser.incoming; + socket.removeListener('close', serverSocketCloseListener); + parser.finish(); + parsers.free(parser); // This is start + byteParsed - var upgradeHead = d.slice(start + bytesParsed, end); + var bodyHead = d.slice(start + bytesParsed, end); - if (self.listeners('upgrade').length) { - self.emit('upgrade', req, req.socket, upgradeHead); + var eventName = req.method === 'CONNECT' ? 'connect' : 'upgrade'; + if (self.listeners(eventName).length) { + self.emit(eventName, req, req.socket, bodyHead); } else { - // Got upgrade header, but have no handler. + // Got upgrade header or CONNECT method, but have no handler. socket.destroy(); } } @@ -1463,13 +1488,7 @@ function connectionListener(socket) { } }; - socket.addListener('close', function() { - debug('server socket close'); - // unref the parser for easy gc - parsers.free(parser); - - abortIncoming(); - }); + socket.addListener('close', serverSocketCloseListener); // The following callback is issued after the headers have been read on a // new message. In this callback we setup the response object and pass it diff --git a/test/simple/test-http-connect.js b/test/simple/test-http-connect.js new file mode 100644 index 0000000..64b9714 --- /dev/null +++ b/test/simple/test-http-connect.js @@ -0,0 +1,87 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +var common = require('../common'); +var assert = require('assert'); +var http = require('http'); + +var serverGotConnect = false; +var clientGotConnect = false; + +var server = http.createServer(function(req, res) { + assert(false); +}); +server.on('connect', function(req, socket, firstBodyChunk) { + assert.equal(req.method, 'CONNECT'); + assert.equal(req.url, 'google.com:443'); + common.debug('Server got CONNECT request'); + serverGotConnect = true; + + socket.write('HTTP/1.1 200 Connection established\r\n\r\n'); + + var data = firstBodyChunk.toString(); + socket.on('data', function(buf) { + data += buf.toString(); + }); + socket.on('end', function() { + socket.end(data); + }); +}); +server.listen(common.PORT, function() { + var req = http.request({ + port: common.PORT, + method: 'CONNECT', + path: 'google.com:443' + }, function(res) { + assert(false); + }); + req.on('connect', function(res, socket, firstBodyChunk) { + common.debug('Client got CONNECT request'); + clientGotConnect = true; + + var data = firstBodyChunk.toString(); + socket.on('data', function(buf) { + data += buf.toString(); + }); + socket.on('end', function() { + assert.equal(data, 'HeadBody'); + server.close(); + }); + socket.write('Body'); + socket.end(); + }); + + // It is legal for the client to send some data intended for the server + // before the "200 Connection established" (or any other success or + // error code) is received. + req.write('Head'); + req.end(); +}); + +process.on('exit', function() { + assert.ok(serverGotConnect); + assert.ok(clientGotConnect); + + // Make sure this request got removed from the pool. + var name = 'localhost:' + common.PORT; + assert(!http.globalAgent.sockets.hasOwnProperty(name)); + assert(!http.globalAgent.requests.hasOwnProperty(name)); +}); -- 2.7.4