From 1fc277fd5e9f8b2ce966f5ddda875d63702f57f8 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Tue, 8 Sep 2020 10:10:46 +0200 Subject: [PATCH] wip --- app/Client/Client.php | 1 + app/Contracts/ConnectionManager.php | 2 + app/Server/Connections/ConnectionManager.php | 20 ++++ .../Admin/StoreUsersController.php | 1 + .../Controllers/ControlMessageController.php | 21 ++++ .../UserRepository/DatabaseUserRepository.php | 5 +- ...03_add_tcp_sharing_flag_to_users_table.sql | 1 + resources/views/server/users/index.twig | 23 +++- tests/Feature/Server/ApiTest.php | 6 + tests/Feature/Server/TunnelTest.php | 106 ++++++++++++++++++ 10 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 database/migrations/03_add_tcp_sharing_flag_to_users_table.sql diff --git a/app/Client/Client.php b/app/Client/Client.php index 2829b48..0aef2aa 100644 --- a/app/Client/Client.php +++ b/app/Client/Client.php @@ -172,6 +172,7 @@ class Client $this->logger->info($data->message); $this->logger->info("Local-Port:\t\t{$port}"); + $this->logger->info("Shared-Port:\t\t{$data->shared_port}"); $this->logger->info("Expose-URL:\t\ttcp://{$host}:{$data->shared_port}."); $this->logger->line(''); diff --git a/app/Contracts/ConnectionManager.php b/app/Contracts/ConnectionManager.php index 25ab69d..b554a9e 100644 --- a/app/Contracts/ConnectionManager.php +++ b/app/Contracts/ConnectionManager.php @@ -27,4 +27,6 @@ interface ConnectionManager public function getConnections(): array; public function getConnectionsForAuthToken(string $authToken): array; + + public function getTcpConnectionsForAuthToken(string $authToken): array; } diff --git a/app/Server/Connections/ConnectionManager.php b/app/Server/Connections/ConnectionManager.php index 743c6e9..6d2a18d 100644 --- a/app/Server/Connections/ConnectionManager.php +++ b/app/Server/Connections/ConnectionManager.php @@ -173,9 +173,29 @@ class ConnectionManager implements ConnectionManagerContract ->filter(function ($connection) use ($authToken) { return $connection->authToken === $authToken; }) + ->filter(function ($connection) use ($authToken) { + return get_class($connection) === ControlConnection::class; + }) ->map(function ($connection) { return $connection->toArray(); }) + ->values() + ->toArray(); + } + + public function getTcpConnectionsForAuthToken(string $authToken): array + { + return collect($this->connections) + ->filter(function ($connection) use ($authToken) { + return $connection->authToken === $authToken; + }) + ->filter(function ($connection) use ($authToken) { + return get_class($connection) === TcpControlConnection::class; + }) + ->map(function ($connection) { + return $connection->toArray(); + }) + ->values() ->toArray(); } } diff --git a/app/Server/Http/Controllers/Admin/StoreUsersController.php b/app/Server/Http/Controllers/Admin/StoreUsersController.php index 14e3ff8..ab2986e 100644 --- a/app/Server/Http/Controllers/Admin/StoreUsersController.php +++ b/app/Server/Http/Controllers/Admin/StoreUsersController.php @@ -40,6 +40,7 @@ class StoreUsersController extends AdminController 'name' => $request->get('name'), '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'), ]; $this->userRepository diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index 213b36f..d1bccc2 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -120,6 +120,10 @@ class ControlMessageController implements MessageComponentInterface protected function handleTcpConnection(ConnectionInterface $connection, $data, $user = null) { + if (! $this->canShareTcpPorts($connection, $data, $user)) { + return; + } + try { $connectionInfo = $this->connectionManager->storeTcpConnection($data->port, $connection); } catch (NoFreePortAvailable $exception) { @@ -233,4 +237,21 @@ class ControlMessageController implements MessageComponentInterface return true; } + + protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user) + { + if (! is_null($user) && $user['can_share_tcp_ports'] === 0) { + $connection->send(json_encode([ + 'event' => 'authenticationFailed', + 'data' => [ + 'message' => config('expose.admin.messages.custom_subdomain_unauthorized'), + ], + ])); + $connection->close(); + + return false; + } + + return true; + } } diff --git a/app/Server/UserRepository/DatabaseUserRepository.php b/app/Server/UserRepository/DatabaseUserRepository.php index 43b15b1..d572f17 100644 --- a/app/Server/UserRepository/DatabaseUserRepository.php +++ b/app/Server/UserRepository/DatabaseUserRepository.php @@ -72,6 +72,7 @@ class DatabaseUserRepository implements UserRepository protected function getUserDetails(array $user) { $user['sites'] = $user['auth_token'] !== '' ? $this->connectionManager->getConnectionsForAuthToken($user['auth_token']) : []; + $user['tcp_connections'] = $user['auth_token'] !== '' ? $this->connectionManager->getTcpConnectionsForAuthToken($user['auth_token']) : []; return $user; } @@ -113,8 +114,8 @@ class DatabaseUserRepository implements UserRepository $deferred = new Deferred(); $this->database->query(" - INSERT INTO users (name, auth_token, can_specify_subdomains, created_at) - VALUES (:name, :auth_token, :can_specify_subdomains, DATETIME('now')) + 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')) ", $data) ->then(function (Result $result) use ($deferred) { $this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId]) diff --git a/database/migrations/03_add_tcp_sharing_flag_to_users_table.sql b/database/migrations/03_add_tcp_sharing_flag_to_users_table.sql new file mode 100644 index 0000000..2d180de --- /dev/null +++ b/database/migrations/03_add_tcp_sharing_flag_to_users_table.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD can_share_tcp_ports BOOLEAN DEFAULT 1; diff --git a/resources/views/server/users/index.twig b/resources/views/server/users/index.twig index fbdd6e7..67749aa 100644 --- a/resources/views/server/users/index.twig +++ b/resources/views/server/users/index.twig @@ -43,6 +43,25 @@ +
+ +
+
+
+ + +
+
+
+
@@ -144,6 +163,7 @@ userForm: { name: '', can_specify_subdomains: true, + can_share_tcp_ports: true, errors: {}, }, paginated: {{ paginated|json_encode|raw }} @@ -186,7 +206,8 @@ }).then((data) => { if (data.user) { this.userForm.name = ''; - this.userForm.can_specify_subdomains = 0; + this.userForm.can_specify_subdomains = true; + this.userForm.can_share_tcp_ports = true; this.userForm.errors = {}; this.users.unshift(data.user); } diff --git a/tests/Feature/Server/ApiTest.php b/tests/Feature/Server/ApiTest.php index ad53dc8..620a91e 100644 --- a/tests/Feature/Server/ApiTest.php +++ b/tests/Feature/Server/ApiTest.php @@ -88,6 +88,7 @@ class ApiTest extends TestCase $this->assertSame('Marcel', $user->name); $this->assertSame([], $user->sites); + $this->assertSame([], $user->tcp_connections); } /** @test */ @@ -115,6 +116,10 @@ class ApiTest extends TestCase $connection->httpRequest = new Request('GET', '/?authToken=some-other-token'); $connectionManager->storeConnection('some-different-host.test', 'different-subdomain', $connection); + $connection = \Mockery::mock(IoConnection::class); + $connection->httpRequest = new Request('GET', '/?authToken='.$createdUser->auth_token); + $connectionManager->storeTcpConnection(2525, $connection); + /** @var Response $response */ $response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users', [ 'Host' => 'expose.localhost', @@ -126,6 +131,7 @@ class ApiTest extends TestCase $users = $body->paginated->users; $this->assertCount(1, $users[0]->sites); + $this->assertCount(1, $users[0]->tcp_connections); $this->assertSame('some-host.test', $users[0]->sites[0]->host); $this->assertSame('fixed-subdomain', $users[0]->sites[0]->subdomain); } diff --git a/tests/Feature/Server/TunnelTest.php b/tests/Feature/Server/TunnelTest.php index cc612f6..8a5aaf0 100644 --- a/tests/Feature/Server/TunnelTest.php +++ b/tests/Feature/Server/TunnelTest.php @@ -9,6 +9,7 @@ use Clue\React\Buzz\Message\ResponseException; use GuzzleHttp\Psr7\Response; use Psr\Http\Message\ServerRequestInterface; use React\Http\Server; +use React\Socket\Connection; use Tests\Feature\TestCase; class TunnelTest extends TestCase @@ -22,6 +23,9 @@ class TunnelTest extends TestCase /** @var \React\Socket\Server */ protected $testHttpServer; + /** @var \React\Socket\Server */ + protected $testTcpServer; + public function setUp(): void { parent::setUp(); @@ -42,6 +46,10 @@ class TunnelTest extends TestCase $this->testHttpServer->close(); } + if (isset($this->testTcpServer)) { + $this->testTcpServer->close(); + } + parent::tearDown(); } @@ -81,6 +89,93 @@ class TunnelTest extends TestCase $this->assertSame('Hello World!', $response->getBody()->getContents()); } + /** @test */ + public function it_sends_incoming_requests_to_the_connected_client_via_tcp() + { + $this->createTestTcpServer(); + + $this->app['config']['expose.admin.validate_auth_tokens'] = false; + + /** + * We create an expose client that connects to our server and shares + * the created test HTTP server. + */ + $client = $this->createClient(); + $response = $this->await($client->connectToServerAndShareTcp(8085)); + + /** + * Once the client is connected, we connect to the + * created tunnel. + */ + $connector = new \React\Socket\Connector($this->loop); + $connection = $this->await($connector->connect('127.0.0.1:'.$response->shared_port)); + + $this->assertInstanceOf(Connection::class, $connection); + } + + /** @test */ + public function it_rejects_tcp_sharing_if_forbidden() + { + $this->createTestTcpServer(); + + $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_share_tcp_ports' => 0, + ]))); + + $user = json_decode($response->getBody()->getContents())->user; + + $this->expectException(\UnexpectedValueException::class); + + /** + * We create an expose client that connects to our server and shares + * the created test HTTP server. + */ + $client = $this->createClient(); + $this->await($client->connectToServerAndShareTcp(8085, $user->auth_token)); + } + + /** @test */ + public function it_allows_tcp_sharing_if_enabled_for_user() + { + $this->createTestTcpServer(); + + $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_share_tcp_ports' => 1, + ]))); + + $user = json_decode($response->getBody()->getContents())->user; + + /** + * We create an expose client that connects to our server and shares + * the created test HTTP server. + */ + $client = $this->createClient(); + $response = $this->await($client->connectToServerAndShareTcp(8085, $user->auth_token)); + + /** + * Once the client is connected, we connect to the + * created tunnel. + */ + $connector = new \React\Socket\Connector($this->loop); + $connection = $this->await($connector->connect('127.0.0.1:'.$response->shared_port)); + + $this->assertInstanceOf(Connection::class, $connection); + } + /** @test */ public function it_rejects_clients_with_invalid_auth_tokens() { @@ -221,4 +316,15 @@ class TunnelTest extends TestCase $this->testHttpServer = new \React\Socket\Server(8085, $this->loop); $server->listen($this->testHttpServer); } + + protected function createTestTcpServer() + { + $this->testTcpServer = new \React\Socket\Server(8085, $this->loop); + + $this->testTcpServer->on('connection', function (\React\Socket\ConnectionInterface $connection) { + $connection->write("Hello " . $connection->getRemoteAddress() . "!\n"); + + $connection->pipe($connection); + }); + } }