diff --git a/app/Contracts/ConnectionManager.php b/app/Contracts/ConnectionManager.php index b554a9e..111c3b7 100644 --- a/app/Contracts/ConnectionManager.php +++ b/app/Contracts/ConnectionManager.php @@ -29,4 +29,8 @@ interface ConnectionManager public function getConnectionsForAuthToken(string $authToken): array; public function getTcpConnectionsForAuthToken(string $authToken): array; + + public function findControlConnectionsForIp(string $ip): array; + + public function findControlConnectionsForAuthToken(string $token): array; } diff --git a/app/Server/Connections/ConnectionManager.php b/app/Server/Connections/ConnectionManager.php index 3978d4d..eab4244 100644 --- a/app/Server/Connections/ConnectionManager.php +++ b/app/Server/Connections/ConnectionManager.php @@ -6,6 +6,7 @@ use App\Contracts\ConnectionManager as ConnectionManagerContract; use App\Contracts\SubdomainGenerator; use App\Http\QueryParameters; use App\Server\Exceptions\NoFreePortAvailable; +use Illuminate\Support\Collection; use Ratchet\ConnectionInterface; use React\EventLoop\LoopInterface; use React\Socket\Server; @@ -157,6 +158,20 @@ class ConnectionManager implements ConnectionManagerContract }); } + public function findControlConnectionsForIp(string $ip): array + { + return collect($this->connections)->filter(function (ControlConnection $connection) use ($ip) { + return $connection->socket->remoteAddress == $ip; + })->toArray(); + } + + public function findControlConnectionsForAuthToken(string $token): array + { + return collect($this->connections)->filter(function (ControlConnection $connection) use ($token) { + return $connection->authToken === $token; + })->toArray(); + } + public function getConnections(): array { return $this->connections; diff --git a/app/Server/Http/Controllers/Admin/StoreUsersController.php b/app/Server/Http/Controllers/Admin/StoreUsersController.php index ab2986e..a85c590 100644 --- a/app/Server/Http/Controllers/Admin/StoreUsersController.php +++ b/app/Server/Http/Controllers/Admin/StoreUsersController.php @@ -41,6 +41,7 @@ class StoreUsersController extends AdminController 'auth_token' => (string) Str::uuid(), 'can_specify_subdomains' => (int) $request->get('can_specify_subdomains'), 'can_share_tcp_ports' => (int) $request->get('can_share_tcp_ports'), + 'max_connections' => (int) $request->get('max_connections'), ]; $this->userRepository diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index 95e2b22..1b3a8a5 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -7,11 +7,13 @@ use App\Contracts\SubdomainRepository; use App\Contracts\UserRepository; use App\Http\QueryParameters; use App\Server\Exceptions\NoFreePortAvailable; +use Illuminate\Support\Arr; use Ratchet\ConnectionInterface; use Ratchet\WebSocket\MessageComponentInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use stdClass; +use function React\Promise\reject; class ControlMessageController implements MessageComponentInterface { @@ -85,7 +87,35 @@ class ControlMessageController implements MessageComponentInterface protected function authenticate(ConnectionInterface $connection, $data) { + if (! isset($data->subdomain)) { + $data->subdomain = null; + } + $this->verifyAuthToken($connection) + ->then(function ($user) use ($connection) { + $maximumConnectionCount = config('expose.admin.maximum_open_connections_per_user', 0); + + if (is_null($user)) { + $connectionCount = count($this->connectionManager->findControlConnectionsForIp($connection->remoteAddress)); + } else { + $maximumConnectionCount = Arr::get($user, 'max_connections', $maximumConnectionCount); + + $connectionCount = count($this->connectionManager->findControlConnectionsForAuthToken($user['auth_token'])); + } + + if ($maximumConnectionCount > 0 && $connectionCount + 1 > $maximumConnectionCount) { + $connection->send(json_encode([ + 'event' => 'authenticationFailed', + 'data' => [ + 'message' => config('expose.admin.messages.maximum_connection_count'), + ], + ])); + $connection->close(); + + reject(null); + } + return $user; + }) ->then(function ($user) use ($connection, $data) { if ($data->type === 'http') { $this->handleHttpConnection($connection, $data, $user); diff --git a/app/Server/UserRepository/DatabaseUserRepository.php b/app/Server/UserRepository/DatabaseUserRepository.php index 06536f6..46867c5 100644 --- a/app/Server/UserRepository/DatabaseUserRepository.php +++ b/app/Server/UserRepository/DatabaseUserRepository.php @@ -132,8 +132,8 @@ class DatabaseUserRepository implements UserRepository $deferred = new Deferred(); $this->database->query(" - INSERT INTO users (name, auth_token, can_specify_subdomains, can_share_tcp_ports, created_at) - VALUES (:name, :auth_token, :can_specify_subdomains, :can_share_tcp_ports, DATETIME('now')) + INSERT INTO users (name, auth_token, can_specify_subdomains, can_share_tcp_ports, max_connections, created_at) + VALUES (:name, :auth_token, :can_specify_subdomains, :can_share_tcp_ports, :max_connections, DATETIME('now')) ", $data) ->then(function (Result $result) use ($deferred) { $this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId]) diff --git a/builds/expose b/builds/expose index d2caf2c..5ca5876 100755 Binary files a/builds/expose and b/builds/expose differ diff --git a/config/expose.php b/config/expose.php index 32bf286..39dfb58 100644 --- a/config/expose.php +++ b/config/expose.php @@ -224,6 +224,21 @@ return [ */ 'maximum_connection_length' => 0, + /* + |-------------------------------------------------------------------------- + | Maximum number of open connections + |-------------------------------------------------------------------------- + | + | You can limit the amount of connections that one client/user can have + | open. A maximum connection count of 0 means that clients can open + | as many connections as they want. + | + | When creating users with the API/admin interface, you can + | override this setting per user. + | + */ + 'maximum_open_connections_per_user' => 0, + /* |-------------------------------------------------------------------------- | Subdomain diff --git a/database/migrations/05_add_maximum_connection_count_to_users_table.sql b/database/migrations/05_add_maximum_connection_count_to_users_table.sql new file mode 100644 index 0000000..59a8ab8 --- /dev/null +++ b/database/migrations/05_add_maximum_connection_count_to_users_table.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD max_connections INTEGER NOT NULL DEFAULT 0; diff --git a/resources/views/server/users/index.twig b/resources/views/server/users/index.twig index 762704d..6d5c6c7 100644 --- a/resources/views/server/users/index.twig +++ b/resources/views/server/users/index.twig @@ -63,6 +63,21 @@ +
+ +
+
+ +
+
+
@@ -99,6 +114,9 @@ Auth-Token + + Max Connections + Custom Subdomains @@ -119,6 +137,9 @@ @{ user.auth_token } + + @{ user.max_connections } + No @@ -186,6 +207,7 @@ name: '', can_specify_subdomains: true, can_share_tcp_ports: true, + max_connections: 0, errors: {}, }, paginated: {{ paginated|json_encode|raw }} @@ -242,6 +264,7 @@ this.userForm.name = ''; this.userForm.can_specify_subdomains = true; this.userForm.can_share_tcp_ports = true; + this.userForm.max_connections = 0; this.userForm.errors = {}; this.users.unshift(data.user); } diff --git a/tests/Feature/Server/TunnelTest.php b/tests/Feature/Server/TunnelTest.php index 48f7437..e050b1b 100644 --- a/tests/Feature/Server/TunnelTest.php +++ b/tests/Feature/Server/TunnelTest.php @@ -135,16 +135,10 @@ class TunnelTest extends TestCase $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_share_tcp_ports' => 0, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $this->expectException(\UnexpectedValueException::class); @@ -163,16 +157,10 @@ class TunnelTest extends TestCase $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_share_tcp_ports' => 1, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); /** * We create an expose client that connects to our server and shares @@ -213,16 +201,10 @@ class TunnelTest extends TestCase { $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_specify_subdomains' => 1, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $this->createTestHttpServer(); @@ -241,16 +223,10 @@ class TunnelTest extends TestCase { $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_specify_subdomains' => 0, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $this->createTestHttpServer(); @@ -269,16 +245,10 @@ class TunnelTest extends TestCase { $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_specify_subdomains' => 1, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [ 'Host' => 'expose.localhost', @@ -289,16 +259,10 @@ class TunnelTest extends TestCase 'auth_token' => $user->auth_token, ]))); - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Test-User', 'can_specify_subdomains' => 1, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $this->createTestHttpServer(); @@ -318,16 +282,10 @@ class TunnelTest extends TestCase { $this->app['config']['expose.admin.validate_auth_tokens'] = true; - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ + $user = $this->createUser([ 'name' => 'Marcel', 'can_specify_subdomains' => 1, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + ]); $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [ 'Host' => 'expose.localhost', @@ -350,20 +308,11 @@ class TunnelTest extends TestCase } /** @test */ - public function it_allows_clients_to_use_random_subdomains_if_custom_subdomains_are_forbidden() + public function it_rejects_clients_with_too_many_connections() { - $this->app['config']['expose.admin.validate_auth_tokens'] = true; - - $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ - 'Host' => 'expose.localhost', - 'Authorization' => base64_encode('username:secret'), - 'Content-Type' => 'application/json', - ], json_encode([ - 'name' => 'Marcel', - 'can_specify_subdomains' => 0, - ]))); - - $user = json_decode($response->getBody()->getContents())->user; + $this->expectException(\UnexpectedValueException::class); + $this->app['config']['expose.admin.validate_auth_tokens'] = false; + $this->app['config']['expose.admin.maximum_open_connections_per_user'] = 1; $this->createTestHttpServer(); @@ -372,9 +321,51 @@ class TunnelTest extends TestCase * the created test HTTP server. */ $client = $this->createClient(); - $response = $this->await($client->connectToServer('127.0.0.1:8085', '', $user->auth_token)); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel-1')); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel-2')); + } - $this->assertInstanceOf(\stdClass::class, $response); + /** @test */ + public function it_rejects_users_with_custom_max_connection_limit() + { + $this->expectException(\UnexpectedValueException::class); + $this->app['config']['expose.admin.validate_auth_tokens'] = true; + $this->app['config']['expose.admin.maximum_open_connections_per_user'] = 5; + + $user = $this->createUser([ + 'name' => 'Marcel', + 'can_specify_subdomains' => 1, + 'max_connections' => 2, + ]); + + $this->createTestHttpServer(); + + /** + * We create an expose client that connects to our server and shares + * the created test HTTP server. + */ + $client = $this->createClient(); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel-1', $user->auth_token)); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel-2', $user->auth_token)); + $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel-3', $user->auth_token)); + } + + /** @test */ + public function it_allows_clients_to_use_random_subdomains_if_custom_subdomains_are_forbidden() + { + $this->app['config']['expose.admin.validate_auth_tokens'] = true; + + $user = $this->createUser([ + 'name' => 'Marcel', + 'can_specify_subdomains' => 0, + ]); + + $this->createTestHttpServer(); + + $client = $this->createClient(); + $response = $this->await($client->connectToServer('127.0.0.1:8085', 'foo', $user->auth_token)); + + $this->assertNotSame('foo', $response->subdomain); } protected function startServer() @@ -405,6 +396,17 @@ class TunnelTest extends TestCase return app(Client::class); } + protected function createUser(array $data) + { + $response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [ + 'Host' => 'expose.localhost', + 'Authorization' => base64_encode('username:secret'), + 'Content-Type' => 'application/json', + ], json_encode($data))); + + return json_decode($response->getBody()->getContents())->user; + } + protected function createTestHttpServer() { $server = new Server($this->loop, function (ServerRequestInterface $request) {