Skip to content

Commit

Permalink
Merge pull request #158 from clue-labs/connect
Browse files Browse the repository at this point in the history
Support CONNECT method
  • Loading branch information
clue authored Mar 31, 2017
2 parents d40bb46 + 463675b commit 8556c38
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 28 deletions.
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,27 @@ $http = new Server($socket, function (RequestInterface $request) {
});
```

Note that the server supports *any* request method (including custom and non-
standard ones) and all request-target formats defined in the HTTP specs for each
respective method.
You can use `getMethod(): string` and `getRequestTarget(): string` to
check this is an accepted request and may want to reject other requests with
an appropriate error code, such as `400` (Bad Request) or `405` (Method Not
Allowed).

> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not
something most HTTP servers would want to care about.
Note that if you want to handle this method, the client MAY send a different
request-target than the `Host` header field (such as removing default ports)
and the request-target MUST take precendence when forwarding.
The HTTP specs define an opaque "tunneling mode" for this method and make no
use of the message body.
For consistency reasons, this library uses the message body of the request and
response for tunneled application data.
This implies that that a `2xx` (Successful) response to a `CONNECT` request
can in fact use a streaming response body for the tunneled application data.
See also [example #21](examples) for more details.

### Response

The callback function passed to the constructor of the [Server](#server)
Expand Down Expand Up @@ -393,14 +414,25 @@ message body as per the HTTP specs.
This means that your callback does not have to take special care of this and any
response body will simply be ignored.

Similarly, any response with a `1xx` (Informational) or `204` (No Content)
status code will *not* include a `Content-Length` or `Transfer-Encoding`
header as these do not apply to these messages.
Similarly, any `2xx` (Successful) response to a `CONNECT` request, any response
with a `1xx` (Informational) or `204` (No Content) status code will *not*
include a `Content-Length` or `Transfer-Encoding` header as these do not apply
to these messages.
Note that a response to a `HEAD` request and any response with a `304` (Not
Modified) status code MAY include these headers even though
the message does not contain a response body, because these header would apply
to the message if the same request would have used an (unconditional) `GET`.

> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not
something most HTTP servers would want to care about.
The HTTP specs define an opaque "tunneling mode" for this method and make no
use of the message body.
For consistency reasons, this library uses the message body of the request and
response for tunneled application data.
This implies that that a `2xx` (Successful) response to a `CONNECT` request
can in fact use a streaming response body for the tunneled application data.
See also [example #21](examples) for more details.

A `Date` header will be automatically added with the system date and time if none is given.
You can add a custom `Date` header yourself like this:

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
}
},
"require-dev": {
"phpunit/phpunit": "^4.8.10||^5.0"
"phpunit/phpunit": "^4.8.10||^5.0",
"react/socket-client": "^0.6"
}
}
77 changes: 77 additions & 0 deletions examples/21-connect-proxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use React\EventLoop\Factory;
use React\Socket\Server;
use React\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\SocketClient\TcpConnector;
use React\SocketClient\ConnectionInterface;
use React\SocketClient\DnsConnector;

require __DIR__ . '/../vendor/autoload.php';

$loop = Factory::create();
$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop);

$resolver = new \React\Dns\Resolver\Factory();
$resolver = $resolver->create('8.8.8.8', $loop);
$connector = new DnsConnector(new TcpConnector($loop), $resolver);

$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($connector) {
if ($request->getMethod() !== 'CONNECT') {
return new Response(
405,
array('Content-Type' => 'text/plain', 'Allow' => 'CONNECT'),
'This is a HTTP CONNECT (secure HTTPS) proxy'
);
}

// pause consuming request body
$body = $request->getBody();
$body->pause();

$buffer = '';
$body->on('data', function ($chunk) use (&$buffer) {
$buffer .= $chunk;
});

// try to connect to given target host
$promise = $connector->connect($request->getRequestTarget())->then(
function (ConnectionInterface $remote) use ($body, &$buffer) {
// connection established => forward data
$body->pipe($remote);
$body->resume();

if ($buffer !== '') {
$remote->write($buffer);
$buffer = '';
}

return new Response(
200,
array(),
$remote
);
},
function ($e) {
return new Response(
502,
array('Content-Type' => 'text/plain'),
'Unable to connect: ' . $e->getMessage()
);
}
);

// cancel pending connection if request closes prematurely
$body->on('close', function () use ($promise) {
$promise->cancel();
});

return $promise;
});

//$server->on('error', 'printf');

echo 'Listening on http://' . $socket->getAddress() . PHP_EOL;

$loop->run();
29 changes: 24 additions & 5 deletions src/RequestHeaderParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,37 @@ private function parseRequest($data)
{
list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2);

$asterisk = false;
$originalTarget = null;
if (strpos($headers, 'OPTIONS * ') === 0) {
$asterisk = true;
$originalTarget = '*';
$headers = 'OPTIONS / ' . substr($headers, 10);
} elseif (strpos($headers, 'CONNECT ') === 0) {
$parts = explode(' ', $headers, 3);
$uri = parse_url('tcp://' . $parts[1]);

// check this is a valid authority-form request-target (host:port)
if (isset($uri['scheme'], $uri['host'], $uri['port']) && count($uri) === 3) {
$originalTarget = $parts[1];
$parts[1] = '/';
$headers = implode(' ', $parts);
}
}

$request = g7\parse_request($headers);

if ($asterisk) {
// Do not assume this is HTTPS when this happens to be port 443
// detecting HTTPS is left up to the socket layer (TLS detection)
if ($request->getUri()->getScheme() === 'https') {
$request = $request->withUri(
$request->getUri()->withScheme('http')->withPort(443)
);
}

if ($originalTarget !== null) {
$request = $request->withUri(
$request->getUri()->withPath('')
)->withRequestTarget('*');
$request->getUri()->withPath(''),
true
)->withRequestTarget($originalTarget);
}

return array($request, $bodyBuffer);
Expand Down
23 changes: 20 additions & 3 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,24 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque

$contentLength = 0;
$stream = new CloseProtectionStream($conn);
if ($request->hasHeader('Transfer-Encoding')) {
if ($request->getMethod() === 'CONNECT') {
// CONNECT method MUST use authority-form request target
$parts = parse_url('tcp://' . $request->getRequestTarget());
if (!isset($parts['scheme'], $parts['host'], $parts['port']) || count($parts) !== 3) {
$this->emit('error', array(new \InvalidArgumentException('CONNECT method MUST use authority-form request target')));
return $this->writeError($conn, 400);
}

// CONNECT uses undelimited body until connection closes
$request = $request->withoutHeader('Transfer-Encoding');
$request = $request->withoutHeader('Content-Length');
$contentLength = null;

// emit end event before the actual close event
$stream->on('close', function () use ($stream) {
$stream->emit('end');
});
} else if ($request->hasHeader('Transfer-Encoding')) {

if (strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
$this->emit('error', array(new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding')));
Expand Down Expand Up @@ -336,9 +353,9 @@ public function handleResponse(ConnectionInterface $connection, RequestInterface
$response = $response->withHeader('Connection', 'close');
}

// response code 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header
// 2xx response to CONNECT and 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header
$code = $response->getStatusCode();
if (($code >= 100 && $code < 200) || $code === 204) {
if (($request->getMethod() === 'CONNECT' && $code >= 200 && $code < 300) || ($code >= 100 && $code < 200) || $code === 204) {
$response = $response->withoutHeader('Content-Length')->withoutHeader('Transfer-Encoding');
}

Expand Down
Loading

0 comments on commit 8556c38

Please sign in to comment.