diff --git a/app/Client/Client.php b/app/Client/Client.php index e4829c8..2829b48 100644 --- a/app/Client/Client.php +++ b/app/Client/Client.php @@ -49,6 +49,11 @@ class Client } } + public function sharePort(int $port) + { + $this->connectToServerAndShareTcp($port, config('expose.auth_token')); + } + protected function prepareSharedUrl(string $sharedUrl): string { if (! $parsedUrl = parse_url($sharedUrl)) { @@ -87,14 +92,12 @@ class Client $clientConnection->on('close', function () use ($sharedUrl, $subdomain, $authToken) { $this->logger->error('Connection to server closed.'); - $this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken); + $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) { + $this->connectToServer($sharedUrl, $subdomain, $authToken); + }); }); - $connection->on('authenticationFailed', function ($data) use ($deferred) { - $this->logger->error($data->message); - - $this->exit($deferred); - }); + $this->attachCommonConnectionListeners($connection, $deferred); $connection->on('subdomainTaken', function ($data) use ($deferred) { $this->logger->error($data->message); @@ -102,20 +105,6 @@ class Client $this->exit($deferred); }); - $connection->on('setMaximumConnectionLength', function ($data) { - $timeoutSection = $this->logger->getOutput()->section(); - - $this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) { - $this->timeConnected++; - - $secondsRemaining = $data->length * 60 - $this->timeConnected; - $remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining)); - - $timeoutSection->clear(); - $timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S')); - }); - }); - $connection->on('authenticated', function ($data) use ($deferred, $sharedUrl) { $httpProtocol = $this->configuration->port() === 443 ? 'https' : 'http'; $host = $this->configuration->host(); @@ -136,7 +125,9 @@ class Client }); }, function (\Exception $e) use ($deferred, $sharedUrl, $subdomain, $authToken) { if ($this->connectionRetries > 0) { - $this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken); + $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) { + $this->connectToServer($sharedUrl, $subdomain, $authToken); + }); return; } @@ -149,6 +140,83 @@ class Client return $promise; } + public function connectToServerAndShareTcp(int $port, $authToken = ''): PromiseInterface + { + $deferred = new Deferred(); + $promise = $deferred->promise(); + + $wsProtocol = $this->configuration->port() === 443 ? 'wss' : 'ws'; + + connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [ + 'X-Expose-Control' => 'enabled', + ], $this->loop) + ->then(function (WebSocket $clientConnection) use ($port, $deferred, $authToken) { + $this->connectionRetries = 0; + + $connection = ControlConnection::create($clientConnection); + + $connection->authenticateTcp($port); + + $this->attachCommonConnectionListeners($connection, $deferred); + + $clientConnection->on('close', function () use ($port, $authToken) { + $this->logger->error('Connection to server closed.'); + + $this->retryConnectionOrExit(function () use ($port, $authToken) { + $this->connectToServerAndShareTcp($port, $authToken); + }); + }); + + $connection->on('authenticated', function ($data) use ($deferred, $port) { + $host = $this->configuration->host(); + + $this->logger->info($data->message); + $this->logger->info("Local-Port:\t\t{$port}"); + $this->logger->info("Expose-URL:\t\ttcp://{$host}:{$data->shared_port}."); + $this->logger->line(''); + + $deferred->resolve($data); + }); + }, function (\Exception $e) use ($deferred, $port, $authToken) { + if ($this->connectionRetries > 0) { + $this->retryConnectionOrExit(function () use ($port, $authToken) { + $this->connectToServerAndShareTcp($port, $authToken); + }); + + return; + } + $this->logger->error('Could not connect to the server.'); + $this->logger->error($e->getMessage()); + + $this->exit($deferred); + }); + + return $promise; + } + + protected function attachCommonConnectionListeners(ControlConnection $connection, Deferred $deferred) + { + $connection->on('authenticationFailed', function ($data) use ($deferred) { + $this->logger->error($data->message); + + $this->exit($deferred); + }); + + $connection->on('setMaximumConnectionLength', function ($data) { + $timeoutSection = $this->logger->getOutput()->section(); + + $this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) { + $this->timeConnected++; + + $secondsRemaining = $data->length * 60 - $this->timeConnected; + $remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining)); + + $timeoutSection->clear(); + $timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S')); + }); + }); + } + protected function exit(Deferred $deferred) { $deferred->reject(); @@ -158,15 +226,15 @@ class Client }); } - protected function retryConnectionOrExit(string $sharedUrl, $subdomain, $authToken = '') + protected function retryConnectionOrExit(callable $retry) { $this->connectionRetries++; if ($this->connectionRetries <= static::MAX_CONNECTION_RETRIES) { - $this->loop->addTimer($this->connectionRetries, function () use ($sharedUrl, $subdomain, $authToken) { + $this->loop->addTimer($this->connectionRetries, function () use ($retry) { $this->logger->info("Retrying connection ({$this->connectionRetries}/".static::MAX_CONNECTION_RETRIES.')'); - $this->connectToServer($sharedUrl, $subdomain, $authToken); + $retry(); }); } else { exit(1); diff --git a/app/Client/Connections/ControlConnection.php b/app/Client/Connections/ControlConnection.php index 83ecae9..02bc31b 100644 --- a/app/Client/Connections/ControlConnection.php +++ b/app/Client/Connections/ControlConnection.php @@ -52,17 +52,34 @@ class ControlConnection $this->proxyManager->createProxy($this->clientId, $data); } + public function createTcpProxy($data) + { + $this->proxyManager->createTcpProxy($this->clientId, $data); + } + public function authenticate(string $sharedHost, string $subdomain) { $this->socket->send(json_encode([ 'event' => 'authenticate', 'data' => [ + 'type' => 'http', 'host' => $sharedHost, 'subdomain' => empty($subdomain) ? null : $subdomain, ], ])); } + public function authenticateTcp(int $port) + { + $this->socket->send(json_encode([ + 'event' => 'authenticate', + 'data' => [ + 'type' => 'tcp', + 'port' => $port, + ], + ])); + } + public function ping() { $this->socket->send(json_encode([ diff --git a/app/Client/Factory.php b/app/Client/Factory.php index 0363ed3..d391397 100644 --- a/app/Client/Factory.php +++ b/app/Client/Factory.php @@ -109,6 +109,13 @@ class Factory return $this; } + public function sharePort(int $port) + { + app('expose.client')->sharePort($port); + + return $this; + } + protected function addRoutes() { $this->router->get('/', DashboardController::class); diff --git a/app/Client/ProxyManager.php b/app/Client/ProxyManager.php index 2a9a07c..0c9b5c5 100644 --- a/app/Client/ProxyManager.php +++ b/app/Client/ProxyManager.php @@ -3,6 +3,7 @@ namespace App\Client; use App\Client\Http\HttpClient; +use React\Socket\Connector; use function Ratchet\Client\connect; use Ratchet\Client\WebSocket; use React\EventLoop\LoopInterface; @@ -43,6 +44,36 @@ class ProxyManager }); } + public function createTcpProxy(string $clientId, $connectionData) + { + $protocol = $this->configuration->port() === 443 ? 'wss' : 'ws'; + + connect($protocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control", [], [ + 'X-Expose-Control' => 'enabled', + ], $this->loop) + ->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) { + $connector = new Connector($this->loop); + + $connector->connect('127.0.0.1:'.$connectionData->port)->then(function ($connection) use ($proxyConnection) { + $connection->on('data', function ($data) use ($proxyConnection) { + $proxyConnection->send($data); + }); + + $proxyConnection->on('message', function ($message) use ($proxyConnection, $connection) { + $connection->write($message); + }); + }); + + $proxyConnection->send(json_encode([ + 'event' => 'registerTcpProxy', + 'data' => [ + 'tcp_request_id' => $connectionData->tcp_request_id ?? null, + 'client_id' => $clientId, + ], + ])); + }); + } + protected function performRequest(WebSocket $proxyConnection, $requestId, string $requestData) { app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $requestId); diff --git a/app/Commands/SharePortCommand.php b/app/Commands/SharePortCommand.php new file mode 100644 index 0000000..b05e068 --- /dev/null +++ b/app/Commands/SharePortCommand.php @@ -0,0 +1,40 @@ +bind(CliRequestLogger::class, function () { + return new CliRequestLogger(new ConsoleOutput()); + }); + + return $this; + } + + public function handle() + { + $this->configureConnectionLogger(); + + (new Factory()) + ->setLoop(app(LoopInterface::class)) + ->setHost(config('expose.host', 'localhost')) + ->setPort(config('expose.port', 8080)) + ->setAuth($this->option('auth')) + ->createClient() + ->sharePort($this->argument('port')) + ->createHttpServer() + ->run(); + } +} diff --git a/app/Contracts/ConnectionManager.php b/app/Contracts/ConnectionManager.php index a208ef6..393b97f 100644 --- a/app/Contracts/ConnectionManager.php +++ b/app/Contracts/ConnectionManager.php @@ -10,6 +10,8 @@ interface ConnectionManager { public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection; + public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection; + public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength); public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection; diff --git a/app/Server/Connections/ConnectionManager.php b/app/Server/Connections/ConnectionManager.php index 00f2034..2544901 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 Ratchet\ConnectionInterface; use React\EventLoop\LoopInterface; +use React\Socket\Server; class ConnectionManager implements ConnectionManagerContract { @@ -53,6 +54,24 @@ class ConnectionManager implements ConnectionManagerContract return $storedConnection; } + public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection + { + $clientId = (string) uniqid(); + + $connection->client_id = $clientId; + + $storedConnection = new TcpControlConnection($connection, $port, $this->getSharedTcpServer(), $clientId); + + $this->connections[] = $storedConnection; + + return $storedConnection; + } + + protected function getSharedTcpServer(): Server + { + return new Server(0, $this->loop); + } + public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection { $this->httpConnections[$requestId] = new HttpConnection($httpConnection); diff --git a/app/Server/Connections/ControlConnection.php b/app/Server/Connections/ControlConnection.php index bc821b2..cba75fc 100644 --- a/app/Server/Connections/ControlConnection.php +++ b/app/Server/Connections/ControlConnection.php @@ -55,6 +55,7 @@ class ControlConnection public function toArray() { return [ + 'type' => 'http', 'host' => $this->host, 'client_id' => $this->client_id, 'subdomain' => $this->subdomain, diff --git a/app/Server/Connections/TcpControlConnection.php b/app/Server/Connections/TcpControlConnection.php new file mode 100644 index 0000000..93c1c2b --- /dev/null +++ b/app/Server/Connections/TcpControlConnection.php @@ -0,0 +1,101 @@ +socket = $socket; + $this->client_id = $clientId; + $this->shared_server = $sharedServer; + $this->port = $port; + $this->shared_at = now()->toDateTimeString(); + $this->shared_port = parse_url($sharedServer->getAddress(), PHP_URL_PORT); + + $this->configureServer($sharedServer); + } + + public function setMaximumConnectionLength(int $maximumConnectionLength) + { + $this->socket->send(json_encode([ + 'event' => 'setMaximumConnectionLength', + 'data' => [ + 'length' => $maximumConnectionLength, + ], + ])); + } + + public function registerProxy($requestId) + { + $this->socket->send(json_encode([ + 'event' => 'createProxy', + 'data' => [ + 'request_id' => $requestId, + 'client_id' => $this->client_id, + ], + ])); + } + + public function registerTcpProxy($requestId) + { + $this->socket->send(json_encode([ + 'event' => 'createTcpProxy', + 'data' => [ + 'port' => $this->port, + 'tcp_request_id' => $requestId, + 'client_id' => $this->client_id, + ], + ])); + } + + public function close() + { + $this->socket->close(); + } + + public function toArray() + { + return [ + 'type' => 'tcp', + 'port' => $this->port, + 'client_id' => $this->client_id, + 'shared_port' => $this->shared_port, + 'shared_at' => $this->shared_at, + ]; + } + + protected function configureServer(Server $sharedServer) + { + $requestId = uniqid(); + + $sharedServer->on('connection', function(\React\Socket\ConnectionInterface $connection) use ($requestId) { + + $this->proxyConnection = $connection; + + $this->once('tcp_proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($connection) { + $this->proxy = $proxy; + dump("Proxy ready"); + + $connection->on('data', function($data) use ($proxy) { + $proxy->send($data); + }); + + $connection->resume(); + }); + + $connection->pause(); + $this->registerTcpProxy($requestId); + }); + } +} diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index d19fb9e..5d0c451 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -54,6 +54,10 @@ class ControlMessageController implements MessageComponentInterface if (isset($connection->request_id)) { return $this->sendResponseToHttpConnection($connection->request_id, $msg); } + if (isset($connection->tcp_request_id)) { + $connectionInfo = $this->connectionManager->findControlConnectionForClientId($connection->tcp_client_id); + $connectionInfo->proxyConnection->write($msg); + } try { $payload = json_decode($msg); @@ -78,22 +82,11 @@ class ControlMessageController implements MessageComponentInterface { $this->verifyAuthToken($connection) ->then(function () use ($connection, $data) { - if (! $this->hasValidSubdomain($connection, $data->subdomain)) { - return; + if ($data->type === 'http') { + $this->handleHttpConnection($connection, $data); + } elseif($data->type === 'tcp') { + $this->handleTcpConnection($connection, $data); } - - $connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection); - - $this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length')); - - $connection->send(json_encode([ - 'event' => 'authenticated', - 'data' => [ - 'message' => config('expose.admin.messages.message_of_the_day'), - 'subdomain' => $connectionInfo->subdomain, - 'client_id' => $connectionInfo->client_id, - ], - ])); }, function () use ($connection) { $connection->send(json_encode([ 'event' => 'authenticationFailed', @@ -105,6 +98,41 @@ class ControlMessageController implements MessageComponentInterface }); } + protected function handleHttpConnection(ConnectionInterface $connection, $data) + { + if (! $this->hasValidSubdomain($connection, $data->subdomain)) { + return; + } + + $connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection); + + $this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length')); + + $connection->send(json_encode([ + 'event' => 'authenticated', + 'data' => [ + 'message' => config('expose.admin.messages.message_of_the_day'), + 'subdomain' => $connectionInfo->subdomain, + 'client_id' => $connectionInfo->client_id, + ], + ])); + } + + protected function handleTcpConnection(ConnectionInterface $connection, $data) + { + $connectionInfo = $this->connectionManager->storeTcpConnection($data->port, $connection); + + $connection->send(json_encode([ + 'event' => 'authenticated', + 'data' => [ + 'message' => config('expose.admin.messages.message_of_the_day'), + 'port' => $connectionInfo->port, + 'shared_port' => $connectionInfo->shared_port, + 'client_id' => $connectionInfo->client_id, + ], + ])); + } + protected function registerProxy(ConnectionInterface $connection, $data) { $connection->request_id = $data->request_id; @@ -116,6 +144,18 @@ class ControlMessageController implements MessageComponentInterface ]); } + protected function registerTcpProxy(ConnectionInterface $connection, $data) + { + $connection->tcp_client_id = $data->client_id; + $connection->tcp_request_id = $data->tcp_request_id; + + $connectionInfo = $this->connectionManager->findControlConnectionForClientId($data->client_id); + + $connectionInfo->emit('tcp_proxy_ready_'.$data->tcp_request_id, [ + $connection, + ]); + } + /** * {@inheritdoc} */ diff --git a/builds/expose b/builds/expose index 7a457c1..e9d7bf6 100755 Binary files a/builds/expose and b/builds/expose differ