From cec52c42299d722738a45927c31217963d1c9a97 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Sun, 1 Nov 2020 22:40:17 +0100 Subject: [PATCH] Allow users to specify custom hostnames --- app/Client/Client.php | 24 +- app/Client/Connections/ControlConnection.php | 3 +- app/Client/Factory.php | 4 +- app/Commands/ShareCommand.php | 13 +- .../ShareCurrentWorkingDirectoryCommand.php | 4 +- app/Contracts/ConnectionManager.php | 4 +- app/Contracts/HostnameRepository.php | 22 ++ .../Connections/ConnectionConfiguration.php | 35 +++ app/Server/Connections/ConnectionManager.php | 18 +- app/Server/Connections/ControlConnection.php | 5 +- app/Server/Factory.php | 16 ++ .../DatabaseHostnameRepository.php | 132 +++++++++ .../Admin/DeleteHostnameController.php | 45 +++ .../Admin/GetUserDetailsController.php | 27 +- .../Admin/StoreHostnameController.php | 78 ++++++ .../Admin/StoreUsersController.php | 1 + .../Controllers/ControlMessageController.php | 106 ++++++- .../Controllers/TunnelMessageController.php | 19 +- .../UserRepository/DatabaseUserRepository.php | 4 +- config/expose.php | 2 + ...d_custom_hostnames_flag_to_users_table.sql | 1 + .../migrations/06_create_hostnames_table.sql | 7 + resources/views/server/sites/index.twig | 14 +- resources/views/server/users/index.twig | 32 +++ tests/Feature/Server/AdminTest.php | 2 +- tests/Feature/Server/ApiTest.php | 92 +++++- tests/Feature/Server/TunnelTest.php | 264 +++++++++++++++++- tests/Feature/TestCase.php | 2 +- 28 files changed, 913 insertions(+), 63 deletions(-) create mode 100644 app/Contracts/HostnameRepository.php create mode 100644 app/Server/Connections/ConnectionConfiguration.php create mode 100644 app/Server/HostnameRepository/DatabaseHostnameRepository.php create mode 100644 app/Server/Http/Controllers/Admin/DeleteHostnameController.php create mode 100644 app/Server/Http/Controllers/Admin/StoreHostnameController.php create mode 100644 database/migrations/05_add_custom_hostnames_flag_to_users_table.sql create mode 100644 database/migrations/06_create_hostnames_table.sql diff --git a/app/Client/Client.php b/app/Client/Client.php index 6380539..cea0f85 100644 --- a/app/Client/Client.php +++ b/app/Client/Client.php @@ -40,12 +40,12 @@ class Client $this->logger = $logger; } - public function share(string $sharedUrl, array $subdomains = []) + public function share(string $sharedUrl, array $subdomains = [], string $hostname = '') { $sharedUrl = $this->prepareSharedUrl($sharedUrl); foreach ($subdomains as $subdomain) { - $this->connectToServer($sharedUrl, $subdomain, config('expose.auth_token')); + $this->connectToServer($sharedUrl, $subdomain, $hostname, config('expose.auth_token')); } } @@ -72,7 +72,7 @@ class Client return $url; } - public function connectToServer(string $sharedUrl, $subdomain, $authToken = ''): PromiseInterface + public function connectToServer(string $sharedUrl, $subdomain, $hostname = '', $authToken = ''): PromiseInterface { $deferred = new Deferred(); $promise = $deferred->promise(); @@ -82,18 +82,18 @@ class Client connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [ 'X-Expose-Control' => 'enabled', ], $this->loop) - ->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $deferred, $authToken) { + ->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $hostname, $deferred, $authToken) { $this->connectionRetries = 0; $connection = ControlConnection::create($clientConnection); - $connection->authenticate($sharedUrl, $subdomain); + $connection->authenticate($sharedUrl, $subdomain, $hostname); - $clientConnection->on('close', function () use ($sharedUrl, $subdomain, $authToken) { + $clientConnection->on('close', function () use ($sharedUrl, $subdomain, $hostname, $authToken) { $this->logger->error('Connection to server closed.'); - $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) { - $this->connectToServer($sharedUrl, $subdomain, $authToken); + $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $hostname, $authToken) { + $this->connectToServer($sharedUrl, $subdomain, $hostname, $authToken); }); }); @@ -113,10 +113,16 @@ class Client $host .= ":{$this->configuration->port()}"; } + if ($data->hostname !== '' && ! is_null($data->hostname)) { + $exposeUrl = "{$httpProtocol}://{$data->hostname}"; + } else { + $exposeUrl = "{$httpProtocol}://{$data->subdomain}.{$host}"; + } + $this->logger->info($data->message); $this->logger->info("Local-URL:\t\t{$sharedUrl}"); $this->logger->info("Dashboard-URL:\t\thttp://127.0.0.1:".config()->get('expose.dashboard_port')); - $this->logger->info("Expose-URL:\t\t{$httpProtocol}://{$data->subdomain}.{$host}"); + $this->logger->info("Expose-URL:\t\t{$exposeUrl}"); $this->logger->line(''); static::$subdomains[] = "{$httpProtocol}://{$data->subdomain}.{$host}"; diff --git a/app/Client/Connections/ControlConnection.php b/app/Client/Connections/ControlConnection.php index 02bc31b..f528644 100644 --- a/app/Client/Connections/ControlConnection.php +++ b/app/Client/Connections/ControlConnection.php @@ -57,7 +57,7 @@ class ControlConnection $this->proxyManager->createTcpProxy($this->clientId, $data); } - public function authenticate(string $sharedHost, string $subdomain) + public function authenticate(string $sharedHost, ?string $subdomain, ?string $hostname) { $this->socket->send(json_encode([ 'event' => 'authenticate', @@ -65,6 +65,7 @@ class ControlConnection 'type' => 'http', 'host' => $sharedHost, 'subdomain' => empty($subdomain) ? null : $subdomain, + 'hostname' => empty($hostname) ? null : $hostname, ], ])); } diff --git a/app/Client/Factory.php b/app/Client/Factory.php index d391397..c77837a 100644 --- a/app/Client/Factory.php +++ b/app/Client/Factory.php @@ -102,9 +102,9 @@ class Factory return $this; } - public function share($sharedUrl, $subdomain = null) + public function share($sharedUrl, $subdomain = null, $hostname = null) { - app('expose.client')->share($sharedUrl, $subdomain); + app('expose.client')->share($sharedUrl, $subdomain, $hostname); return $this; } diff --git a/app/Commands/ShareCommand.php b/app/Commands/ShareCommand.php index fcd9919..7375200 100644 --- a/app/Commands/ShareCommand.php +++ b/app/Commands/ShareCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; class ShareCommand extends Command { - protected $signature = 'share {host} {--subdomain=} {--auth=}'; + protected $signature = 'share {host} {--hostname=} {--subdomain=} {--auth=}'; protected $description = 'Share a local url with a remote expose server'; @@ -25,6 +25,11 @@ class ShareCommand extends Command public function handle() { + if (! empty($this->option('hostname')) && ! empty($this->option('subdomain'))) { + $this->error('You can only specify one. Either a custom hostname or a subdomain.'); + return; + } + $this->configureConnectionLogger(); (new Factory()) @@ -33,7 +38,11 @@ class ShareCommand extends Command ->setPort(config('expose.port', 8080)) ->setAuth($this->option('auth')) ->createClient() - ->share($this->argument('host'), explode(',', $this->option('subdomain'))) + ->share( + $this->argument('host'), + explode(',', $this->option('subdomain')), + $this->option('hostname') + ) ->createHttpServer() ->run(); } diff --git a/app/Commands/ShareCurrentWorkingDirectoryCommand.php b/app/Commands/ShareCurrentWorkingDirectoryCommand.php index e6e117b..231cce2 100644 --- a/app/Commands/ShareCurrentWorkingDirectoryCommand.php +++ b/app/Commands/ShareCurrentWorkingDirectoryCommand.php @@ -4,7 +4,7 @@ namespace App\Commands; class ShareCurrentWorkingDirectoryCommand extends ShareCommand { - protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=}'; + protected $signature = 'share-cwd {host?} {--hostname=} {--subdomain=} {--auth=}'; public function handle() { @@ -13,7 +13,7 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand $this->input->setArgument('host', $host); - if (! $this->option('subdomain')) { + if (! $this->option('subdomain') && ! $this->option('hostname')) { $this->input->setOption('subdomain', $subdomain); } diff --git a/app/Contracts/ConnectionManager.php b/app/Contracts/ConnectionManager.php index b554a9e..253a4b0 100644 --- a/app/Contracts/ConnectionManager.php +++ b/app/Contracts/ConnectionManager.php @@ -8,7 +8,7 @@ use Ratchet\ConnectionInterface; interface ConnectionManager { - public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection; + public function storeConnection(string $host, ?string $subdomain, ?string $hostname, ConnectionInterface $connection): ControlConnection; public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection; @@ -22,6 +22,8 @@ interface ConnectionManager public function findControlConnectionForSubdomain($subdomain): ?ControlConnection; + public function findControlConnectionForHostname(string $hostname): ?ControlConnection; + public function findControlConnectionForClientId(string $clientId): ?ControlConnection; public function getConnections(): array; diff --git a/app/Contracts/HostnameRepository.php b/app/Contracts/HostnameRepository.php new file mode 100644 index 0000000..929c352 --- /dev/null +++ b/app/Contracts/HostnameRepository.php @@ -0,0 +1,22 @@ +subdomain = $subdomain; + $this->hostname = $hostname; + } + + public static function withSubdomain($subdomain) + { + return new static($subdomain, null); + } + + public static function withHostname($hostname) + { + return new static(null, $hostname); + } + + public function getSubdomain() + { + return $this->subdomain; + } + + public function getHostname() + { + return $this->hostname; + } +} diff --git a/app/Server/Connections/ConnectionManager.php b/app/Server/Connections/ConnectionManager.php index 3978d4d..9f04d8d 100644 --- a/app/Server/Connections/ConnectionManager.php +++ b/app/Server/Connections/ConnectionManager.php @@ -43,16 +43,23 @@ class ConnectionManager implements ConnectionManagerContract }); } - public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection + public function storeConnection(string $host, ?string $subdomain, ?string $hostname, ConnectionInterface $connection): ControlConnection { $clientId = (string) uniqid(); $connection->client_id = $clientId; + if (! is_null($hostname) && $hostname !== '') { + $subdomain = ''; + } else { + $subdomain = $subdomain ?? $this->subdomainGenerator->generateSubdomain(); + } + $storedConnection = new ControlConnection( $connection, $host, - $subdomain ?? $this->subdomainGenerator->generateSubdomain(), + $subdomain, + $hostname, $clientId, $this->getAuthTokenFromConnection($connection) ); @@ -150,6 +157,13 @@ class ConnectionManager implements ConnectionManagerContract }); } + public function findControlConnectionForHostname($hostname): ?ControlConnection + { + return collect($this->connections)->last(function ($connection) use ($hostname) { + return $connection->hostname == $hostname; + }); + } + public function findControlConnectionForClientId(string $clientId): ?ControlConnection { return collect($this->connections)->last(function ($connection) use ($clientId) { diff --git a/app/Server/Connections/ControlConnection.php b/app/Server/Connections/ControlConnection.php index 80b1d31..b49d8ca 100644 --- a/app/Server/Connections/ControlConnection.php +++ b/app/Server/Connections/ControlConnection.php @@ -14,15 +14,17 @@ class ControlConnection public $host; public $authToken; public $subdomain; + public $hostname; public $client_id; public $proxies = []; protected $shared_at; - public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId, string $authToken = '') + public function __construct(ConnectionInterface $socket, string $host, string $subdomain, ?string $hostname, string $clientId, string $authToken = '') { $this->socket = $socket; $this->host = $host; $this->subdomain = $subdomain; + $this->hostname = $hostname; $this->client_id = $clientId; $this->authToken = $authToken; $this->shared_at = now()->toDateTimeString(); @@ -64,6 +66,7 @@ class ControlConnection 'client_id' => $this->client_id, 'auth_token' => $this->authToken, 'subdomain' => $this->subdomain, + 'hostname' => $this->hostname, 'shared_at' => $this->shared_at, ]; } diff --git a/app/Server/Factory.php b/app/Server/Factory.php index 9f7e728..0a7861f 100644 --- a/app/Server/Factory.php +++ b/app/Server/Factory.php @@ -3,12 +3,14 @@ namespace App\Server; use App\Contracts\ConnectionManager as ConnectionManagerContract; +use App\Contracts\HostnameRepository; use App\Contracts\SubdomainGenerator; use App\Contracts\SubdomainRepository; use App\Contracts\UserRepository; use App\Http\RouteGenerator; use App\Http\Server as HttpServer; use App\Server\Connections\ConnectionManager; +use App\Server\Http\Controllers\Admin\DeleteHostnameController; use App\Server\Http\Controllers\Admin\DeleteSubdomainController; use App\Server\Http\Controllers\Admin\DeleteUsersController; use App\Server\Http\Controllers\Admin\DisconnectSiteController; @@ -23,6 +25,7 @@ use App\Server\Http\Controllers\Admin\ListTcpConnectionsController; use App\Server\Http\Controllers\Admin\ListUsersController; use App\Server\Http\Controllers\Admin\RedirectToUsersController; use App\Server\Http\Controllers\Admin\ShowSettingsController; +use App\Server\Http\Controllers\Admin\StoreHostnameController; use App\Server\Http\Controllers\Admin\StoreSettingsController; use App\Server\Http\Controllers\Admin\StoreSubdomainController; use App\Server\Http\Controllers\Admin\StoreUsersController; @@ -135,6 +138,8 @@ class Factory $this->router->get('/api/users/{id}', GetUserDetailsController::class, $adminCondition); $this->router->post('/api/subdomains', StoreSubdomainController::class, $adminCondition); $this->router->delete('/api/subdomains/{subdomain}', DeleteSubdomainController::class, $adminCondition); + $this->router->post('/api/hostnames', StoreHostnameController::class, $adminCondition); + $this->router->delete('/api/hostnames/{hostname}', DeleteHostnameController::class, $adminCondition); $this->router->delete('/api/users/{id}', DeleteUsersController::class, $adminCondition); $this->router->get('/api/sites', GetSitesController::class, $adminCondition); $this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition); @@ -177,6 +182,7 @@ class Factory ->bindSubdomainGenerator() ->bindUserRepository() ->bindSubdomainRepository() + ->bindHostnameRepository() ->bindDatabase() ->ensureDatabaseIsInitialized() ->bindConnectionManager() @@ -222,6 +228,15 @@ class Factory return $this; } + protected function bindHostnameRepository() + { + app()->singleton(HostnameRepository::class, function () { + return app(config('expose.admin.hostname_repository')); + }); + + return $this; + } + protected function bindDatabase() { app()->singleton(DatabaseInterface::class, function () { @@ -248,6 +263,7 @@ class Factory ->files() ->ignoreDotFiles(true) ->in(database_path('migrations')) + ->sortByName() ->name('*.sql'); /** @var SplFileInfo $migration */ diff --git a/app/Server/HostnameRepository/DatabaseHostnameRepository.php b/app/Server/HostnameRepository/DatabaseHostnameRepository.php new file mode 100644 index 0000000..5837e38 --- /dev/null +++ b/app/Server/HostnameRepository/DatabaseHostnameRepository.php @@ -0,0 +1,132 @@ +database = $database; + } + + public function getHostnames(): PromiseInterface + { + $deferred = new Deferred(); + + $this->database + ->query('SELECT * FROM hostnames ORDER by created_at DESC') + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows); + }); + + return $deferred->promise(); + } + + public function getHostnameById($id): PromiseInterface + { + $deferred = new Deferred(); + + $this->database + ->query('SELECT * FROM hostnames WHERE id = :id', ['id' => $id]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows[0] ?? null); + }); + + return $deferred->promise(); + } + + public function getHostnameByName(string $name): PromiseInterface + { + $deferred = new Deferred(); + + $this->database + ->query('SELECT * FROM hostnames WHERE hostname = :name', ['name' => $name]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows[0] ?? null); + }); + + return $deferred->promise(); + } + + public function getHostnamesByUserId($id): PromiseInterface + { + $deferred = new Deferred(); + + $this->database + ->query('SELECT * FROM hostnames WHERE user_id = :user_id ORDER by created_at DESC', [ + 'user_id' => $id, + ]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows); + }); + + return $deferred->promise(); + } + + public function storeHostname(array $data): PromiseInterface + { + $deferred = new Deferred(); + + $this->getHostnameByName($data['hostname']) + ->then(function ($registeredHostname) use ($data, $deferred) { + if (! is_null($registeredHostname)) { + $deferred->resolve(null); + + return; + } + + $this->database->query(" + INSERT INTO hostnames (user_id, hostname, created_at) + VALUES (:user_id, :hostname, DATETIME('now')) + ", $data) + ->then(function (Result $result) use ($deferred) { + $this->database->query('SELECT * FROM hostnames WHERE id = :id', ['id' => $result->insertId]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows[0]); + }); + }); + }); + + return $deferred->promise(); + } + + public function getHostnamesByUserIdAndName($id, $name): PromiseInterface + { + $deferred = new Deferred(); + + $this->database + ->query('SELECT * FROM hostnames WHERE user_id = :user_id AND hostname = :name ORDER by created_at DESC', [ + 'user_id' => $id, + 'name' => $name, + ]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows); + }); + + return $deferred->promise(); + } + + public function deleteHostnameForUserId($userId, $hostnameId): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('DELETE FROM hostnames WHERE id = :id AND user_id = :user_id', [ + 'id' => $hostnameId, + 'user_id' => $userId, + ]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result); + }); + + return $deferred->promise(); + } +} diff --git a/app/Server/Http/Controllers/Admin/DeleteHostnameController.php b/app/Server/Http/Controllers/Admin/DeleteHostnameController.php new file mode 100644 index 0000000..94f5f2f --- /dev/null +++ b/app/Server/Http/Controllers/Admin/DeleteHostnameController.php @@ -0,0 +1,45 @@ +userRepository = $userRepository; + $this->hostnameRepository = $hostnameRepository; + } + + public function handle(Request $request, ConnectionInterface $httpConnection) + { + $this->userRepository->getUserByToken($request->get('auth_token', '')) + ->then(function ($user) use ($request, $httpConnection) { + if (is_null($user)) { + $httpConnection->send(respond_json(['error' => 'The user does not exist'], 404)); + $httpConnection->close(); + + return; + } + + $this->hostnameRepository->deleteHostnameForUserId($user['id'], $request->get('hostname')) + ->then(function ($deleted) use ($httpConnection) { + $httpConnection->send(respond_json(['deleted' => $deleted], 200)); + $httpConnection->close(); + }); + }); + } +} diff --git a/app/Server/Http/Controllers/Admin/GetUserDetailsController.php b/app/Server/Http/Controllers/Admin/GetUserDetailsController.php index 6fe8b0d..9f5e7cb 100644 --- a/app/Server/Http/Controllers/Admin/GetUserDetailsController.php +++ b/app/Server/Http/Controllers/Admin/GetUserDetailsController.php @@ -2,6 +2,7 @@ namespace App\Server\Http\Controllers\Admin; +use App\Contracts\HostnameRepository; use App\Contracts\SubdomainRepository; use App\Contracts\UserRepository; use Illuminate\Http\Request; @@ -17,10 +18,14 @@ class GetUserDetailsController extends AdminController /** @var SubdomainRepository */ protected $subdomainRepository; - public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository) + /** @var HostnameRepository */ + protected $hostnameRepository; + + public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository, HostnameRepository $hostnameRepository) { $this->userRepository = $userRepository; $this->subdomainRepository = $subdomainRepository; + $this->hostnameRepository = $hostnameRepository; } public function handle(Request $request, ConnectionInterface $httpConnection) @@ -29,15 +34,19 @@ class GetUserDetailsController extends AdminController ->getUserById($request->get('id')) ->then(function ($user) use ($httpConnection, $request) { $this->subdomainRepository->getSubdomainsByUserId($request->get('id')) - ->then(function ($subdomains) use ($httpConnection, $user) { - $httpConnection->send( - respond_json([ - 'user' => $user, - 'subdomains' => $subdomains, - ]) - ); + ->then(function ($subdomains) use ($httpConnection, $user, $request) { + $this->hostnameRepository->getHostnamesByUserId($request->get('id')) + ->then(function ($hostnames) use ($httpConnection, $user, $subdomains) { + $httpConnection->send( + respond_json([ + 'user' => $user, + 'subdomains' => $subdomains, + 'hostnames' => $hostnames, + ]) + ); - $httpConnection->close(); + $httpConnection->close(); + }); }); }); } diff --git a/app/Server/Http/Controllers/Admin/StoreHostnameController.php b/app/Server/Http/Controllers/Admin/StoreHostnameController.php new file mode 100644 index 0000000..16d347c --- /dev/null +++ b/app/Server/Http/Controllers/Admin/StoreHostnameController.php @@ -0,0 +1,78 @@ +userRepository = $userRepository; + $this->hostnameRepository = $hostnameRepository; + } + + public function handle(Request $request, ConnectionInterface $httpConnection) + { + $validator = Validator::make($request->all(), [ + 'hostname' => 'required', + ], [ + 'required' => 'The :attribute field is required.', + ]); + + if ($validator->fails()) { + $httpConnection->send(respond_json(['errors' => $validator->getMessageBag()], 401)); + $httpConnection->close(); + + return; + } + + $this->userRepository->getUserByToken($request->get('auth_token', '')) + ->then(function ($user) use ($httpConnection, $request) { + if (is_null($user)) { + $httpConnection->send(respond_json(['error' => 'The user does not exist'], 404)); + $httpConnection->close(); + + return; + } + + if ($user['can_specify_hostnames'] === 0) { + $httpConnection->send(respond_json(['error' => 'The user is not allowed to reserve hostnames.'], 401)); + $httpConnection->close(); + + return; + } + + $insertData = [ + 'user_id' => $user['id'], + 'hostname' => $request->get('hostname'), + ]; + + $this->hostnameRepository + ->storeHostname($insertData) + ->then(function ($hostname) use ($httpConnection) { + if (is_null($hostname)) { + $httpConnection->send(respond_json(['error' => 'The hostname is already taken.'], 422)); + $httpConnection->close(); + + return; + } + $httpConnection->send(respond_json(['hostname' => $hostname], 200)); + $httpConnection->close(); + }); + }); + } +} diff --git a/app/Server/Http/Controllers/Admin/StoreUsersController.php b/app/Server/Http/Controllers/Admin/StoreUsersController.php index ab2986e..e7445d9 100644 --- a/app/Server/Http/Controllers/Admin/StoreUsersController.php +++ b/app/Server/Http/Controllers/Admin/StoreUsersController.php @@ -39,6 +39,7 @@ class StoreUsersController extends AdminController $insertData = [ 'name' => $request->get('name'), 'auth_token' => (string) Str::uuid(), + 'can_specify_hostnames' => (int) $request->get('can_specify_hostnames'), 'can_specify_subdomains' => (int) $request->get('can_specify_subdomains'), 'can_share_tcp_ports' => (int) $request->get('can_share_tcp_ports'), ]; diff --git a/app/Server/Http/Controllers/ControlMessageController.php b/app/Server/Http/Controllers/ControlMessageController.php index dc8b34b..0493c99 100644 --- a/app/Server/Http/Controllers/ControlMessageController.php +++ b/app/Server/Http/Controllers/ControlMessageController.php @@ -4,14 +4,19 @@ namespace App\Server\Http\Controllers; use App\Contracts\ConnectionManager; use App\Contracts\SubdomainRepository; +use App\Contracts\HostnameRepository; use App\Contracts\UserRepository; use App\Http\QueryParameters; +use App\Server\Connections\ConnectionConfiguration; use App\Server\Exceptions\NoFreePortAvailable; +use Illuminate\Support\Str; use Ratchet\ConnectionInterface; use Ratchet\WebSocket\MessageComponentInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use stdClass; +use function React\Promise\reject; +use function React\Promise\resolve as resolvePromise; class ControlMessageController implements MessageComponentInterface { @@ -24,11 +29,15 @@ class ControlMessageController implements MessageComponentInterface /** @var SubdomainRepository */ protected $subdomainRepository; - public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $subdomainRepository) + /** @var HostnameRepository */ + protected $hostnameRepository; + + public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $subdomainRepository, HostnameRepository $hostnameRepository) { $this->connectionManager = $connectionManager; $this->userRepository = $userRepository; $this->subdomainRepository = $subdomainRepository; + $this->hostnameRepository = $hostnameRepository; } /** @@ -105,14 +114,12 @@ class ControlMessageController implements MessageComponentInterface protected function handleHttpConnection(ConnectionInterface $connection, $data, $user = null) { - $this->hasValidSubdomain($connection, $data->subdomain, $user)->then(function ($subdomain) use ($data, $connection) { - if ($subdomain === false) { - return; - } + $this->hasValidConfiguration($connection, $data, $user) + ->then(function (ConnectionConfiguration $configuration) use ($data, $connection) { + $data->subdomain = $configuration->getSubdomain(); + $data->hostname = $configuration->getHostname(); - $data->subdomain = $subdomain; - - $connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection); + $connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $data->hostname, $connection); $this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length')); @@ -121,6 +128,7 @@ class ControlMessageController implements MessageComponentInterface 'data' => [ 'message' => config('expose.admin.messages.message_of_the_day'), 'subdomain' => $connectionInfo->subdomain, + 'hostname' => $connectionInfo->hostname, 'client_id' => $connectionInfo->client_id, ], ])); @@ -192,7 +200,7 @@ class ControlMessageController implements MessageComponentInterface protected function verifyAuthToken(ConnectionInterface $connection): PromiseInterface { if (config('expose.admin.validate_auth_tokens') !== true) { - return \React\Promise\resolve(null); + return resolvePromise(null); } $deferred = new Deferred(); @@ -225,7 +233,7 @@ class ControlMessageController implements MessageComponentInterface ], ])); - return \React\Promise\resolve(null); + return resolvePromise(ConnectionConfiguration::withSubdomain(null)); } /** @@ -246,7 +254,7 @@ class ControlMessageController implements MessageComponentInterface ])); $connection->close(); - return \React\Promise\resolve(false); + return reject(false); } $controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain); @@ -263,14 +271,75 @@ class ControlMessageController implements MessageComponentInterface ])); $connection->close(); - return \React\Promise\resolve(false); + return reject(false); } - return \React\Promise\resolve($subdomain); + return resolvePromise(ConnectionConfiguration::withSubdomain($subdomain)); }); } - return \React\Promise\resolve($subdomain); + return resolvePromise(ConnectionConfiguration::withSubdomain($subdomain)); + } + + protected function hasValidHostname(ConnectionInterface $connection, string $hostname, ?array $user): PromiseInterface + { + /** + * Check if the user can specify a custom hostname in the first place. + */ + if (! is_null($user) && $user['can_specify_hostnames'] === 0) { + $connection->send(json_encode([ + 'event' => 'info', + 'data' => [ + 'message' => config('expose.admin.messages.custom_hostname_unauthorized').PHP_EOL, + ], + ])); + + return reject(); + } + + /** + * Check if the given hostname is reserved for a different user. + */ + return $this->hostnameRepository->getHostnamesByUserId($user['id']) + ->then(function ($foundHostnames) use ($connection, $hostname, $user) { + $foundHostname = collect($foundHostnames)->first(function ($foundHostname) use ($hostname) { + return Str::is($foundHostname['hostname'], $hostname); + }); + + if (is_null($foundHostname)) { + $message = config('expose.admin.messages.hostname_invalid'); + $message = str_replace(':hostname', $hostname, $message); + + $connection->send(json_encode([ + 'event' => 'hostnameTaken', + 'data' => [ + 'message' => $message, + ], + ])); + $connection->close(); + + return reject(false); + } + + $controlConnection = $this->connectionManager->findControlConnectionForHostname($hostname); + + if (! is_null($controlConnection)) { + $message = config('expose.admin.messages.hostname_taken'); + $message = str_replace(':hostname', $hostname, $message); + + $connection->send(json_encode([ + 'event' => 'hostnameTaken', + 'data' => [ + 'message' => $message, + ], + ])); + $connection->close(); + + return reject(false); + } + + return resolvePromise(ConnectionConfiguration::withHostname($hostname)); + }); } protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user) @@ -289,4 +358,13 @@ class ControlMessageController implements MessageComponentInterface return true; } + + protected function hasValidConfiguration(ConnectionInterface $connection, $data, $user) + { + if (isset($data->hostname) && ! is_null($data->hostname)) { + return $this->hasValidHostname($connection, $data->hostname, $user); + } + + return $this->hasValidSubdomain($connection, $data->subdomain, $user); + } } diff --git a/app/Server/Http/Controllers/TunnelMessageController.php b/app/Server/Http/Controllers/TunnelMessageController.php index a592868..c84d8b2 100644 --- a/app/Server/Http/Controllers/TunnelMessageController.php +++ b/app/Server/Http/Controllers/TunnelMessageController.php @@ -36,8 +36,9 @@ class TunnelMessageController extends Controller public function handle(Request $request, ConnectionInterface $httpConnection) { $subdomain = $this->detectSubdomain($request); + $hostname = $request->getHost(); - if (is_null($subdomain)) { + if (is_null($subdomain) && $hostname === $this->configuration->hostname()) { $httpConnection->send( respond_html($this->getView($httpConnection, 'server.homepage'), 200) ); @@ -46,7 +47,11 @@ class TunnelMessageController extends Controller return; } - $controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain); + if (! is_null($subdomain)) { + $controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain); + } else { + $controlConnection = $this->connectionManager->findControlConnectionForHostname($hostname); + } if (is_null($controlConnection)) { $httpConnection->send( @@ -113,13 +118,19 @@ class TunnelMessageController extends Controller $host .= ":{$this->configuration->port()}"; } + if (empty($controlConnection->subdomain)) { + $originalHost = $controlConnection->hostname; + } else { + $originalHost = "{$controlConnection->subdomain}.{$host}"; + } + $request->headers->set('Host', $controlConnection->host); $request->headers->set('X-Forwarded-Proto', $request->isSecure() ? 'https' : 'http'); $request->headers->set('X-Expose-Request-ID', uniqid()); $request->headers->set('Upgrade-Insecure-Requests', 1); $request->headers->set('X-Exposed-By', config('app.name').' '.config('app.version')); - $request->headers->set('X-Original-Host', "{$controlConnection->subdomain}.{$host}"); - $request->headers->set('X-Forwarded-Host', "{$controlConnection->subdomain}.{$host}"); + $request->headers->set('X-Original-Host', $originalHost); + $request->headers->set('X-Forwarded-Host', $originalHost); return $request; } diff --git a/app/Server/UserRepository/DatabaseUserRepository.php b/app/Server/UserRepository/DatabaseUserRepository.php index d572f17..bfb5e14 100644 --- a/app/Server/UserRepository/DatabaseUserRepository.php +++ b/app/Server/UserRepository/DatabaseUserRepository.php @@ -114,8 +114,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_specify_hostnames, can_share_tcp_ports, created_at) + VALUES (:name, :auth_token, :can_specify_subdomains, :can_specify_hostnames, :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/config/expose.php b/config/expose.php index f1a754f..d6063f4 100644 --- a/config/expose.php +++ b/config/expose.php @@ -234,6 +234,8 @@ return [ 'subdomain_repository' => \App\Server\SubdomainRepository\DatabaseSubdomainRepository::class, + 'hostname_repository' => \App\Server\HostnameRepository\DatabaseHostnameRepository::class, + /* |-------------------------------------------------------------------------- | Messages diff --git a/database/migrations/05_add_custom_hostnames_flag_to_users_table.sql b/database/migrations/05_add_custom_hostnames_flag_to_users_table.sql new file mode 100644 index 0000000..0d40865 --- /dev/null +++ b/database/migrations/05_add_custom_hostnames_flag_to_users_table.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD can_specify_hostnames BOOLEAN DEFAULT 1; diff --git a/database/migrations/06_create_hostnames_table.sql b/database/migrations/06_create_hostnames_table.sql new file mode 100644 index 0000000..65c3de0 --- /dev/null +++ b/database/migrations/06_create_hostnames_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS hostnames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + hostname STRING NOT NULL, + created_at DATETIME, + updated_at DATETIME +) diff --git a/resources/views/server/sites/index.twig b/resources/views/server/sites/index.twig index 82ec83f..d400952 100644 --- a/resources/views/server/sites/index.twig +++ b/resources/views/server/sites/index.twig @@ -9,10 +9,10 @@ - Host + Local Host - Subdomain + Expose Host Shared At @@ -26,13 +26,13 @@ @{ site.host } - @{ site.subdomain }.{{ configuration.hostname()}}:{{ configuration.port() }} + @{ getUrl(site) } @{ site.shared_at } - Visit +
+ +
+
+
+ + +
+
+
+