From 5a14c290bf54ec5485c6da1b3bc23b56aeab4e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 15 Nov 2018 17:53:27 +0100 Subject: [PATCH 1/2] Change Server::setAuth() to use bool as async promise resolution value --- README.md | 29 +++++++++++++++++++++++------ src/Server.php | 34 +++++++++++++++++----------------- tests/FunctionalTest.php | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4607a8c..db4a573 100644 --- a/README.md +++ b/README.md @@ -676,21 +676,38 @@ the connection will be rejected. Because your authentication mechanism might take some time to actually check the provided authentication credentials (like querying a remote database or webservice), -the server side uses a [Promise](https://github.com/reactphp/promise) based interface. +the server side uses a [Promise](https://github.com/reactphp/promise)-based interface. While this might seem complex at first, it actually provides a very simple way to handle simultanous connections in a non-blocking fashion and increases overall performance. -```PHP -$server->setAuth(function ($username, $password, $remote) { - // either return a boolean success value right away - // or use promises for delayed authentication +You can use the `setAuth(callable $authenticator)` method to configure a callable +function that should return a `bool` value like this synchronous example: +```php +$server->setAuth(function ($username, $password, $remote) { // $remote is a full URI à la socks5://user:pass@192.168.1.1:1234 // or socks5s://user:pass@192.168.1.1:1234 for SOCKS over TLS // useful for logging or extracting parts, such as the remote IP $ip = parse_url($remote, PHP_URL_HOST); - return ($username === 'root' && $ip === '127.0.0.1'); + return ($username === 'root' && $password === 'secret' && $ip === '127.0.0.1'); +}); +``` + +Similarly, you can return a [Promise](https://github.com/reactphp/promise) from +the authenticator function that will fulfill with a `bool` value like this async +example: + +```php +$server->setAuth(function ($username, $password) use ($db) { + // pseudo-code: query database for given authentication details + return $db->query( + 'SELECT 1 FROM users WHERE name = ? AND password = ?', + array($username, $password) + )->then(function (QueryResult $result) { + // ensure we find exactly one match in the database + return count($result->resultRows) === 1; + }); }); ``` diff --git a/src/Server.php b/src/Server.php index ef6d143..d5bcaf7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -3,8 +3,6 @@ namespace Clue\React\Socks; use React\Socket\ServerInterface; -use React\Promise; -use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Socket\ConnectorInterface; use React\Socket\Connector; @@ -71,13 +69,9 @@ public function setAuth($auth) // wrap authentication callback in order to cast its return value to a promise $this->auth = function($username, $password, $remote) use ($auth) { - $ret = call_user_func($auth, $username, $password, $remote); - if ($ret instanceof PromiseInterface) { - return $ret; - } - $deferred = new Deferred(); - $ret ? $deferred->resolve() : $deferred->reject(); - return $deferred->promise(); + return \React\Promise\resolve( + call_user_func($auth, $username, $password, $remote) + ); }; } @@ -215,7 +209,7 @@ public function handleSocks4(ConnectionInterface $stream, StreamReader $reader) } /** @internal */ - public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamReader $reader) + public function handleSocks5(ConnectionInterface $stream, $auth, StreamReader $reader) { $remote = $stream->getRemoteAddress(); if ($remote !== null) { @@ -255,13 +249,19 @@ public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamRead $remote = str_replace('://', '://' . rawurlencode($username) . ':' . rawurlencode($password) . '@', $remote); } - return $auth($username, $password, $remote)->then(function () use ($stream) { - // accept - $stream->write(pack('C2', 0x01, 0x00)); - }, function() use ($stream) { - // reject => send any code but 0x00 + return $auth($username, $password, $remote)->then(function ($authenticated) use ($stream) { + if ($authenticated) { + // accept auth + $stream->write(pack('C2', 0x01, 0x00)); + } else { + // reject auth => send any code but 0x00 + $stream->end(pack('C2', 0x01, 0xFF)); + throw new UnexpectedValueException('Authentication denied'); + } + }, function ($e) use ($stream) { + // reject failed authentication => send any code but 0x00 $stream->end(pack('C2', 0x01, 0xFF)); - throw new UnexpectedValueException('Unable to authenticate'); + throw new UnexpectedValueException('Authentication error', 0, $e); }); }); }); @@ -336,7 +336,7 @@ public function connectTarget(ConnectionInterface $stream, array $target) // validate URI so a string hostname can not pass excessive URI parts $parts = parse_url('tcp://' . $uri); if (!$parts || !isset($parts['scheme'], $parts['host'], $parts['port']) || count($parts) !== 3) { - return Promise\reject(new InvalidArgumentException('Invalid target URI given')); + return \React\Promise\reject(new InvalidArgumentException('Invalid target URI given')); } if (isset($target[2])) { diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 615069b..464e840 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -302,6 +302,39 @@ public function testConnectionInvalidAuthenticationMismatch() $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); } + public function testConnectionInvalidAuthenticatorReturnsFalse() + { + $this->server->setAuth(function () { + return false; + }); + + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); + + $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); + } + + public function testConnectionInvalidAuthenticatorReturnsPromiseFulfilledWithFalse() + { + $this->server->setAuth(function () { + return \React\Promise\resolve(false); + }); + + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); + + $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); + } + + public function testConnectionInvalidAuthenticatorReturnsPromiseRejected() + { + $this->server->setAuth(function () { + return \React\Promise\reject(); + }); + + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); + + $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); + } + /** @group internet */ public function testConnectorOkay() { From 6231abc9110540dc30e5ac4b9f6d991498718ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 16 Nov 2018 18:55:34 +0100 Subject: [PATCH 2/2] Replace Server::setAuth() with optional constructor parameter --- README.md | 57 ++++++++++----------- examples/12-server-with-password.php | 3 +- src/Server.php | 56 ++++++++++---------- tests/FunctionalTest.php | 76 +++++++++++++++++++++++----- tests/ServerTest.php | 37 +++++++------- 5 files changed, 137 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index db4a573..5d3331c 100644 --- a/README.md +++ b/README.md @@ -628,6 +628,10 @@ $server->listen($socket); $loop->run(); ``` +Additionally, the `Server` constructor accepts optional parameters to explicitly +configure the [connector](#server-connector) to use and to require +[authentication](#server-authentication). For more details, read on... + #### Server connector The `Server` uses an instance of ReactPHP's @@ -674,32 +678,43 @@ If a client tries to use any other protocol version, does not send along authentication details or if authentication details can not be verified, the connection will be rejected. -Because your authentication mechanism might take some time to actually check -the provided authentication credentials (like querying a remote database or webservice), -the server side uses a [Promise](https://github.com/reactphp/promise)-based interface. -While this might seem complex at first, it actually provides a very simple way -to handle simultanous connections in a non-blocking fashion and increases overall performance. +If you only want to accept static authentication details, you can simply pass an +additional assoc array with your authentication details to the `Server` like this: -You can use the `setAuth(callable $authenticator)` method to configure a callable +```php +$server = new Clue\React\Socks\Server($loop, null, array( + 'tom' => 'password', + 'admin' => 'root' +)); +``` + +See also [example #12](examples). + +If you want more control over authentication, you can pass an authenticator function that should return a `bool` value like this synchronous example: ```php -$server->setAuth(function ($username, $password, $remote) { +$server = new Clue\React\Socks\Server($loop, null, function ($user, $pass, $remote) { // $remote is a full URI à la socks5://user:pass@192.168.1.1:1234 // or socks5s://user:pass@192.168.1.1:1234 for SOCKS over TLS // useful for logging or extracting parts, such as the remote IP $ip = parse_url($remote, PHP_URL_HOST); - return ($username === 'root' && $password === 'secret' && $ip === '127.0.0.1'); + return ($user === 'root' && $pass === 'secret' && $ip === '127.0.0.1'); }); ``` -Similarly, you can return a [Promise](https://github.com/reactphp/promise) from -the authenticator function that will fulfill with a `bool` value like this async -example: +Because your authentication mechanism might take some time to actually check the +provided authentication credentials (like querying a remote database or webservice), +the server also supports a [Promise](https://github.com/reactphp/promise)-based +interface. While this might seem more complex at first, it actually provides a +very powerful way of handling a large number of connections concurrently without +ever blocking any connections. You can return a [Promise](https://github.com/reactphp/promise) +from the authenticator function that will fulfill with a `bool` value like this +async example: ```php -$server->setAuth(function ($username, $password) use ($db) { +$server = new Clue\React\Socks\Server($loop, null, function ($user, $pass) use ($db) { // pseudo-code: query database for given authentication details return $db->query( 'SELECT 1 FROM users WHERE name = ? AND password = ?', @@ -711,24 +726,6 @@ $server->setAuth(function ($username, $password) use ($db) { }); ``` -Or if you only accept static authentication details, you can use the simple -array-based authentication method as a shortcut: - -```PHP -$server->setAuthArray(array( - 'tom' => 'password', - 'admin' => 'root' -)); -``` - -See also [example #12](examples). - -If you do not want to use authentication anymore: - -```PHP -$server->unsetAuth(); -``` - #### Server proxy chaining The `Server` is responsible for creating connections to the target host. diff --git a/examples/12-server-with-password.php b/examples/12-server-with-password.php index d7af2e4..ca9ecc3 100644 --- a/examples/12-server-with-password.php +++ b/examples/12-server-with-password.php @@ -9,8 +9,7 @@ // start a new SOCKS proxy server // require authentication and hence make this a SOCKS5-only server -$server = new Server($loop); -$server->setAuthArray(array( +$server = new Server($loop, null, array( 'tom' => 'god', 'user' => 'p@ssw0rd' )); diff --git a/src/Server.php b/src/Server.php index d5bcaf7..2288bf7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -37,14 +37,40 @@ final class Server private $connector; - private $auth = null; + /** + * @var null|callable + */ + private $auth; - public function __construct(LoopInterface $loop, ConnectorInterface $connector = null) + /** + * @param LoopInterface $loop + * @param null|ConnectorInterface $connector + * @param null|array|callable $auth + */ + public function __construct(LoopInterface $loop, ConnectorInterface $connector = null, $auth = null) { if ($connector === null) { $connector = new Connector($loop); } + if (\is_array($auth)) { + // wrap authentication array in authentication callback + $this->auth = function ($username, $password) use ($auth) { + return \React\Promise\resolve( + isset($auth[$username]) && (string)$auth[$username] === $password + ); + }; + } elseif (\is_callable($auth)) { + // wrap authentication callback in order to cast its return value to a promise + $this->auth = function($username, $password, $remote) use ($auth) { + return \React\Promise\resolve( + \call_user_func($auth, $username, $password, $remote) + ); + }; + } elseif ($auth !== null) { + throw new \InvalidArgumentException('Invalid authenticator given'); + } + $this->loop = $loop; $this->connector = $connector; } @@ -61,32 +87,6 @@ public function listen(ServerInterface $socket) }); } - public function setAuth($auth) - { - if (!is_callable($auth)) { - throw new InvalidArgumentException('Given authenticator is not a valid callable'); - } - - // wrap authentication callback in order to cast its return value to a promise - $this->auth = function($username, $password, $remote) use ($auth) { - return \React\Promise\resolve( - call_user_func($auth, $username, $password, $remote) - ); - }; - } - - public function setAuthArray(array $login) - { - $this->setAuth(function ($username, $password) use ($login) { - return (isset($login[$username]) && (string)$login[$username] === $password); - }); - } - - public function unsetAuth() - { - $this->auth = null; - } - /** @internal */ public function onConnection(ConnectionInterface $connection) { diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 464e840..6ad8244 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -180,9 +180,8 @@ public function testConnectionSocksWithAuthenticationOverUnix() $path = sys_get_temp_dir() . '/test' . mt_rand(1000, 9999) . '.sock'; $socket = new UnixServer($path, $this->loop); - $this->server = new Server($this->loop); + $this->server = new Server($this->loop, null, array('name' => 'pass')); $this->server->listen($socket); - $this->server->setAuthArray(array('name' => 'pass')); $this->connector = new Connector($this->loop); $this->client = new Client('socks+unix://name:pass@' . $path, $this->connector); @@ -195,7 +194,11 @@ public function testConnectionSocksWithAuthenticationOverUnix() /** @group internet */ public function testConnectionAuthenticationFromUri() { - $this->server->setAuthArray(array('name' => 'pass')); + $this->server = new Server($this->loop, null, array('name' => 'pass')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client('name:pass@127.0.0.1:' . $this->port, $this->connector); @@ -207,7 +210,7 @@ public function testConnectionAuthenticationCallback() { $called = 0; $that = $this; - $this->server->setAuth(function ($name, $pass, $remote) use ($that, &$called) { + $this->server = new Server($this->loop, null, function ($name, $pass, $remote) use ($that, &$called) { ++$called; $that->assertEquals('name', $name); $that->assertEquals('pass', $pass); @@ -216,6 +219,10 @@ public function testConnectionAuthenticationCallback() return true; }); + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('name:pass@127.0.0.1:' . $this->port, $this->connector); $this->assertResolveStream($this->client->connect('www.google.com:80')); @@ -226,12 +233,16 @@ public function testConnectionAuthenticationCallback() public function testConnectionAuthenticationCallbackWillNotBeInvokedIfClientsSendsNoAuth() { $called = 0; - $this->server->setAuth(function () use (&$called) { + $this->server = new Server($this->loop, null, function () use (&$called) { ++$called; return true; }); + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('127.0.0.1:' . $this->port, $this->connector); $this->assertRejectPromise($this->client->connect('www.google.com:80')); @@ -241,7 +252,11 @@ public function testConnectionAuthenticationCallbackWillNotBeInvokedIfClientsSen /** @group internet */ public function testConnectionAuthenticationFromUriEncoded() { - $this->server->setAuthArray(array('name' => 'p@ss:w0rd')); + $this->server = new Server($this->loop, null, array('name' => 'p@ss:w0rd')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client(rawurlencode('name') . ':' . rawurlencode('p@ss:w0rd') . '@127.0.0.1:' . $this->port, $this->connector); @@ -251,7 +266,11 @@ public function testConnectionAuthenticationFromUriEncoded() /** @group internet */ public function testConnectionAuthenticationFromUriWithOnlyUserAndNoPassword() { - $this->server->setAuthArray(array('empty' => '')); + $this->server = new Server($this->loop, null, array('empty' => '')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client('empty@127.0.0.1:' . $this->port, $this->connector); @@ -261,7 +280,12 @@ public function testConnectionAuthenticationFromUriWithOnlyUserAndNoPassword() /** @group internet */ public function testConnectionAuthenticationEmptyPassword() { - $this->server->setAuthArray(array('user' => '')); + $this->server = new Server($this->loop, null, array('user' => '')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('user@127.0.0.1:' . $this->port, $this->connector); $this->assertResolveStream($this->client->connect('www.google.com:80')); @@ -277,7 +301,11 @@ public function testConnectionAuthenticationUnused() public function testConnectionInvalidNoAuthenticationOverLegacySocks4() { - $this->server->setAuthArray(array('name' => 'pass')); + $this->server = new Server($this->loop, null, array('name' => 'pass')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client('socks4://127.0.0.1:' . $this->port, $this->connector); @@ -286,7 +314,11 @@ public function testConnectionInvalidNoAuthenticationOverLegacySocks4() public function testConnectionInvalidNoAuthentication() { - $this->server->setAuthArray(array('name' => 'pass')); + $this->server = new Server($this->loop, null, array('name' => 'pass')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client('socks5://127.0.0.1:' . $this->port, $this->connector); @@ -295,7 +327,11 @@ public function testConnectionInvalidNoAuthentication() public function testConnectionInvalidAuthenticationMismatch() { - $this->server->setAuthArray(array('name' => 'pass')); + $this->server = new Server($this->loop, null, array('name' => 'pass')); + + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); @@ -304,10 +340,14 @@ public function testConnectionInvalidAuthenticationMismatch() public function testConnectionInvalidAuthenticatorReturnsFalse() { - $this->server->setAuth(function () { + $this->server = new Server($this->loop, null, function () { return false; }); + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); @@ -315,10 +355,14 @@ public function testConnectionInvalidAuthenticatorReturnsFalse() public function testConnectionInvalidAuthenticatorReturnsPromiseFulfilledWithFalse() { - $this->server->setAuth(function () { + $this->server = new Server($this->loop, null, function () { return \React\Promise\resolve(false); }); + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); @@ -326,10 +370,14 @@ public function testConnectionInvalidAuthenticatorReturnsPromiseFulfilledWithFal public function testConnectionInvalidAuthenticatorReturnsPromiseRejected() { - $this->server->setAuth(function () { + $this->server = new Server($this->loop, null, function () { return \React\Promise\reject(); }); + $socket = new React\Socket\Server(0, $this->loop); + $this->server->listen($socket); + $this->port = parse_url($socket->getAddress(), PHP_URL_PORT); + $this->client = new Client('user:pass@127.0.0.1:' . $this->port, $this->connector); $this->assertRejectPromise($this->client->connect('www.google.com:80'), null, SOCKET_EACCES); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 375e415..828bb6e 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -6,19 +6,21 @@ class ServerTest extends TestCase { + private $loop; + private $connector; + /** @var Server */ private $server; - private $connector; public function setUp() { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface') + $this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface') ->getMock(); $this->connector = $this->getMockBuilder('React\Socket\ConnectorInterface') ->getMock(); - $this->server = new Server($loop, $this->connector); + $this->server = new Server($this->loop, $this->connector); } /** @@ -31,24 +33,31 @@ public function testListen() $this->server->listen($socket); } - public function testSetAuthArray() + /** + * @doesNotPerformAssertions + */ + public function testConstructorWithEmptyAuthArray() { - $this->server->setAuthArray(array()); + $this->server = new Server($this->loop, $this->connector, array()); + } - $this->server->setAuthArray(array( + /** + * @doesNotPerformAssertions + */ + public function testConstructorWithStaticAuthArray() + { + $this->server = new Server($this->loop, $this->connector, array( 'name1' => 'password1', 'name2' => 'password2' )); - - $this->assertTrue(true); } /** * @expectedException InvalidArgumentException */ - public function testSetAuthInvalid() + public function testConstructorWithInvalidAuthenticatorThrows() { - $this->server->setAuth(true); + new Server($this->loop, $this->connector, true); } public function testConnectWillCreateConnection() @@ -383,12 +392,4 @@ public function testHandleSocksConnectionWillCancelOutputConnectionIfIncomingClo $connection->emit('data', array("\x04\x01" . "\x00\x50" . pack('N', ip2long('127.0.0.1')) . "\x00")); $connection->emit('close'); } - - public function testUnsetAuth() - { - $this->server->unsetAuth(); - $this->server->unsetAuth(); - - $this->assertTrue(true); - } }