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) {
|