31 Commits

Author SHA1 Message Date
Marcel Pociot
6acf67ab39 Merge pull request #155 from beyondcode/analysis-5ZodB2
Apply fixes from StyleCI
2020-11-01 22:40:40 +01:00
Marcel Pociot
5595a9de3d Apply fixes from StyleCI 2020-11-01 21:40:32 +00:00
Marcel Pociot
cec52c4229 Allow users to specify custom hostnames 2020-11-01 22:40:17 +01:00
Marcel Pociot
5b7a80bb0c Merge branch 'master' of github.com:beyondcode/phunnel 2020-11-01 20:34:21 +01:00
Marcel Pociot
f5c009eadd Merge pull request #153 from beyondcode/analysis-7ao7E3
Apply fixes from StyleCI
2020-11-01 17:47:36 +01:00
Marcel Pociot
7459c0189b Apply fixes from StyleCI 2020-11-01 16:47:29 +00:00
Merkhad Luigton
8b8426cd3b make expose directly executable in the docker container (#149) 2020-11-01 17:47:21 +01:00
Marcel Pociot
880259657f Rewrite location header 2020-10-25 23:40:45 +01:00
Marcel Pociot
538c7da446 Better memory management for binary responses. Fixes #140 2020-10-17 21:07:29 +02:00
Marcel Pociot
26de32d375 Allow users to reserve subdomains (#131) 2020-09-09 21:57:42 +02:00
Marcel Pociot
2f57fa1952 Add ability to expose TCP connections (#123) 2020-09-08 16:27:39 +02:00
Marcel Pociot
c8cfe7b8b4 Merge pull request #121 from beyondcode/add_flag_to_users_to_allow_custom_subdomains
Add a new flag to users to allow the specification of custom subdomains
2020-09-07 20:27:03 +02:00
Marcel Pociot
ab316f6bdc Merge pull request #117 from beyondcode/dependabot/composer/symfony/http-kernel-5.1.5
Bump symfony/http-kernel from 5.1.2 to 5.1.5
2020-09-07 20:24:17 +02:00
Marcel Pociot
b071e81b1d Merge pull request #120 from beyondcode/analysis-NAg54v
Apply fixes from StyleCI
2020-09-07 20:20:20 +02:00
Marcel Pociot
d9ab55f308 Apply fixes from StyleCI 2020-09-07 18:20:13 +00:00
Marcel Pociot
0ebe6a4ce4 Add a new flag to users to allow the specification of custom subdomains 2020-09-07 20:19:56 +02:00
Marcel Pociot
faa3309c70 Merge pull request #119 from beyondcode/associate-sites-with-auth-tokens
Associate shared sites with auth tokens
2020-09-07 13:55:52 +02:00
Marcel Pociot
a83349e6b9 Merge pull request #118 from beyondcode/analysis-gOEJOG
Apply fixes from StyleCI
2020-09-07 13:34:13 +02:00
Marcel Pociot
9363e97d81 Apply fixes from StyleCI 2020-09-07 11:34:05 +00:00
Marcel Pociot
47b2350631 Associate shared sites with auth tokens 2020-09-07 13:33:40 +02:00
dependabot[bot]
74236b6863 Bump symfony/http-kernel from 5.1.2 to 5.1.5
Bumps [symfony/http-kernel](https://github.com/symfony/http-kernel) from 5.1.2 to 5.1.5.
- [Release notes](https://github.com/symfony/http-kernel/releases)
- [Changelog](https://github.com/symfony/http-kernel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-kernel/compare/v5.1.2...v5.1.5)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-04 14:30:06 +00:00
Marcel Pociot
b1d23e1f75 Merge pull request #112 from leemcd56/master
Removed padraic/phar-updater
2020-09-04 16:26:20 +02:00
Nathanael McDaniel
e52659bf59 Removed padraic/phar-updater 2020-08-26 14:47:01 -05:00
Marcel Pociot
13f184a955 Merge pull request #108 from beyondcode/analysis-J2x92V
Apply fixes from StyleCI
2020-08-13 00:20:14 +02:00
Marcel Pociot
55a456d5e1 Apply fixes from StyleCI 2020-08-12 22:20:06 +00:00
Marcel Pociot
f9084c3c31 Merge pull request #83 from ahmedash95/detect-valet-links
Auto detect valet links
2020-08-13 00:19:47 +02:00
Marcel Pociot
730b8457a6 Merge pull request #106 from CDRO/patch-1
[DOCS] Describe apache proxy configuration example
2020-08-07 14:34:10 +02:00
Tizian Schmidlin
188e1efe57 [DOCS] Describe apache proxy configuration exampl 2020-08-03 09:00:55 +02:00
Marcel Pociot
eaf04a8eae Don't try to log requests as curl when the requests are bigger than 256kb 2020-07-31 10:55:32 +02:00
Ahmed Ashraf
8db13e70af set path only if exists 2020-07-03 12:53:31 +02:00
Ahmed Ashraf
dfe889692b auto detect valet links 2020-07-03 12:50:08 +02:00
56 changed files with 3763 additions and 1450 deletions

View File

@@ -21,3 +21,4 @@ ENV password=password
ENV exposeConfigPath=/src/config/expose.php ENV exposeConfigPath=/src/config/expose.php
CMD sed -i "s|username|${username}|g" ${exposeConfigPath} && sed -i "s|password|${password}|g" ${exposeConfigPath} && php expose serve ${domain} --port ${port} --validateAuthTokens CMD sed -i "s|username|${username}|g" ${exposeConfigPath} && sed -i "s|password|${password}|g" ${exposeConfigPath} && php expose serve ${domain} --port ${port} --validateAuthTokens
ENTRYPOINT ["/src/expose"]

View File

@@ -40,15 +40,20 @@ class Client
$this->logger = $logger; $this->logger = $logger;
} }
public function share(string $sharedUrl, array $subdomains = []) public function share(string $sharedUrl, array $subdomains = [], string $hostname = '')
{ {
$sharedUrl = $this->prepareSharedUrl($sharedUrl); $sharedUrl = $this->prepareSharedUrl($sharedUrl);
foreach ($subdomains as $subdomain) { foreach ($subdomains as $subdomain) {
$this->connectToServer($sharedUrl, $subdomain, config('expose.auth_token')); $this->connectToServer($sharedUrl, $subdomain, $hostname, config('expose.auth_token'));
} }
} }
public function sharePort(int $port)
{
$this->connectToServerAndShareTcp($port, config('expose.auth_token'));
}
protected function prepareSharedUrl(string $sharedUrl): string protected function prepareSharedUrl(string $sharedUrl): string
{ {
if (! $parsedUrl = parse_url($sharedUrl)) { if (! $parsedUrl = parse_url($sharedUrl)) {
@@ -67,7 +72,7 @@ class Client
return $url; return $url;
} }
public function connectToServer(string $sharedUrl, $subdomain, $authToken = ''): PromiseInterface public function connectToServer(string $sharedUrl, $subdomain, $hostname = '', $authToken = ''): PromiseInterface
{ {
$deferred = new Deferred(); $deferred = new Deferred();
$promise = $deferred->promise(); $promise = $deferred->promise();
@@ -77,24 +82,22 @@ class Client
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [ connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [
'X-Expose-Control' => 'enabled', 'X-Expose-Control' => 'enabled',
], $this->loop) ], $this->loop)
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $deferred, $authToken) { ->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $hostname, $deferred, $authToken) {
$this->connectionRetries = 0; $this->connectionRetries = 0;
$connection = ControlConnection::create($clientConnection); $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->logger->error('Connection to server closed.');
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken); $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $hostname, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $hostname, $authToken);
});
}); });
$connection->on('authenticationFailed', function ($data) use ($deferred) { $this->attachCommonConnectionListeners($connection, $deferred);
$this->logger->error($data->message);
$this->exit($deferred);
});
$connection->on('subdomainTaken', function ($data) use ($deferred) { $connection->on('subdomainTaken', function ($data) use ($deferred) {
$this->logger->error($data->message); $this->logger->error($data->message);
@@ -102,20 +105,6 @@ class Client
$this->exit($deferred); $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) { $connection->on('authenticated', function ($data) use ($deferred, $sharedUrl) {
$httpProtocol = $this->configuration->port() === 443 ? 'https' : 'http'; $httpProtocol = $this->configuration->port() === 443 ? 'https' : 'http';
$host = $this->configuration->host(); $host = $this->configuration->host();
@@ -124,10 +113,16 @@ class Client
$host .= ":{$this->configuration->port()}"; $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($data->message);
$this->logger->info("Local-URL:\t\t{$sharedUrl}"); $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("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(''); $this->logger->line('');
static::$subdomains[] = "{$httpProtocol}://{$data->subdomain}.{$host}"; static::$subdomains[] = "{$httpProtocol}://{$data->subdomain}.{$host}";
@@ -136,7 +131,9 @@ class Client
}); });
}, function (\Exception $e) use ($deferred, $sharedUrl, $subdomain, $authToken) { }, function (\Exception $e) use ($deferred, $sharedUrl, $subdomain, $authToken) {
if ($this->connectionRetries > 0) { if ($this->connectionRetries > 0) {
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken); $this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $authToken);
});
return; return;
} }
@@ -149,6 +146,92 @@ class Client
return $promise; 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("Shared-Port:\t\t{$data->shared_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('info', function ($data) {
$this->logger->info($data->message);
});
$connection->on('error', function ($data) {
$this->logger->error($data->message);
});
$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) protected function exit(Deferred $deferred)
{ {
$deferred->reject(); $deferred->reject();
@@ -158,15 +241,15 @@ class Client
}); });
} }
protected function retryConnectionOrExit(string $sharedUrl, $subdomain, $authToken = '') protected function retryConnectionOrExit(callable $retry)
{ {
$this->connectionRetries++; $this->connectionRetries++;
if ($this->connectionRetries <= static::MAX_CONNECTION_RETRIES) { 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->logger->info("Retrying connection ({$this->connectionRetries}/".static::MAX_CONNECTION_RETRIES.')');
$this->connectToServer($sharedUrl, $subdomain, $authToken); $retry();
}); });
} else { } else {
exit(1); exit(1);

View File

@@ -36,4 +36,16 @@ class Configuration
{ {
return intval($this->port); return intval($this->port);
} }
public function getUrl(string $subdomain): string
{
$httpProtocol = $this->port() === 443 ? 'https' : 'http';
$host = $this->host();
if ($httpProtocol !== 'https') {
$host .= ":{$this->port()}";
}
return "{$subdomain}.{$host}";
}
} }

View File

@@ -52,13 +52,31 @@ class ControlConnection
$this->proxyManager->createProxy($this->clientId, $data); $this->proxyManager->createProxy($this->clientId, $data);
} }
public function authenticate(string $sharedHost, string $subdomain) public function createTcpProxy($data)
{
$this->proxyManager->createTcpProxy($this->clientId, $data);
}
public function authenticate(string $sharedHost, ?string $subdomain, ?string $hostname)
{ {
$this->socket->send(json_encode([ $this->socket->send(json_encode([
'event' => 'authenticate', 'event' => 'authenticate',
'data' => [ 'data' => [
'type' => 'http',
'host' => $sharedHost, 'host' => $sharedHost,
'subdomain' => empty($subdomain) ? null : $subdomain, 'subdomain' => empty($subdomain) ? null : $subdomain,
'hostname' => empty($hostname) ? null : $hostname,
],
]));
}
public function authenticateTcp(int $port)
{
$this->socket->send(json_encode([
'event' => 'authenticate',
'data' => [
'type' => 'tcp',
'port' => $port,
], ],
])); ]));
} }

View File

@@ -102,9 +102,16 @@ class Factory
return $this; 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;
}
public function sharePort(int $port)
{
app('expose.client')->sharePort($port);
return $this; return $this;
} }

View File

@@ -2,6 +2,7 @@
namespace App\Client\Http; namespace App\Client\Http;
use App\Client\Configuration;
use App\Client\Http\Modifiers\CheckBasicAuthentication; use App\Client\Http\Modifiers\CheckBasicAuthentication;
use App\Logger\RequestLogger; use App\Logger\RequestLogger;
use Clue\React\Buzz\Browser; use Clue\React\Buzz\Browser;
@@ -26,19 +27,26 @@ class HttpClient
/** @var Request */ /** @var Request */
protected $request; protected $request;
protected $connectionData;
/** @var array */ /** @var array */
protected $modifiers = [ protected $modifiers = [
CheckBasicAuthentication::class, CheckBasicAuthentication::class,
]; ];
/** @var Configuration */
protected $configuration;
public function __construct(LoopInterface $loop, RequestLogger $logger) public function __construct(LoopInterface $loop, RequestLogger $logger, Configuration $configuration)
{ {
$this->loop = $loop; $this->loop = $loop;
$this->logger = $logger; $this->logger = $logger;
$this->configuration = $configuration;
} }
public function performRequest(string $requestData, WebSocket $proxyConnection = null, string $requestId = null) public function performRequest(string $requestData, WebSocket $proxyConnection = null, $connectionData = null)
{ {
$this->connectionData = $connectionData;
$this->request = $this->parseRequest($requestData); $this->request = $this->parseRequest($requestData);
$this->logger->logRequest($requestData, $this->request); $this->logger->logRequest($requestData, $this->request);
@@ -85,6 +93,8 @@ class HttpClient
->send($request) ->send($request)
->then(function (ResponseInterface $response) use ($proxyConnection) { ->then(function (ResponseInterface $response) use ($proxyConnection) {
if (! isset($response->buffer)) { if (! isset($response->buffer)) {
$response = $this->rewriteResponseHeaders($response);
$response->buffer = str($response); $response->buffer = str($response);
} }
@@ -126,4 +136,25 @@ class HttpClient
{ {
return Request::fromString($data); return Request::fromString($data);
} }
protected function rewriteResponseHeaders(ResponseInterface $response)
{
if (! $response->hasHeader('Location')) {
return $response;
}
$location = $response->getHeaderLine('Location');
if (! strstr($location, $this->connectionData->host)) {
return $response;
}
$location = str_replace(
$this->connectionData->host,
$this->configuration->getUrl($this->connectionData->subdomain),
$location
);
return $response->withHeader('Location', $location);
}
} }

View File

@@ -5,7 +5,9 @@ namespace App\Client;
use App\Client\Http\HttpClient; use App\Client\Http\HttpClient;
use function Ratchet\Client\connect; use function Ratchet\Client\connect;
use Ratchet\Client\WebSocket; use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\Frame;
use React\EventLoop\LoopInterface; use React\EventLoop\LoopInterface;
use React\Socket\Connector;
class ProxyManager class ProxyManager
{ {
@@ -30,7 +32,7 @@ class ProxyManager
], $this->loop) ], $this->loop)
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) { ->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
$proxyConnection->on('message', function ($message) use ($proxyConnection, $connectionData) { $proxyConnection->on('message', function ($message) use ($proxyConnection, $connectionData) {
$this->performRequest($proxyConnection, $connectionData->request_id, (string) $message); $this->performRequest($proxyConnection, (string) $message, $connectionData);
}); });
$proxyConnection->send(json_encode([ $proxyConnection->send(json_encode([
@@ -43,8 +45,39 @@ class ProxyManager
}); });
} }
protected function performRequest(WebSocket $proxyConnection, $requestId, string $requestData) public function createTcpProxy(string $clientId, $connectionData)
{ {
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $requestId); $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) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxyConnection->send($binaryMsg);
});
$proxyConnection->on('message', function ($message) use ($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, string $requestData, $connectionData)
{
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $connectionData);
} }
} }

View File

@@ -10,7 +10,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
class ShareCommand extends Command 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'; protected $description = 'Share a local url with a remote expose server';
@@ -25,6 +25,12 @@ class ShareCommand extends Command
public function handle() 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(); $this->configureConnectionLogger();
(new Factory()) (new Factory())
@@ -33,7 +39,11 @@ class ShareCommand extends Command
->setPort(config('expose.port', 8080)) ->setPort(config('expose.port', 8080))
->setAuth($this->option('auth')) ->setAuth($this->option('auth'))
->createClient() ->createClient()
->share($this->argument('host'), explode(',', $this->option('subdomain'))) ->share(
$this->argument('host'),
explode(',', $this->option('subdomain')),
$this->option('hostname')
)
->createHttpServer() ->createHttpServer()
->run(); ->run();
} }

View File

@@ -4,16 +4,16 @@ namespace App\Commands;
class ShareCurrentWorkingDirectoryCommand extends ShareCommand class ShareCurrentWorkingDirectoryCommand extends ShareCommand
{ {
protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=}'; protected $signature = 'share-cwd {host?} {--hostname=} {--subdomain=} {--auth=}';
public function handle() public function handle()
{ {
$host = $this->prepareSharedHost(basename(getcwd()).'.'.$this->detectTld()); $subdomain = $this->detectName();
$host = $this->prepareSharedHost($subdomain.'.'.$this->detectTld());
$this->input->setArgument('host', $host); $this->input->setArgument('host', $host);
if (! $this->option('subdomain')) { if (! $this->option('subdomain') && ! $this->option('hostname')) {
$subdomain = str_replace('.', '-', basename(getcwd()));
$this->input->setOption('subdomain', $subdomain); $this->input->setOption('subdomain', $subdomain);
} }
@@ -33,6 +33,32 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand
return config('expose.default_tld', 'test'); return config('expose.default_tld', 'test');
} }
protected function detectName(): string
{
$projectPath = getcwd();
$valetSitesPath = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'Sites';
if (is_dir($valetSitesPath)) {
$site = collect(scandir($valetSitesPath))
->skip(2)
->map(function ($site) use ($valetSitesPath) {
return $valetSitesPath.DIRECTORY_SEPARATOR.$site;
})->mapWithKeys(function ($site) {
return [$site => readlink($site)];
})->filter(function ($sourcePath) use ($projectPath) {
return $sourcePath === $projectPath;
})
->keys()
->first();
if ($site) {
$projectPath = $site;
}
}
return str_replace('.', '-', basename($projectPath));
}
protected function prepareSharedHost($host): string protected function prepareSharedHost($host): string
{ {
$certificateFile = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'Certificates'.DIRECTORY_SEPARATOR.$host.'.crt'; $certificateFile = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'Certificates'.DIRECTORY_SEPARATOR.$host.'.crt';

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Commands;
use App\Client\Factory;
use App\Logger\CliRequestLogger;
use LaravelZero\Framework\Commands\Command;
use React\EventLoop\LoopInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
class SharePortCommand extends Command
{
protected $signature = 'share-port {port} {--auth=}';
protected $description = 'Share a local port with a remote expose server';
protected function configureConnectionLogger()
{
app()->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();
}
}

View File

@@ -8,7 +8,9 @@ use Ratchet\ConnectionInterface;
interface ConnectionManager 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;
public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength); public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength);
@@ -20,7 +22,13 @@ interface ConnectionManager
public function findControlConnectionForSubdomain($subdomain): ?ControlConnection; public function findControlConnectionForSubdomain($subdomain): ?ControlConnection;
public function findControlConnectionForHostname(string $hostname): ?ControlConnection;
public function findControlConnectionForClientId(string $clientId): ?ControlConnection; public function findControlConnectionForClientId(string $clientId): ?ControlConnection;
public function getConnections(): array; public function getConnections(): array;
public function getConnectionsForAuthToken(string $authToken): array;
public function getTcpConnectionsForAuthToken(string $authToken): array;
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface HostnameRepository
{
public function getHostnames(): PromiseInterface;
public function getHostnameById($id): PromiseInterface;
public function getHostnameByName(string $name): PromiseInterface;
public function getHostnamesByUserId($id): PromiseInterface;
public function getHostnamesByUserIdAndName($id, $name): PromiseInterface;
public function deleteHostnameForUserId($userId, $hostnameId): PromiseInterface;
public function storeHostname(array $data): PromiseInterface;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface SubdomainRepository
{
public function getSubdomains(): PromiseInterface;
public function getSubdomainById($id): PromiseInterface;
public function getSubdomainByName(string $name): PromiseInterface;
public function getSubdomainsByUserId($id): PromiseInterface;
public function getSubdomainsByUserIdAndName($id, $name): PromiseInterface;
public function deleteSubdomainForUserId($userId, $subdomainId): PromiseInterface;
public function storeSubdomain(array $data): PromiseInterface;
}

View File

@@ -19,11 +19,8 @@ class LoggedRequest implements \JsonSerializable
/** @var Request */ /** @var Request */
protected $parsedRequest; protected $parsedRequest;
/** @var string */ /** @var LoggedResponse */
protected $rawResponse; protected $response;
/** @var Response */
protected $parsedResponse;
/** @var string */ /** @var string */
protected $id; protected $id;
@@ -71,22 +68,8 @@ class LoggedRequest implements \JsonSerializable
], ],
]; ];
if ($this->parsedResponse) { if ($this->response) {
$logBody = $this->shouldReturnBody(); $data['response'] = $this->response->toArray();
try {
$body = $logBody ? $this->parsedResponse->getBody() : '';
} catch (\Exception $e) {
$body = '';
}
$data['response'] = [
'raw' => $logBody ? $this->rawResponse : 'SKIPPED BY CONFIG OR BINARY RESPONSE',
'status' => $this->parsedResponse->getStatusCode(),
'headers' => $this->parsedResponse->getHeaders()->toArray(),
'reason' => $this->parsedResponse->getReasonPhrase(),
'body' => $logBody ? $body : 'SKIPPED BY CONFIG OR BINARY RESPONSE',
];
} }
return $data; return $data;
@@ -107,96 +90,6 @@ class LoggedRequest implements \JsonSerializable
return preg_match('~[^\x20-\x7E\t\r\n]~', $string) > 0; return preg_match('~[^\x20-\x7E\t\r\n]~', $string) > 0;
} }
protected function shouldReturnBody(): bool
{
if ($this->skipByStatus()) {
return false;
}
if ($this->skipByContentType()) {
return false;
}
if ($this->skipByExtension()) {
return false;
}
if ($this->skipBySize()) {
return false;
}
$header = $this->parsedResponse->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
$patterns = [
'application/json',
'text/*',
'*javascript*',
];
return Str::is($patterns, $contentType);
}
protected function skipByStatus(): bool
{
if (empty(config()->get('expose.skip_body_log.status'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.status'), $this->parsedResponse->getStatusCode());
}
protected function skipByContentType(): bool
{
if (empty(config()->get('expose.skip_body_log.content_type'))) {
return false;
}
$header = $this->parsedResponse->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
return Str::is(config()->get('expose.skip_body_log.content_type'), $contentType);
}
protected function skipByExtension(): bool
{
if (empty(config()->get('expose.skip_body_log.extension'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.extension'), $this->parsedRequest->getUri()->getPath());
}
protected function skipBySize(): bool
{
$configSize = $this->getConfigSize(config()->get('expose.skip_body_log.size', '1MB'));
$contentLength = $this->parsedResponse->getHeaders()->get('Content-Length');
if (! $contentLength) {
return false;
}
$contentSize = $contentLength->getFieldValue() ?? 0;
return $contentSize > $configSize;
}
protected function getConfigSize(string $size): int
{
$units = ['B', 'KB', 'MB', 'GB'];
$number = substr($size, 0, -2);
$suffix = strtoupper(substr($size, -2));
// B or no suffix
if (is_numeric(substr($suffix, 0, 1))) {
return preg_replace('/[^\d]/', '', $size);
}
// if we have an error in the input, default to GB
$exponent = array_flip($units)[$suffix] ?? 5;
return $number * (1024 ** $exponent);
}
public function getRequest() public function getRequest()
{ {
return $this->parsedRequest; return $this->parsedRequest;
@@ -204,9 +97,7 @@ class LoggedRequest implements \JsonSerializable
public function setResponse(string $rawResponse, Response $response) public function setResponse(string $rawResponse, Response $response)
{ {
$this->parsedResponse = $response; $this->response = new LoggedResponse($rawResponse, $response, $this->getRequest());
$this->rawResponse = $rawResponse;
if (is_null($this->stopTime)) { if (is_null($this->stopTime)) {
$this->stopTime = now(); $this->stopTime = now();
@@ -223,9 +114,9 @@ class LoggedRequest implements \JsonSerializable
return $this->rawRequest; return $this->rawRequest;
} }
public function getResponse(): ?Response public function getResponse(): ?LoggedResponse
{ {
return $this->parsedResponse; return $this->response;
} }
public function getPostData() public function getPostData()
@@ -308,6 +199,12 @@ class LoggedRequest implements \JsonSerializable
protected function getRequestAsCurl(): string protected function getRequestAsCurl(): string
{ {
$maxRequestLength = 256000;
if (strlen($this->rawRequest) > $maxRequestLength) {
return '';
}
try { try {
return (new CurlFormatter())->format(parse_request($this->rawRequest)); return (new CurlFormatter())->format(parse_request($this->rawRequest));
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Logger;
use Illuminate\Support\Str;
use Laminas\Http\Request;
use Laminas\Http\Response;
class LoggedResponse
{
/** @var string */
protected $rawResponse;
/** @var Response */
protected $response;
/** @var Request */
protected $request;
protected $reasonPhrase;
protected $body;
protected $statusCode;
protected $headers;
public function __construct(string $rawResponse, Response $response, Request $request)
{
$this->rawResponse = $rawResponse;
$this->response = $response;
$this->request = $request;
if (! $this->shouldReturnBody()) {
$this->rawResponse = 'SKIPPED BY CONFIG OR BINARY RESPONSE';
$this->body = 'SKIPPED BY CONFIG OR BINARY RESPONSE';
} else {
try {
$this->body = $response->getBody();
} catch (\Exception $e) {
$this->body = '';
}
}
$this->statusCode = $response->getStatusCode();
$this->reasonPhrase = $response->getReasonPhrase();
$this->headers = $response->getHeaders()->toArray();
$this->response = null;
$this->request = null;
}
protected function shouldReturnBody(): bool
{
if ($this->skipByStatus()) {
return false;
}
if ($this->skipByContentType()) {
return false;
}
if ($this->skipByExtension()) {
return false;
}
if ($this->skipBySize()) {
return false;
}
$header = $this->response->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
$patterns = [
'application/json',
'text/*',
'*javascript*',
];
return Str::is($patterns, $contentType);
}
protected function skipByStatus(): bool
{
if (empty(config()->get('expose.skip_body_log.status'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.status'), $this->response->getStatusCode());
}
protected function skipByContentType(): bool
{
if (empty(config()->get('expose.skip_body_log.content_type'))) {
return false;
}
$header = $this->response->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
return Str::is(config()->get('expose.skip_body_log.content_type'), $contentType);
}
protected function skipByExtension(): bool
{
if (empty(config()->get('expose.skip_body_log.extension'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.extension'), $this->request->getUri()->getPath());
}
protected function skipBySize(): bool
{
$configSize = $this->getConfigSize(config()->get('expose.skip_body_log.size', '1MB'));
$contentLength = $this->response->getHeaders()->get('Content-Length');
if (! $contentLength) {
return false;
}
$contentSize = $contentLength->getFieldValue() ?? 0;
return $contentSize > $configSize;
}
protected function getConfigSize(string $size): int
{
$units = ['B', 'KB', 'MB', 'GB'];
$number = substr($size, 0, -2);
$suffix = strtoupper(substr($size, -2));
// B or no suffix
if (is_numeric(substr($suffix, 0, 1))) {
return preg_replace('/[^\d]/', '', $size);
}
// if we have an error in the input, default to GB
$exponent = array_flip($units)[$suffix] ?? 5;
return $number * (1024 ** $exponent);
}
public function getStatusCode()
{
return $this->statusCode;
}
public function getReasonPhrase()
{
return $this->reasonPhrase;
}
public function toArray()
{
return [
'raw' => $this->rawResponse,
'status' => $this->statusCode,
'headers' => $this->headers,
'reason' => $this->reasonPhrase,
'body' => $this->body,
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Server\Connections;
class ConnectionConfiguration
{
protected $hostname;
protected $subdomain;
private function __construct($subdomain, $hostname)
{
$this->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;
}
}

View File

@@ -4,8 +4,11 @@ namespace App\Server\Connections;
use App\Contracts\ConnectionManager as ConnectionManagerContract; use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\SubdomainGenerator; use App\Contracts\SubdomainGenerator;
use App\Http\QueryParameters;
use App\Server\Exceptions\NoFreePortAvailable;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface; use React\EventLoop\LoopInterface;
use React\Socket\Server;
class ConnectionManager implements ConnectionManagerContract class ConnectionManager implements ConnectionManagerContract
{ {
@@ -40,19 +43,75 @@ 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(); $clientId = (string) uniqid();
$connection->client_id = $clientId; $connection->client_id = $clientId;
$storedConnection = new ControlConnection($connection, $host, $subdomain ?? $this->subdomainGenerator->generateSubdomain(), $clientId); if (! is_null($hostname) && $hostname !== '') {
$subdomain = '';
} else {
$subdomain = $subdomain ?? $this->subdomainGenerator->generateSubdomain();
}
$storedConnection = new ControlConnection(
$connection,
$host,
$subdomain,
$hostname,
$clientId,
$this->getAuthTokenFromConnection($connection)
);
$this->connections[] = $storedConnection; $this->connections[] = $storedConnection;
return $storedConnection; 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->getAuthTokenFromConnection($connection)
);
$this->connections[] = $storedConnection;
return $storedConnection;
}
protected function getSharedTcpServer(): Server
{
$portRange = config('expose.admin.tcp_port_range');
$port = $portRange['from'] ?? 50000;
$maxPort = $portRange['to'] ?? 60000;
do {
try {
$portFound = true;
$server = new Server('0.0.0.0:'.$port, $this->loop);
} catch (\RuntimeException $exception) {
$portFound = false;
$port++;
if ($port > $maxPort) {
throw new NoFreePortAvailable();
}
}
} while (! $portFound);
return $server;
}
public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection
{ {
$this->httpConnections[$requestId] = new HttpConnection($httpConnection); $this->httpConnections[$requestId] = new HttpConnection($httpConnection);
@@ -75,6 +134,16 @@ class ConnectionManager implements ConnectionManagerContract
if (isset($connection->client_id)) { if (isset($connection->client_id)) {
$clientId = $connection->client_id; $clientId = $connection->client_id;
$controlConnection = collect($this->connections)->first(function ($connection) use ($clientId) {
return $connection->client_id == $clientId;
});
if ($controlConnection instanceof TcpControlConnection) {
$controlConnection->stop();
$controlConnection = null;
}
$this->connections = collect($this->connections)->reject(function ($connection) use ($clientId) { $this->connections = collect($this->connections)->reject(function ($connection) use ($clientId) {
return $connection->client_id == $clientId; return $connection->client_id == $clientId;
})->toArray(); })->toArray();
@@ -88,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 public function findControlConnectionForClientId(string $clientId): ?ControlConnection
{ {
return collect($this->connections)->last(function ($connection) use ($clientId) { return collect($this->connections)->last(function ($connection) use ($clientId) {
@@ -99,4 +175,41 @@ class ConnectionManager implements ConnectionManagerContract
{ {
return $this->connections; return $this->connections;
} }
protected function getAuthTokenFromConnection(ConnectionInterface $connection): string
{
return QueryParameters::create($connection->httpRequest)->get('authToken');
}
public function getConnectionsForAuthToken(string $authToken): array
{
return collect($this->connections)
->filter(function ($connection) use ($authToken) {
return $connection->authToken === $authToken;
})
->filter(function ($connection) {
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) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($connection) {
return $connection->toArray();
})
->values()
->toArray();
}
} }

View File

@@ -12,17 +12,21 @@ class ControlConnection
/** @var ConnectionInterface */ /** @var ConnectionInterface */
public $socket; public $socket;
public $host; public $host;
public $authToken;
public $subdomain; public $subdomain;
public $hostname;
public $client_id; public $client_id;
public $proxies = []; public $proxies = [];
protected $shared_at; protected $shared_at;
public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId) public function __construct(ConnectionInterface $socket, string $host, string $subdomain, ?string $hostname, string $clientId, string $authToken = '')
{ {
$this->socket = $socket; $this->socket = $socket;
$this->host = $host; $this->host = $host;
$this->subdomain = $subdomain; $this->subdomain = $subdomain;
$this->hostname = $hostname;
$this->client_id = $clientId; $this->client_id = $clientId;
$this->authToken = $authToken;
$this->shared_at = now()->toDateTimeString(); $this->shared_at = now()->toDateTimeString();
} }
@@ -41,6 +45,8 @@ class ControlConnection
$this->socket->send(json_encode([ $this->socket->send(json_encode([
'event' => 'createProxy', 'event' => 'createProxy',
'data' => [ 'data' => [
'host' => $this->host,
'subdomain' => $this->subdomain,
'request_id' => $requestId, 'request_id' => $requestId,
'client_id' => $this->client_id, 'client_id' => $this->client_id,
], ],
@@ -55,9 +61,12 @@ class ControlConnection
public function toArray() public function toArray()
{ {
return [ return [
'type' => 'http',
'host' => $this->host, 'host' => $this->host,
'client_id' => $this->client_id, 'client_id' => $this->client_id,
'auth_token' => $this->authToken,
'subdomain' => $this->subdomain, 'subdomain' => $this->subdomain,
'hostname' => $this->hostname,
'shared_at' => $this->shared_at, 'shared_at' => $this->shared_at,
]; ];
} }

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Server\Connections;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\Frame;
use React\Socket\Server;
class TcpControlConnection extends ControlConnection
{
public $proxy;
public $proxyConnection;
public $port;
public $shared_port;
public $shared_server;
public function __construct(ConnectionInterface $socket, int $port, Server $sharedServer, string $clientId, string $authToken = '')
{
$this->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->authToken = $authToken;
$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 stop()
{
$this->shared_server->close();
$this->shared_server = null;
}
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;
$connection->on('data', function ($data) use ($proxy) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxy->send($binaryMsg);
});
$connection->resume();
});
$connection->pause();
$this->registerTcpProxy($requestId);
});
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Server\Exceptions;
class NoFreePortAvailable extends \Exception
{
}

View File

@@ -3,21 +3,31 @@
namespace App\Server; namespace App\Server;
use App\Contracts\ConnectionManager as ConnectionManagerContract; use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\HostnameRepository;
use App\Contracts\SubdomainGenerator; use App\Contracts\SubdomainGenerator;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository; use App\Contracts\UserRepository;
use App\Http\RouteGenerator; use App\Http\RouteGenerator;
use App\Http\Server as HttpServer; use App\Http\Server as HttpServer;
use App\Server\Connections\ConnectionManager; 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\DeleteUsersController;
use App\Server\Http\Controllers\Admin\DisconnectSiteController; use App\Server\Http\Controllers\Admin\DisconnectSiteController;
use App\Server\Http\Controllers\Admin\DisconnectTcpConnectionController;
use App\Server\Http\Controllers\Admin\GetSettingsController; use App\Server\Http\Controllers\Admin\GetSettingsController;
use App\Server\Http\Controllers\Admin\GetSitesController; use App\Server\Http\Controllers\Admin\GetSitesController;
use App\Server\Http\Controllers\Admin\GetTcpConnectionsController;
use App\Server\Http\Controllers\Admin\GetUserDetailsController;
use App\Server\Http\Controllers\Admin\GetUsersController; use App\Server\Http\Controllers\Admin\GetUsersController;
use App\Server\Http\Controllers\Admin\ListSitesController; use App\Server\Http\Controllers\Admin\ListSitesController;
use App\Server\Http\Controllers\Admin\ListTcpConnectionsController;
use App\Server\Http\Controllers\Admin\ListUsersController; use App\Server\Http\Controllers\Admin\ListUsersController;
use App\Server\Http\Controllers\Admin\RedirectToUsersController; use App\Server\Http\Controllers\Admin\RedirectToUsersController;
use App\Server\Http\Controllers\Admin\ShowSettingsController; 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\StoreSettingsController;
use App\Server\Http\Controllers\Admin\StoreSubdomainController;
use App\Server\Http\Controllers\Admin\StoreUsersController; use App\Server\Http\Controllers\Admin\StoreUsersController;
use App\Server\Http\Controllers\ControlMessageController; use App\Server\Http\Controllers\ControlMessageController;
use App\Server\Http\Controllers\TunnelMessageController; use App\Server\Http\Controllers\TunnelMessageController;
@@ -119,14 +129,22 @@ class Factory
$this->router->get('/users', ListUsersController::class, $adminCondition); $this->router->get('/users', ListUsersController::class, $adminCondition);
$this->router->get('/settings', ShowSettingsController::class, $adminCondition); $this->router->get('/settings', ShowSettingsController::class, $adminCondition);
$this->router->get('/sites', ListSitesController::class, $adminCondition); $this->router->get('/sites', ListSitesController::class, $adminCondition);
$this->router->get('/tcp', ListTcpConnectionsController::class, $adminCondition);
$this->router->get('/api/settings', GetSettingsController::class, $adminCondition); $this->router->get('/api/settings', GetSettingsController::class, $adminCondition);
$this->router->post('/api/settings', StoreSettingsController::class, $adminCondition); $this->router->post('/api/settings', StoreSettingsController::class, $adminCondition);
$this->router->get('/api/users', GetUsersController::class, $adminCondition); $this->router->get('/api/users', GetUsersController::class, $adminCondition);
$this->router->post('/api/users', StoreUsersController::class, $adminCondition); $this->router->post('/api/users', StoreUsersController::class, $adminCondition);
$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->delete('/api/users/{id}', DeleteUsersController::class, $adminCondition);
$this->router->get('/api/sites', GetSitesController::class, $adminCondition); $this->router->get('/api/sites', GetSitesController::class, $adminCondition);
$this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition); $this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition);
$this->router->get('/api/tcp', GetTcpConnectionsController::class, $adminCondition);
$this->router->delete('/api/tcp/{id}', DisconnectTcpConnectionController::class, $adminCondition);
} }
protected function bindConfiguration() protected function bindConfiguration()
@@ -163,6 +181,8 @@ class Factory
$this->bindConfiguration() $this->bindConfiguration()
->bindSubdomainGenerator() ->bindSubdomainGenerator()
->bindUserRepository() ->bindUserRepository()
->bindSubdomainRepository()
->bindHostnameRepository()
->bindDatabase() ->bindDatabase()
->ensureDatabaseIsInitialized() ->ensureDatabaseIsInitialized()
->bindConnectionManager() ->bindConnectionManager()
@@ -199,6 +219,24 @@ class Factory
return $this; return $this;
} }
protected function bindSubdomainRepository()
{
app()->singleton(SubdomainRepository::class, function () {
return app(config('expose.admin.subdomain_repository'));
});
return $this;
}
protected function bindHostnameRepository()
{
app()->singleton(HostnameRepository::class, function () {
return app(config('expose.admin.hostname_repository'));
});
return $this;
}
protected function bindDatabase() protected function bindDatabase()
{ {
app()->singleton(DatabaseInterface::class, function () { app()->singleton(DatabaseInterface::class, function () {
@@ -225,6 +263,7 @@ class Factory
->files() ->files()
->ignoreDotFiles(true) ->ignoreDotFiles(true)
->in(database_path('migrations')) ->in(database_path('migrations'))
->sortByName()
->name('*.sql'); ->name('*.sql');
/** @var SplFileInfo $migration */ /** @var SplFileInfo $migration */

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Server\HostnameRepository;
use App\Contracts\HostnameRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseHostnameRepository implements HostnameRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->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();
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\HostnameRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DeleteHostnameController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var HostnameRepository */
protected $hostnameRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, HostnameRepository $hostnameRepository)
{
$this->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();
});
});
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DeleteSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
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->subdomainRepository->deleteSubdomainForUserId($user['id'], $request->get('subdomain'))
->then(function ($deleted) use ($httpConnection) {
$httpConnection->send(respond_json(['deleted' => $deleted], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DisconnectTcpConnectionController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager)
{
$this->connectionManager = $connectionManager;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$connection = $this->connectionManager->findControlConnectionForClientId($request->get('id'));
if (! is_null($connection)) {
$connection->close();
$this->connectionManager->removeControlConnection($connection);
}
$httpConnection->send(respond_json([
'tcp_connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->values(),
]));
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager; use App\Contracts\ConnectionManager;
use App\Server\Configuration; use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
@@ -23,12 +24,16 @@ class GetSitesController extends AdminController
{ {
$httpConnection->send( $httpConnection->send(
respond_json([ respond_json([
'sites' => collect($this->connectionManager->getConnections())->map(function ($site, $siteId) { 'sites' => collect($this->connectionManager->getConnections())
$site = $site->toArray(); ->filter(function ($connection) {
$site['id'] = $siteId; return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site; return $site;
})->values(), })->values(),
]) ])
); );
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetTcpConnectionsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(
respond_json([
'tcp_connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site;
})
->values(),
])
);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\HostnameRepository;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetUserDetailsController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var UserRepository */
protected $userRepository;
/** @var SubdomainRepository */
protected $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)
{
$this->userRepository
->getUserById($request->get('id'))
->then(function ($user) use ($httpConnection, $request) {
$this->subdomainRepository->getSubdomainsByUserId($request->get('id'))
->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();
});
});
});
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager; use App\Contracts\ConnectionManager;
use App\Server\Configuration; use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
@@ -25,12 +26,17 @@ class ListSitesController extends AdminController
$sites = $this->getView($httpConnection, 'server.sites.index', [ $sites = $this->getView($httpConnection, 'server.sites.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http', 'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration, 'configuration' => $this->configuration,
'sites' => collect($this->connectionManager->getConnections())->map(function ($site, $siteId) { 'sites' => collect($this->connectionManager->getConnections())
$site = $site->toArray(); ->filter(function ($connection) {
$site['id'] = $siteId; return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site; return $site;
})->values(), })
->values(),
]); ]);
$httpConnection->send( $httpConnection->send(

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class ListTcpConnectionsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
$this->configuration = $configuration;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$sites = $this->getView($httpConnection, 'server.tcp.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration,
'connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($connection, $connectionId) {
$connection = $connection->toArray();
$connection['id'] = $connectionId;
return $connection;
})
->values(),
]);
$httpConnection->send(
respond_html($sites)
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\HostnameRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Ratchet\ConnectionInterface;
class StoreHostnameController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var HostnameRepository */
protected $hostnameRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, HostnameRepository $hostnameRepository)
{
$this->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();
});
});
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Ratchet\ConnectionInterface;
class StoreSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$validator = Validator::make($request->all(), [
'subdomain' => '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_subdomains'] === 0) {
$httpConnection->send(respond_json(['error' => 'The user is not allowed to reserve subdomains.'], 401));
$httpConnection->close();
return;
}
$insertData = [
'user_id' => $user['id'],
'subdomain' => $request->get('subdomain'),
];
$this->subdomainRepository
->storeSubdomain($insertData)
->then(function ($subdomain) use ($httpConnection) {
if (is_null($subdomain)) {
$httpConnection->send(respond_json(['error' => 'The subdomain is already taken.'], 422));
$httpConnection->close();
return;
}
$httpConnection->send(respond_json(['subdomain' => $subdomain], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -39,6 +39,9 @@ class StoreUsersController extends AdminController
$insertData = [ $insertData = [
'name' => $request->get('name'), 'name' => $request->get('name'),
'auth_token' => (string) Str::uuid(), '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'),
]; ];
$this->userRepository $this->userRepository

View File

@@ -3,13 +3,19 @@
namespace App\Server\Http\Controllers; namespace App\Server\Http\Controllers;
use App\Contracts\ConnectionManager; use App\Contracts\ConnectionManager;
use App\Contracts\HostnameRepository;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository; use App\Contracts\UserRepository;
use App\Http\QueryParameters; use App\Http\QueryParameters;
use App\Server\Connections\ConnectionConfiguration;
use App\Server\Exceptions\NoFreePortAvailable;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use Ratchet\WebSocket\MessageComponentInterface; use Ratchet\WebSocket\MessageComponentInterface;
use React\Promise\Deferred; use React\Promise\Deferred;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface; use React\Promise\PromiseInterface;
use function React\Promise\reject;
use function React\Promise\resolve as resolvePromise;
use stdClass; use stdClass;
class ControlMessageController implements MessageComponentInterface class ControlMessageController implements MessageComponentInterface
@@ -20,10 +26,18 @@ class ControlMessageController implements MessageComponentInterface
/** @var UserRepository */ /** @var UserRepository */
protected $userRepository; protected $userRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository) /** @var SubdomainRepository */
protected $subdomainRepository;
/** @var HostnameRepository */
protected $hostnameRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $subdomainRepository, HostnameRepository $hostnameRepository)
{ {
$this->connectionManager = $connectionManager; $this->connectionManager = $connectionManager;
$this->userRepository = $userRepository; $this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
$this->hostnameRepository = $hostnameRepository;
} }
/** /**
@@ -54,6 +68,10 @@ class ControlMessageController implements MessageComponentInterface
if (isset($connection->request_id)) { if (isset($connection->request_id)) {
return $this->sendResponseToHttpConnection($connection->request_id, $msg); 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 { try {
$payload = json_decode($msg); $payload = json_decode($msg);
@@ -77,23 +95,12 @@ class ControlMessageController implements MessageComponentInterface
protected function authenticate(ConnectionInterface $connection, $data) protected function authenticate(ConnectionInterface $connection, $data)
{ {
$this->verifyAuthToken($connection) $this->verifyAuthToken($connection)
->then(function () use ($connection, $data) { ->then(function ($user) use ($connection, $data) {
if (! $this->hasValidSubdomain($connection, $data->subdomain)) { if ($data->type === 'http') {
return; $this->handleHttpConnection($connection, $data, $user);
} elseif ($data->type === 'tcp') {
$this->handleTcpConnection($connection, $data, $user);
} }
$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) { }, function () use ($connection) {
$connection->send(json_encode([ $connection->send(json_encode([
'event' => 'authenticationFailed', 'event' => 'authenticationFailed',
@@ -105,6 +112,60 @@ class ControlMessageController implements MessageComponentInterface
}); });
} }
protected function handleHttpConnection(ConnectionInterface $connection, $data, $user = null)
{
$this->hasValidConfiguration($connection, $data, $user)
->then(function (ConnectionConfiguration $configuration) use ($data, $connection) {
$data->subdomain = $configuration->getSubdomain();
$data->hostname = $configuration->getHostname();
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $data->hostname, $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,
'hostname' => $connectionInfo->hostname,
'client_id' => $connectionInfo->client_id,
],
]));
});
}
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) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.no_free_tcp_port_available'),
],
]));
$connection->close();
return;
}
$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) protected function registerProxy(ConnectionInterface $connection, $data)
{ {
$connection->request_id = $data->request_id; $connection->request_id = $data->request_id;
@@ -116,6 +177,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} * {@inheritdoc}
*/ */
@@ -127,7 +200,7 @@ class ControlMessageController implements MessageComponentInterface
protected function verifyAuthToken(ConnectionInterface $connection): PromiseInterface protected function verifyAuthToken(ConnectionInterface $connection): PromiseInterface
{ {
if (config('expose.admin.validate_auth_tokens') !== true) { if (config('expose.admin.validate_auth_tokens') !== true) {
return new FulfilledPromise(); return resolvePromise(null);
} }
$deferred = new Deferred(); $deferred = new Deferred();
@@ -147,26 +220,151 @@ class ControlMessageController implements MessageComponentInterface
return $deferred->promise(); return $deferred->promise();
} }
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain): bool protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain, ?array $user): PromiseInterface
{ {
/**
* Check if the user can specify a custom subdomain in the first place.
*/
if (! is_null($user) && $user['can_specify_subdomains'] === 0 && ! is_null($subdomain)) {
$connection->send(json_encode([
'event' => 'info',
'data' => [
'message' => config('expose.admin.messages.custom_subdomain_unauthorized').PHP_EOL,
],
]));
return resolvePromise(ConnectionConfiguration::withSubdomain(null));
}
/**
* Check if the given subdomain is reserved for a different user.
*/
if (! is_null($subdomain)) { if (! is_null($subdomain)) {
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain); return $this->subdomainRepository->getSubdomainByName($subdomain)
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) { ->then(function ($foundSubdomain) use ($connection, $subdomain, $user) {
$message = config('expose.admin.messages.subdomain_taken'); if (! is_null($foundSubdomain) && ! is_null($user) && $foundSubdomain['user_id'] !== $user['id']) {
$message = str_replace(':subdomain', $subdomain, $message); $message = config('expose.admin.messages.subdomain_reserved');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([ $connection->send(json_encode([
'event' => 'subdomainTaken', 'event' => 'subdomainTaken',
'data' => [ 'data' => [
'message' => $message, 'message' => $message,
], ],
])); ]));
$connection->close(); $connection->close();
return false; return reject(false);
} }
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
$message = config('expose.admin.messages.subdomain_taken');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
return reject(false);
}
return resolvePromise(ConnectionConfiguration::withSubdomain($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) {
$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)
{
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; 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);
}
} }

View File

@@ -36,8 +36,9 @@ class TunnelMessageController extends Controller
public function handle(Request $request, ConnectionInterface $httpConnection) public function handle(Request $request, ConnectionInterface $httpConnection)
{ {
$subdomain = $this->detectSubdomain($request); $subdomain = $this->detectSubdomain($request);
$hostname = $request->getHost();
if (is_null($subdomain)) { if (is_null($subdomain) && $hostname === $this->configuration->hostname()) {
$httpConnection->send( $httpConnection->send(
respond_html($this->getView($httpConnection, 'server.homepage'), 200) respond_html($this->getView($httpConnection, 'server.homepage'), 200)
); );
@@ -46,7 +47,11 @@ class TunnelMessageController extends Controller
return; 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)) { if (is_null($controlConnection)) {
$httpConnection->send( $httpConnection->send(
@@ -75,7 +80,7 @@ class TunnelMessageController extends Controller
$httpConnection = $this->connectionManager->storeHttpConnection($httpConnection, $requestId); $httpConnection = $this->connectionManager->storeHttpConnection($httpConnection, $requestId);
transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection , $requestId) { transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection, $requestId) {
$controlConnection->once('proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($request) { $controlConnection->once('proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($request) {
// Convert the Laravel request into a PSR7 request // Convert the Laravel request into a PSR7 request
$psr17Factory = new Psr17Factory(); $psr17Factory = new Psr17Factory();
@@ -113,13 +118,19 @@ class TunnelMessageController extends Controller
$host .= ":{$this->configuration->port()}"; $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('Host', $controlConnection->host);
$request->headers->set('X-Forwarded-Proto', $request->isSecure() ? 'https' : 'http'); $request->headers->set('X-Forwarded-Proto', $request->isSecure() ? 'https' : 'http');
$request->headers->set('X-Expose-Request-ID', uniqid()); $request->headers->set('X-Expose-Request-ID', uniqid());
$request->headers->set('Upgrade-Insecure-Requests', 1); $request->headers->set('Upgrade-Insecure-Requests', 1);
$request->headers->set('X-Exposed-By', config('app.name').' '.config('app.version')); $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-Original-Host', $originalHost);
$request->headers->set('X-Forwarded-Host', "{$controlConnection->subdomain}.{$host}"); $request->headers->set('X-Forwarded-Host', $originalHost);
return $request; return $request;
} }

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Server\SubdomainRepository;
use App\Contracts\SubdomainRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseSubdomainRepository implements SubdomainRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function getSubdomains(): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains ORDER by created_at DESC')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function getSubdomainById($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE id = :id', ['id' => $id])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getSubdomainByName(string $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE subdomain = :name', ['name' => $name])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getSubdomainsByUserId($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains 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 storeSubdomain(array $data): PromiseInterface
{
$deferred = new Deferred();
$this->getSubdomainByName($data['subdomain'])
->then(function ($registeredSubdomain) use ($data, $deferred) {
if (! is_null($registeredSubdomain)) {
$deferred->resolve(null);
return;
}
$this->database->query("
INSERT INTO subdomains (user_id, subdomain, created_at)
VALUES (:user_id, :subdomain, DATETIME('now'))
", $data)
->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM subdomains WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
});
});
return $deferred->promise();
}
public function getSubdomainsByUserIdAndName($id, $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE user_id = :user_id AND subdomain = :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 deleteSubdomainForUserId($userId, $subdomainId): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('DELETE FROM subdomains WHERE id = :id AND user_id = :user_id', [
'id' => $subdomainId,
'user_id' => $userId,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result);
});
return $deferred->promise();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Server\UserRepository; namespace App\Server\UserRepository;
use App\Contracts\ConnectionManager;
use App\Contracts\UserRepository; use App\Contracts\UserRepository;
use Clue\React\SQLite\DatabaseInterface; use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result; use Clue\React\SQLite\Result;
@@ -13,9 +14,13 @@ class DatabaseUserRepository implements UserRepository
/** @var DatabaseInterface */ /** @var DatabaseInterface */
protected $database; protected $database;
public function __construct(DatabaseInterface $database) /** @var ConnectionManager */
protected $connectionManager;
public function __construct(DatabaseInterface $database, ConnectionManager $connectionManager)
{ {
$this->database = $database; $this->database = $database;
$this->connectionManager = $connectionManager;
} }
public function getUsers(): PromiseInterface public function getUsers(): PromiseInterface
@@ -46,8 +51,12 @@ class DatabaseUserRepository implements UserRepository
$nextPage = $currentPage + 1; $nextPage = $currentPage + 1;
} }
$users = collect($result->rows)->map(function ($user) {
return $this->getUserDetails($user);
})->toArray();
$paginated = [ $paginated = [
'users' => $result->rows, 'users' => $users,
'current_page' => $currentPage, 'current_page' => $currentPage,
'per_page' => $perPage, 'per_page' => $perPage,
'next_page' => $nextPage ?? null, 'next_page' => $nextPage ?? null,
@@ -60,6 +69,14 @@ class DatabaseUserRepository implements UserRepository
return $deferred->promise(); return $deferred->promise();
} }
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;
}
public function getUserById($id): PromiseInterface public function getUserById($id): PromiseInterface
{ {
$deferred = new Deferred(); $deferred = new Deferred();
@@ -67,7 +84,13 @@ class DatabaseUserRepository implements UserRepository
$this->database $this->database
->query('SELECT * FROM users WHERE id = :id', ['id' => $id]) ->query('SELECT * FROM users WHERE id = :id', ['id' => $id])
->then(function (Result $result) use ($deferred) { ->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null); $user = $result->rows[0] ?? null;
if (! is_null($user)) {
$user = $this->getUserDetails($user);
}
$deferred->resolve($user);
}); });
return $deferred->promise(); return $deferred->promise();
@@ -91,8 +114,8 @@ class DatabaseUserRepository implements UserRepository
$deferred = new Deferred(); $deferred = new Deferred();
$this->database->query(" $this->database->query("
INSERT INTO users (name, auth_token, created_at) INSERT INTO users (name, auth_token, can_specify_subdomains, can_specify_hostnames, can_share_tcp_ports, created_at)
VALUES (:name, :auth_token, DATETIME('now')) VALUES (:name, :auth_token, :can_specify_subdomains, :can_specify_hostnames, :can_share_tcp_ports, DATETIME('now'))
", $data) ", $data)
->then(function (Result $result) use ($deferred) { ->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId]) $this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId])

Binary file not shown.

View File

@@ -17,8 +17,7 @@
], ],
"require": { "require": {
"php": "^7.3.0", "php": "^7.3.0",
"ext-json": "*", "ext-json": "*"
"padraic/phar-updater": "^1.0.6"
}, },
"require-dev": { "require-dev": {
"cboden/ratchet": "^0.4.2", "cboden/ratchet": "^0.4.2",
@@ -39,7 +38,7 @@
"phpunit/phpunit": "^8.5", "phpunit/phpunit": "^8.5",
"ratchet/pawl": "^0.3.4", "ratchet/pawl": "^0.3.4",
"react/http": "^0.8.6", "react/http": "^0.8.6",
"react/socket": "dev-master as 1.1", "react/socket": "^1.6",
"react/stream": "^1.1.1", "react/stream": "^1.1.1",
"riverline/multipart-parser": "^2.0", "riverline/multipart-parser": "^2.0",
"symfony/expression-language": "^5.0", "symfony/expression-language": "^5.0",

1869
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -151,6 +151,24 @@ return [
*/ */
'validate_auth_tokens' => false, 'validate_auth_tokens' => false,
/*
|--------------------------------------------------------------------------
| TCP Port Range
|--------------------------------------------------------------------------
|
| Expose allows you to also share TCP ports, for example when sharing your
| local SSH server with the public. This setting allows you to define the
| port range that Expose will use to assign new ports to the users.
|
| Note: Do not use port ranges below 1024, as it might require root
| privileges to assign these ports.
|
*/
'tcp_port_range' => [
'from' => 50000,
'to' => 60000,
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Maximum connection length | Maximum connection length
@@ -214,6 +232,10 @@ return [
*/ */
'user_repository' => \App\Server\UserRepository\DatabaseUserRepository::class, 'user_repository' => \App\Server\UserRepository\DatabaseUserRepository::class,
'subdomain_repository' => \App\Server\SubdomainRepository\DatabaseSubdomainRepository::class,
'hostname_repository' => \App\Server\HostnameRepository\DatabaseHostnameRepository::class,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Messages | Messages
@@ -230,6 +252,10 @@ return [
'invalid_auth_token' => 'Authentication failed. Please check your authentication token and try again.', 'invalid_auth_token' => 'Authentication failed. Please check your authentication token and try again.',
'subdomain_taken' => 'The chosen subdomain :subdomain is already taken. Please choose a different subdomain.', 'subdomain_taken' => 'The chosen subdomain :subdomain is already taken. Please choose a different subdomain.',
'custom_subdomain_unauthorized' => 'You are not allowed to specify custom subdomains. Please upgrade to Expose Pro. Assigning a random subdomain instead.',
'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.',
], ],
], ],
]; ];

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD can_specify_subdomains BOOLEAN DEFAULT 1;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD can_share_tcp_ports BOOLEAN DEFAULT 1;

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS subdomains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
subdomain STRING NOT NULL,
created_at DATETIME,
updated_at DATETIME
)

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD can_specify_hostnames BOOLEAN DEFAULT 1;

View File

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

View File

@@ -7,7 +7,9 @@ order: 2
Once your Expose server is running, you can only access it over the port that you configure when the server gets started. Once your Expose server is running, you can only access it over the port that you configure when the server gets started.
If you want to enable SSL support, you will need to use a proxy service - like Nginx, HAProxy or Caddy - to handle the SSL configurations and proxy all non-SSL requests to your expose server. If you want to enable SSL support, you will need to use a proxy service - like Nginx, HAProxy, Apache2 or Caddy - to handle the SSL configurations and proxy all non-SSL requests to your expose server.
## Nginx configuration
A basic Nginx configuration would look like this, but you might want to tweak the SSL parameters to your liking. A basic Nginx configuration would look like this, but you might want to tweak the SSL parameters to your liking.
@@ -40,3 +42,47 @@ server {
} }
} }
``` ```
## Apache2 configuration
A basic Apache configuration would look like this, but you might want to tweak the SSL parameters to your liking.
```
Listen 80
Listen 443
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName expose.domain.tld
ServerAlias *.expose.domain.tld
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
ServerAdmin admin@domain.tld
ProxyPass "/" "http://localhost:8080/"
ProxyPassReverse "/" "http://localhost:8080/"
ProxyPreserveHost On
# Needed for websocket support
RewriteCond %{HTTP:UPGRADE} ^WebSocket$ [NC,OR]
RewriteCond %{HTTP:CONNECTION} ^Upgrade$ [NC]
RewriteRule .* ws://127.0.0.1:8080%{REQUEST_URI} [P,QSA,L]
<Proxy http://localhost:8080>
Require all granted
Options none
</Proxy>
ErrorLog ${APACHE_LOG_DIR}/expose.domain.tld-error.log
CustomLog ${APACHE_LOG_DIR}/expose.domain.tld-access.log combined
SSLCertificateFile /etc/letsencrypt/live/expose.domain.tld-0001/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/expose.domain.tld-0001/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>
```

View File

@@ -27,6 +27,12 @@
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out"> ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Shared sites Shared sites
</a> </a>
<a href="/tcp"
class="
{% if request.is('tcp') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %}
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
TCP connections
</a>
<a href="/settings" <a href="/settings"
class=" class="
{% if request.is('settings') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %} {% if request.is('settings') %} border-indigo-500 focus:border-indigo-700 text-gray-900 {% else %} border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300{% endif %}

View File

@@ -9,10 +9,10 @@
<thead> <thead>
<tr> <tr>
<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"> <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">
Host Local Host
</th> </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"> <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">
Subdomain Expose Host
</th> </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"> <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">
Shared At Shared At
@@ -26,13 +26,13 @@
@{ site.host } @{ site.host }
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ site.subdomain }.{{ configuration.hostname()}}:{{ configuration.port() }} @{ getUrl(site) }
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ site.shared_at } @{ site.shared_at }
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap text-right border-b border-gray-200 text-sm leading-5 font-medium"> <td class="px-6 py-4 whitespace-no-wrap text-right border-b border-gray-200 text-sm leading-5 font-medium">
<a :href="'{{ scheme|raw }}://'+site.subdomain+'.{{ configuration.hostname()}}:{{ configuration.port() }}'" target="_blank" <a :href="'{{ scheme|raw }}://'+getUrl(site)" target="_blank"
class="text-indigo-600 hover:text-indigo-900">Visit</a> class="text-indigo-600 hover:text-indigo-900">Visit</a>
<a href="#" <a href="#"
@click.prevent="disconnectSite(site.client_id)" @click.prevent="disconnectSite(site.client_id)"
@@ -60,6 +60,12 @@
}, },
methods: { methods: {
getUrl(site) {
if (site.hostname !== '' && site.hostname !== null) {
return site.hostname;
}
return `${site.subdomain}.{{ configuration.hostname()}}:{{ configuration.port() }}`;
},
disconnectSite(id) { disconnectSite(id) {
fetch('/api/sites/' + id, { fetch('/api/sites/' + id, {
method: 'DELETE', method: 'DELETE',

View File

@@ -0,0 +1,73 @@
{% extends "app" %}
{% block title %}TCP Connections{% endblock %}
{% block content %}
<div class="flex flex-col py-8">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div
class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
<table class="min-w-full" v-if="connections.length > 0">
<thead>
<tr>
<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">
Local Port
</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">
Expose Port
</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">
Shared At
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50"></th>
</tr>
</thead>
<tbody class="bg-white">
<tr v-for="connection in connections">
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 font-medium text-gray-900">
@{ connection.port }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ connection.shared_port }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ connection.shared_at }
</td>
<td class="px-6 py-4 whitespace-no-wrap text-right border-b border-gray-200 text-sm leading-5 font-medium">
<a href="#"
@click.prevent="disconnectConnection(connection.client_id)"
class="pl-2 text-red-600 hover:text-red-900">Disconnect</a>
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-center text-gray-900 p-4" v-else>
<span class="text-xl">There are no TCP connections shared with this server yet.</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
delimiters: ['@{', '}'],
data: {
connections: {{ connections|json_encode|raw }}
},
methods: {
disconnectConnection(id) {
fetch('/api/tcp/' + id, {
method: 'DELETE',
}).then((response) => {
return response.json();
}).then((data) => {
this.connections = data.connections;
});
}
}
})
</script>
{% endblock %}

View File

@@ -24,6 +24,63 @@
</div> </div>
</div> </div>
</div> </div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="can_specify_subdomains"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Can specify custom subdomains
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<input id="can_specify_subdomains"
v-model="userForm.can_specify_subdomains"
name="can_specify_subdomains"
value="1" type="checkbox" class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
<label for="can_specify_subdomains" class="ml-2 block text-sm leading-5 text-gray-900">
Yes
</label>
</div>
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="can_specify_hostnames"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Can specify custom hostnames
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<input id="can_specify_hostnames"
v-model="userForm.can_specify_hostnames"
name="can_specify_hostnames"
value="1" type="checkbox" class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
<label for="can_specify_hostnames" class="ml-2 block text-sm leading-5 text-gray-900">
Yes
</label>
</div>
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="can_share_tcp_ports"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Can share TCP ports
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<input id="can_share_tcp_ports"
v-model="userForm.can_share_tcp_ports"
name="can_share_tcp_ports"
value="1" type="checkbox" class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
<label for="can_share_tcp_ports" class="ml-2 block text-sm leading-5 text-gray-900">
Yes
</label>
</div>
</div>
</div>
</div>
</div> </div>
<div class="mt-8 border-t border-gray-200 pt-5"> <div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end"> <div class="flex justify-end">
@@ -51,6 +108,15 @@
<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"> <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 Auth-Token
</th> </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>
<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 Hostnames
</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">
TCP ports
</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"> <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">
Created At Created At
</th> </th>
@@ -65,6 +131,30 @@
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ user.auth_token } @{ user.auth_token }
</td> </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
</span>
<span v-else>
Yes
</span>
</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_hostnames === 0">
No
</span>
<span v-else>
Yes
</span>
</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_share_tcp_ports === 0">
No
</span>
<span v-else>
Yes
</span>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ user.created_at } @{ user.created_at }
</td> </td>
@@ -113,6 +203,9 @@
data: { data: {
userForm: { userForm: {
name: '', name: '',
can_specify_subdomains: true,
can_specify_hostnames: true,
can_share_tcp_ports: true,
errors: {}, errors: {},
}, },
paginated: {{ paginated|json_encode|raw }} paginated: {{ paginated|json_encode|raw }}
@@ -140,7 +233,7 @@
}).then((response) => { }).then((response) => {
return response.json(); return response.json();
}).then((data) => { }).then((data) => {
this.users = this.users.filter(u => u.id !== user.id); this.getUsers(1)
}); });
}, },
saveUser() { saveUser() {
@@ -155,6 +248,9 @@
}).then((data) => { }).then((data) => {
if (data.user) { if (data.user) {
this.userForm.name = ''; this.userForm.name = '';
this.userForm.can_specify_subdomains = true;
this.userForm.can_specify_hostnames = true;
this.userForm.can_share_tcp_ports = true;
this.userForm.errors = {}; this.userForm.errors = {};
this.users.unshift(data.user); this.users.unshift(data.user);
} }

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature\Client; namespace Tests\Feature\Client;
use App\Client\Configuration;
use App\Client\Factory; use App\Client\Factory;
use App\Client\Http\HttpClient; use App\Client\Http\HttpClient;
use App\Logger\LoggedRequest; use App\Logger\LoggedRequest;
@@ -129,6 +130,10 @@ class DashboardTest extends TestCase
protected function startDashboard() protected function startDashboard()
{ {
app()->singleton(Configuration::class, function ($app) {
return new Configuration('localhost', '8080', false);
});
$this->dashboardFactory = (new Factory()) $this->dashboardFactory = (new Factory())
->setLoop($this->loop) ->setLoop($this->loop)
->createHttpServer(); ->createHttpServer();

View File

@@ -8,6 +8,7 @@ use Clue\React\Buzz\Browser;
use Clue\React\Buzz\Message\ResponseException; use Clue\React\Buzz\Message\ResponseException;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Nyholm\Psr7\Request;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Ratchet\Server\IoConnection; use Ratchet\Server\IoConnection;
use Tests\Feature\TestCase; use Tests\Feature\TestCase;
@@ -149,7 +150,9 @@ class AdminTest extends TestCase
$connectionManager = app(ConnectionManager::class); $connectionManager = app(ConnectionManager::class);
$connection = \Mockery::mock(IoConnection::class); $connection = \Mockery::mock(IoConnection::class);
$connectionManager->storeConnection('some-host.text', 'fixed-subdomain', $connection); $connection->httpRequest = new Request('GET', '/?authToken=some-token');
$connectionManager->storeConnection('some-host.text', 'fixed-subdomain', '', $connection);
/** @var Response $response */ /** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/sites', [ $response = $this->await($this->browser->get('http://127.0.0.1:8080/sites', [

View File

@@ -0,0 +1,465 @@
<?php
namespace Tests\Feature\Server;
use App\Contracts\ConnectionManager;
use App\Server\Factory;
use Clue\React\Buzz\Browser;
use Clue\React\Buzz\Message\ResponseException;
use GuzzleHttp\Psr7\Response;
use Nyholm\Psr7\Request;
use Ratchet\Server\IoConnection;
use Tests\Feature\TestCase;
class ApiTest extends TestCase
{
/** @var Browser */
protected $browser;
/** @var Factory */
protected $serverFactory;
public function setUp(): void
{
parent::setUp();
$this->browser = new Browser($this->loop);
$this->browser = $this->browser->withOptions([
'followRedirects' => false,
]);
$this->startServer();
}
public function tearDown(): void
{
$this->serverFactory->getSocket()->close();
parent::tearDown();
}
/** @test */
public function it_can_list_all_registered_users()
{
/** @var Response $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',
])));
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$users = $body->paginated->users;
$this->assertCount(1, $users);
$this->assertSame('Marcel', $users[0]->name);
$this->assertSame([], $users[0]->sites);
}
/** @test */
public function it_does_not_allow_subdomain_reservation_for_users_without_the_right_flag()
{
/** @var Response $response */
$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',
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->expectException(ResponseException::class);
$this->expectExceptionMessage('HTTP status code 401');
$this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
'subdomain' => 'reserved',
])));
}
/** @test */
public function it_allows_subdomain_reservation_for_users_with_the_right_flag()
{
/** @var Response $response */
$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' => 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',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
'subdomain' => 'reserved',
])));
$this->assertSame(200, $response->getStatusCode());
}
/** @test */
public function it_allows__hostname_reservation_for_users_with_the_right_flag()
{
/** @var Response $response */
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
'hostname' => 'reserved.beyondco.de',
])));
$this->assertSame(200, $response->getStatusCode());
}
/** @test */
public function it_can_delete_hostnames()
{
/** @var Response $response */
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => 'reserved.beyondco.de',
'auth_token' => $user->auth_token,
])));
$this->await($this->browser->delete('http://127.0.0.1:8080/api/hostnames/1', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
])));
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users/1', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$hostnames = $body->hostnames;
$this->assertCount(0, $hostnames);
}
/** @test */
public function it_can_get_user_details()
{
/** @var Response $response */
$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_hostnames' => 1,
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
'subdomain' => 'reserved',
])));
$this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
'hostname' => 'reserved.beyondco.de',
])));
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users/1', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$user = $body->user;
$subdomains = $body->subdomains;
$hostnames = $body->hostnames;
$this->assertSame('Marcel', $user->name);
$this->assertSame([], $user->sites);
$this->assertSame([], $user->tcp_connections);
$this->assertCount(1, $subdomains);
$this->assertCount(1, $hostnames);
}
/** @test */
public function it_can_delete_subdomains()
{
/** @var Response $response */
$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' => 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',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'auth_token' => $user->auth_token,
])));
$this->await($this->browser->delete('http://127.0.0.1:8080/api/subdomains/1', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'auth_token' => $user->auth_token,
])));
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users/1', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$subdomains = $body->subdomains;
$this->assertCount(0, $subdomains);
}
/** @test */
public function it_can_not_reserve_an_already_reserved_subdomain()
{
/** @var Response $response */
$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' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'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([
'name' => 'Sebastian',
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->expectException(ResponseException::class);
$this->expectExceptionMessage('HTTP status code 422');
$this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'auth_token' => $user->auth_token,
])));
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/users/2', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$subdomains = $body->subdomains;
$this->assertCount(0, $subdomains);
}
/** @test */
public function it_can_list_all_currently_connected_sites_from_all_users()
{
/** @var Response $response */
$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',
])));
$createdUser = json_decode($response->getBody()->getContents())->user;
/** @var ConnectionManager $connectionManager */
$connectionManager = app(ConnectionManager::class);
$connection = \Mockery::mock(IoConnection::class);
$connection->httpRequest = new Request('GET', '/?authToken='.$createdUser->auth_token);
$connectionManager->storeConnection('some-host.test', 'fixed-subdomain', '', $connection);
$connection = \Mockery::mock(IoConnection::class);
$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',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$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);
}
/** @test */
public function it_can_list_all_currently_connected_sites()
{
/** @var ConnectionManager $connectionManager */
$connectionManager = app(ConnectionManager::class);
$connection = \Mockery::mock(IoConnection::class);
$connection->httpRequest = new Request('GET', '/?authToken=some-token');
$connectionManager->storeConnection('some-host.test', 'fixed-subdomain', '', $connection);
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/sites', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$sites = $body->sites;
$this->assertCount(1, $sites);
$this->assertSame('some-host.test', $sites[0]->host);
$this->assertSame('some-token', $sites[0]->auth_token);
$this->assertSame('fixed-subdomain', $sites[0]->subdomain);
}
/** @test */
public function it_can_list_all_currently_connected_sites_without_auth_tokens()
{
/** @var ConnectionManager $connectionManager */
$connectionManager = app(ConnectionManager::class);
$connection = \Mockery::mock(IoConnection::class);
$connection->httpRequest = new Request('GET', '/');
$connectionManager->storeConnection('some-host.test', 'fixed-subdomain', '', $connection);
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/api/sites', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = json_decode($response->getBody()->getContents());
$sites = $body->sites;
$this->assertCount(1, $sites);
$this->assertSame('some-host.test', $sites[0]->host);
$this->assertSame('', $sites[0]->auth_token);
$this->assertSame('fixed-subdomain', $sites[0]->subdomain);
}
protected function startServer()
{
$this->app['config']['expose.admin.subdomain'] = 'expose';
$this->app['config']['expose.admin.database'] = ':memory:';
$this->app['config']['expose.admin.users'] = [
'username' => 'secret',
];
$this->serverFactory = new Factory();
$this->serverFactory->setLoop($this->loop)
->createServer();
}
}

View File

@@ -9,6 +9,8 @@ use Clue\React\Buzz\Message\ResponseException;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use React\Http\Server; use React\Http\Server;
use React\Promise\Timer\TimeoutException;
use React\Socket\Connection;
use Tests\Feature\TestCase; use Tests\Feature\TestCase;
class TunnelTest extends TestCase class TunnelTest extends TestCase
@@ -22,11 +24,17 @@ class TunnelTest extends TestCase
/** @var \React\Socket\Server */ /** @var \React\Socket\Server */
protected $testHttpServer; protected $testHttpServer;
/** @var \React\Socket\Server */
protected $testTcpServer;
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->browser = new Browser($this->loop); $this->browser = new Browser($this->loop);
$this->browser = $this->browser->withOptions([
'followRedirects' => false,
]);
$this->startServer(); $this->startServer();
} }
@@ -39,6 +47,10 @@ class TunnelTest extends TestCase
$this->testHttpServer->close(); $this->testHttpServer->close();
} }
if (isset($this->testTcpServer)) {
$this->testTcpServer->close();
}
parent::tearDown(); parent::tearDown();
} }
@@ -58,6 +70,8 @@ class TunnelTest extends TestCase
{ {
$this->createTestHttpServer(); $this->createTestHttpServer();
$this->app['config']['expose.admin.validate_auth_tokens'] = false;
/** /**
* We create an expose client that connects to our server and shares * We create an expose client that connects to our server and shares
* the created test HTTP server. * the created test HTTP server.
@@ -76,6 +90,163 @@ class TunnelTest extends TestCase
$this->assertSame('Hello World!', $response->getBody()->getContents()); $this->assertSame('Hello World!', $response->getBody()->getContents());
} }
/** @test */
public function it_sends_incoming_requests_to_the_connected_client_with_random_subdomain()
{
$this->createTestHttpServer();
$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();
$data = $this->await($client->connectToServer('127.0.0.1:8085', null));
/**
* Once the client is connected, we perform a GET request on the
* created tunnel.
*/
$response = $this->await($this->browser->get('http://127.0.0.1:8080/', [
'Host' => $data->subdomain.'.localhost',
]));
$this->assertSame('Hello World!', $response->getBody()->getContents());
}
/** @test */
public function it_sends_incoming_requests_to_the_connected_client_with_specific_hostname()
{
$this->createTestHttpServer();
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => 'reserved.beyondco.de',
'auth_token' => $user->auth_token,
])));
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$data = $this->await($client->connectToServer('127.0.0.1:8085', null, 'reserved.beyondco.de', $user->auth_token));
/**
* Once the client is connected, we perform a GET request on the
* created tunnel.
*/
$response = $this->await($this->browser->get('http://127.0.0.1:8080/', [
'Host' => 'reserved.beyondco.de',
]));
$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 */ /** @test */
public function it_rejects_clients_with_invalid_auth_tokens() public function it_rejects_clients_with_invalid_auth_tokens()
{ {
@@ -98,22 +269,364 @@ class TunnelTest extends TestCase
{ {
$this->app['config']['expose.admin.validate_auth_tokens'] = true; $this->app['config']['expose.admin.validate_auth_tokens'] = true;
$this->createTestHttpServer(); $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' => 1,
])));
$this->expectException(\UnexpectedValueException::class); $user = json_decode($response->getBody()->getContents())->user;
$this->createTestHttpServer();
/** /**
* We create an expose client that connects to our server and shares * We create an expose client that connects to our server and shares
* the created test HTTP server. * the created test HTTP server.
*/ */
$client = $this->createClient(); $client = $this->createClient();
$this->await($client->connectToServer('127.0.0.1:8085', 'tunnel')); $response = $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel', null, $user->auth_token));
$this->assertSame('tunnel', $response->subdomain);
}
/** @test */
public function it_rejects_clients_to_specify_custom_subdomains()
{
$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->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel', null, $user->auth_token));
$this->assertNotSame('tunnel', $response->subdomain);
}
/** @test */
public function it_rejects_users_that_want_to_use_a_reserved_subdomain()
{
$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' => 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',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'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([
'name' => 'Test-User',
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->createTestHttpServer();
$this->expectException(\UnexpectedValueException::class);
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'reserved', $user->auth_token));
$this->assertSame('reserved', $response->subdomain);
}
/** @test */
public function it_allows_users_to_use_their_own_reserved_subdomains()
{
$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' => 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',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'auth_token' => $user->auth_token,
])));
$this->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'reserved', null, $user->auth_token));
$this->assertSame('reserved', $response->subdomain);
}
/** @test */
public function it_allows_users_to_use_their_own_reserved_hostnames()
{
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => 'reserved.beyondco.de',
'auth_token' => $user->auth_token,
])));
$this->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', null, 'reserved.beyondco.de', $user->auth_token));
$this->assertSame('reserved.beyondco.de', $response->hostname);
}
/** @test */
public function it_allows_users_to_use_their_own_reserved_hostnames_with_wildcards()
{
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => '*.share.beyondco.de',
'auth_token' => $user->auth_token,
])));
$this->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', null, 'foo.share.beyondco.de', $user->auth_token));
$this->assertSame('foo.share.beyondco.de', $response->hostname);
}
/** @test */
public function it_rejects_users_trying_to_use_non_registered_hostnames()
{
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => 'share.beyondco.de',
'auth_token' => $user->auth_token,
])));
$this->createTestHttpServer();
$this->expectException(TimeoutException::class);
/**
* 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', null, 'foo.beyondco.de', $user->auth_token));
}
/** @test */
public function it_rejects_users_trying_to_use_other_peoples_registered_hostnames()
{
$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_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/hostnames', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'hostname' => '*.share.beyondco.de',
'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([
'name' => 'Marcel',
'can_specify_hostnames' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->createTestHttpServer();
$this->expectException(TimeoutException::class);
/**
* 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', null, 'foo.share.beyondco.de', $user->auth_token));
}
/** @test */
public function it_rejects_clients_to_specify_custom_hostnames()
{
$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_hostnames' => 0,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->createTestHttpServer();
$this->expectException(TimeoutException::class);
/**
* 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', null, 'reserved.beyondco.de', $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;
$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->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', '', null, $user->auth_token));
$this->assertInstanceOf(\stdClass::class, $response);
} }
protected function startServer() protected function startServer()
{ {
$this->app['config']['expose.admin.subdomain'] = 'expose';
$this->app['config']['expose.admin.database'] = ':memory:'; $this->app['config']['expose.admin.database'] = ':memory:';
$this->app['config']['expose.admin.users'] = [
'username' => 'secret',
];
$this->serverFactory = new Factory(); $this->serverFactory = new Factory();
$this->serverFactory->setLoop($this->loop) $this->serverFactory->setLoop($this->loop)
@@ -142,4 +655,15 @@ class TunnelTest extends TestCase
$this->testHttpServer = new \React\Socket\Server(8085, $this->loop); $this->testHttpServer = new \React\Socket\Server(8085, $this->loop);
$server->listen($this->testHttpServer); $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);
});
}
} }

View File

@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\ConsoleOutputInterface;
abstract class TestCase extends \Tests\TestCase abstract class TestCase extends \Tests\TestCase
{ {
const AWAIT_TIMEOUT = 5.0; const AWAIT_TIMEOUT = 0.2;
/** @var LoopInterface */ /** @var LoopInterface */
protected $loop; protected $loop;