Allow specifying maximum connection counts per user

This commit is contained in:
Marcel Pociot
2021-05-28 16:51:48 +02:00
parent 717e8cf05c
commit a3d1735b6e
10 changed files with 164 additions and 73 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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])

Binary file not shown.

View File

@@ -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

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD max_connections INTEGER NOT NULL DEFAULT 0;

View File

@@ -63,6 +63,21 @@
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="max_connections"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Maximum Open Connections
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<input id="max_connections"
type="number"
min="0"
v-model="userForm.max_connections"
class="flex-1 border-gray-300 block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/>
</div>
</div>
</div>
</div>
<div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end">
@@ -99,6 +114,9 @@
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Auth-Token
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Max Connections
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Custom Subdomains
</th>
@@ -119,6 +137,9 @@
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ user.auth_token }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ user.max_connections }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
<span v-if="user.can_specify_subdomains === 0">
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);
}

View File

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