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);
+ });
+ }
}