Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ See also the [first example](examples).
The `tcp://` scheme can also be omitted.
Passing any other scheme will reject the promise.

Pending connection attempts can be cancelled by cancelling its pending promise like so:
Pending connection attempts can be canceled by canceling its pending promise like so:

```php
$promise = $connector->connect($uri);
Expand Down Expand Up @@ -237,7 +237,7 @@ If you use the low-level `SecureConnector`, then the `tls://` scheme can also
be omitted.
Passing any other scheme will reject the promise.

Pending connection attempts can be cancelled by cancelling its pending promise
Pending connection attempts can be canceled by canceling its pending promise
as usual.

> Also note how secure TLS connections are in fact entirely handled outside of
Expand Down Expand Up @@ -396,7 +396,7 @@ $connector = new React\Socket\Connector($loop, array(

See also the [fourth example](examples).

Pending connection attempts can be cancelled by cancelling its pending promise
Pending connection attempts can be canceled by canceling its pending promise
as usual.

> Also note how local DNS resolution is in fact entirely handled outside of this
Expand Down Expand Up @@ -442,6 +442,12 @@ $client = new Client(
);
```

> The authentication details will be transmitted in cleartext to the SOCKS proxy
server only if it requires username/password authentication.
If the authentication details are missing or not accepted by the remote SOCKS
proxy server, it is expected to reject each connection attempt with an
exception error code of `SOCKET_EACCES` (13).

Authentication is only supported by protocol version 5 (SOCKS5),
so passing authentication to the `Client` enforces communication with protocol
version 5 and complains if you have explicitly set anything else:
Expand Down Expand Up @@ -494,7 +500,7 @@ $connector->connect('tls://www.google.com:443')->then(function ($stream) {

See also the [third example](examples).

Pending connection attempts can be cancelled by cancelling its pending promise
Pending connection attempts can be canceled by canceling its pending promise
as usual.

Proxy chaining can happen on the server side and/or the client side:
Expand Down Expand Up @@ -541,7 +547,7 @@ $connector->connect('tcp://google.com:80')->then(function ($stream) {

See also any of the [examples](examples).

Pending connection attempts can be cancelled by cancelling its pending promise
Pending connection attempts can be canceled by canceling its pending promise
as usual.

> Also note how connection timeout is in fact entirely handled outside of this
Expand Down
75 changes: 56 additions & 19 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ public function connect($uri)
return $this->connector->connect($socksUri)->then(
function (ConnectionInterface $stream) use ($that, $host, $port) {
return $that->handleConnectedSocks($stream, $host, $port);
},
function (Exception $e) {
throw new RuntimeException('Unable to connect to proxy (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $e);
}
);
}
Expand All @@ -176,43 +179,51 @@ function (ConnectionInterface $stream) use ($that, $host, $port) {
public function handleConnectedSocks(ConnectionInterface $stream, $host, $port)
{
$deferred = new Deferred(function ($_, $reject) {
$reject(new RuntimeException('Connection attempt cancelled while establishing socks session'));
$reject(new RuntimeException('Connection canceled while establishing SOCKS session (ECONNABORTED)', defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103));
});

$reader = new StreamReader();
$stream->on('data', array($reader, 'write'));

$stream->on('error', $onError = function (Exception $e) use ($deferred) {
$deferred->reject(new RuntimeException('Stream error while waiting for response from proxy (EIO)', defined('SOCKET_EIO') ? SOCKET_EIO : 5, $e));
});

$stream->on('close', $onClose = function () use ($deferred) {
$deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response (ECONNRESET)', defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104));
});

if ($this->protocolVersion === '5') {
$promise = $this->handleSocks5($stream, $host, $port, $reader);
} else {
$promise = $this->handleSocks4($stream, $host, $port, $reader);
}
$promise->then(function () use ($deferred, $stream) {
$deferred->resolve($stream);
}, function($error) use ($deferred) {
$deferred->reject(new Exception('Unable to communicate...', 0, $error));
});
}, function (Exception $error) use ($deferred) {
// pass custom RuntimeException through as-is, otherwise wrap in protocol error
if (!$error instanceof RuntimeException) {
$error = new RuntimeException('Invalid response received from proxy (EBADMSG)', defined('SOCKET_EBADMSG') ? SOCKET_EBADMSG: 71, $error);
}

$deferred->promise()->then(
function (ConnectionInterface $stream) use ($reader) {
$stream->removeAllListeners('end');
$deferred->reject($error);
});

return $deferred->promise()->then(
function (ConnectionInterface $stream) use ($reader, $onError, $onClose) {
$stream->removeListener('data', array($reader, 'write'));
$stream->removeListener('error', $onError);
$stream->removeListener('close', $onClose);

return $stream;
},
function ($error) use ($stream) {
function ($error) use ($stream, $onClose) {
$stream->removeListener('close', $onClose);
$stream->close();

return $error;
throw $error;
}
);

$stream->on('end', function () use ($stream, $deferred) {
$deferred->reject(new Exception('Premature end while establishing socks session'));
});

return $deferred->promise();
}

private function handleSocks4(ConnectionInterface $stream, $host, $port, StreamReader $reader)
Expand All @@ -236,9 +247,12 @@ private function handleSocks4(ConnectionInterface $stream, $host, $port, StreamR
'port' => 'n',
'ip' => 'N'
))->then(function ($data) {
if ($data['null'] !== 0x00 || $data['status'] !== 0x5a) {
if ($data['null'] !== 0x00) {
throw new Exception('Invalid SOCKS response');
}
if ($data['status'] !== 0x5a) {
throw new RuntimeException('Proxy refused connection with SOCKS error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111);
}
});
}

Expand Down Expand Up @@ -276,12 +290,12 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR
'status' => 'C'
))->then(function ($data) {
if ($data['version'] !== 0x01 || $data['status'] !== 0x00) {
throw new Exception('Username/Password authentication failed');
throw new RuntimeException('Username/Password authentication failed (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13);
}
});
} else if ($data['method'] !== 0x00) {
// any other method than "no authentication"
throw new Exception('Unacceptable authentication method requested');
throw new RuntimeException('No acceptable authentication method found (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13);
}
})->then(function () use ($stream, $reader, $host, $port) {
// do not resolve hostname. only try to convert to (binary/packed) IP
Expand All @@ -306,9 +320,32 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR
'type' => 'C'
));
})->then(function ($data) use ($reader) {
if ($data['version'] !== 0x05 || $data['status'] !== 0x00 || $data['null'] !== 0x00) {
if ($data['version'] !== 0x05 || $data['null'] !== 0x00) {
throw new Exception('Invalid SOCKS response');
}
if ($data['status'] !== 0x00) {
// map limited list of SOCKS error codes to common socket error conditions
// @link https://tools.ietf.org/html/rfc1928#section-6
if ($data['status'] === Server::ERROR_GENERAL) {
throw new RuntimeException('SOCKS server reported a general server failure (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111);
} elseif ($data['status'] === Server::ERROR_NOT_ALLOWED_BY_RULESET) {
throw new RuntimeException('SOCKS server reported connection is not allowed by ruleset (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13);
} elseif ($data['status'] === Server::ERROR_NETWORK_UNREACHABLE) {
throw new RuntimeException('SOCKS server reported network unreachable (ENETUNREACH)', defined('SOCKET_ENETUNREACH') ? SOCKET_ENETUNREACH : 101);
} elseif ($data['status'] === Server::ERROR_HOST_UNREACHABLE) {
throw new RuntimeException('SOCKS server reported host unreachable (EHOSTUNREACH)', defined('SOCKET_EHOSTUNREACH') ? SOCKET_EHOSTUNREACH : 113);
} elseif ($data['status'] === Server::ERROR_CONNECTION_REFUSED) {
throw new RuntimeException('SOCKS server reported connection refused (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111);
} elseif ($data['status'] === Server::ERROR_TTL) {
throw new RuntimeException('SOCKS server reported TTL/timeout expired (ETIMEDOUT)', defined('SOCKET_ETIMEDOUT') ? SOCKET_ETIMEDOUT : 110);
} elseif ($data['status'] === Server::ERROR_COMMAND_UNSUPPORTED) {
throw new RuntimeException('SOCKS server does not support the CONNECT command (EPROTO)', defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71);
} elseif ($data['status'] === Server::ERROR_ADDRESS_UNSUPPORTED) {
throw new RuntimeException('SOCKS server does not support this address type (EPROTO)', defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71);
}

throw new RuntimeException('SOCKS server reported an unassigned error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111);
}
if ($data['type'] === 0x01) {
// IPv4 address => skip IP and port
return $reader->readLength(6);
Expand Down
162 changes: 161 additions & 1 deletion tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Clue\React\Socks\Client;
use React\Promise\Promise;
use Clue\React\Socks\Server;

class ClientTest extends TestCase
{
Expand Down Expand Up @@ -120,6 +121,17 @@ public function testCreateWithInvalidPortDoesNotConnect()
$this->assertInstanceOf('\React\Promise\PromiseInterface', $promise);
}

public function testConnectorRejectsWillRejectConnection()
{
$promise = \React\Promise\reject(new RuntimeException());

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$promise = $this->client->connect('google.com:80');

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED));
}

public function testCancelConnectionDuringConnectionWillCancelConnection()
{
$promise = new Promise(function () { }, function () {
Expand All @@ -146,6 +158,154 @@ public function testCancelConnectionDuringSessionWillCloseStream()
$promise = $this->client->connect('google.com:80');
$promise->cancel();

$this->expectPromiseReject($promise);
$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED));
}

public function testEmitConnectionCloseDuringSessionWillRejectConnection()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$promise = $this->client->connect('google.com:80');

$stream->emit('close');

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNRESET));
}

public function testEmitConnectionErrorDuringSessionWillRejectConnection()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();
$stream->expects($this->once())->method('close');

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$promise = $this->client->connect('google.com:80');

$stream->emit('error', array(new RuntimeException()));

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EIO));
}

public function testEmitInvalidSocks4DataDuringSessionWillRejectConnection()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();
$stream->expects($this->once())->method('close');

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$promise = $this->client->connect('google.com:80');

$stream->emit('data', array("HTTP/1.1 400 Bad Request\r\n\r\n"));

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG));
}

public function testEmitInvalidSocks5DataDuringSessionWillRejectConnection()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();
$stream->expects($this->once())->method('close');

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$this->client = new Client('socks5://127.0.0.1:1080', $this->connector);

$promise = $this->client->connect('google.com:80');

$stream->emit('data', array("HTTP/1.1 400 Bad Request\r\n\r\n"));

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG));
}

public function testEmitSocks5DataErrorDuringSessionWillRejectConnection()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();
$stream->expects($this->once())->method('close');

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$this->client = new Client('socks5://127.0.0.1:1080', $this->connector);

$promise = $this->client->connect('google.com:80');

$stream->emit('data', array("\x05\x00" . "\x05\x01\x00\x00"));

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED));
}

public function provideConnectionErrors()
{
return array(
array(
Server::ERROR_GENERAL,
SOCKET_ECONNREFUSED
),
array(
Server::ERROR_NOT_ALLOWED_BY_RULESET,
SOCKET_EACCES
),
array(
Server::ERROR_NETWORK_UNREACHABLE,
SOCKET_ENETUNREACH
),
array(
Server::ERROR_HOST_UNREACHABLE,
SOCKET_EHOSTUNREACH
),
array(
Server::ERROR_CONNECTION_REFUSED,
SOCKET_ECONNREFUSED
),
array(
Server::ERROR_TTL,
SOCKET_ETIMEDOUT
),
array(
Server::ERROR_COMMAND_UNSUPPORTED,
SOCKET_EPROTO
),
array(
Server::ERROR_ADDRESS_UNSUPPORTED,
SOCKET_EPROTO
),
array(
200,
SOCKET_ECONNREFUSED
)
);
}

/**
* @dataProvider provideConnectionErrors
* @param int $error
* @param int $expectedCode
*/
public function testEmitSocks5DataErrorMapsToExceptionCode($error, $expectedCode)
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock();
$stream->expects($this->once())->method('close');

$promise = \React\Promise\resolve($stream);

$this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise);

$this->client = new Client('socks5://127.0.0.1:1080', $this->connector);

$promise = $this->client->connect('google.com:80');

$stream->emit('data', array("\x05\x00" . "\x05" . chr($error) . "\x00\x00"));

$promise->then(null, $this->expectCallableOnceWithExceptionCode($expectedCode));
}
}
Loading