mirror of
https://github.com/bitinflow/expose.git
synced 2026-03-13 21:45:55 +00:00
Compare commits
96 Commits
analysis-l
...
tcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71fea398e | ||
|
|
ab3fc0f2ab | ||
|
|
4bfa384f1b | ||
|
|
c57758f08f | ||
|
|
ce932cf937 | ||
|
|
d68dcddf2b | ||
|
|
077be1cee3 | ||
|
|
1fc277fd5e | ||
|
|
790d33d548 | ||
|
|
54495fd4a8 | ||
|
|
9fde919bbe | ||
|
|
51c6749adf | ||
|
|
f9339c0049 | ||
|
|
e8842f33f0 | ||
|
|
256ba609f5 | ||
|
|
cfc0ad92a5 | ||
|
|
da8757744a | ||
|
|
fb45d40684 | ||
|
|
827ca9a13e | ||
|
|
b9ca7e9e48 | ||
|
|
1d7555f58c | ||
|
|
3a3dc85e72 | ||
|
|
c086d0a77e | ||
|
|
32fd4ba8ea | ||
|
|
e857de8498 | ||
|
|
c8cfe7b8b4 | ||
|
|
ab316f6bdc | ||
|
|
b071e81b1d | ||
|
|
d9ab55f308 | ||
|
|
0ebe6a4ce4 | ||
|
|
faa3309c70 | ||
|
|
a83349e6b9 | ||
|
|
9363e97d81 | ||
|
|
47b2350631 | ||
|
|
74236b6863 | ||
|
|
b1d23e1f75 | ||
|
|
12f08db391 | ||
|
|
e52659bf59 | ||
|
|
13f184a955 | ||
|
|
55a456d5e1 | ||
|
|
f9084c3c31 | ||
|
|
730b8457a6 | ||
|
|
188e1efe57 | ||
|
|
eaf04a8eae | ||
|
|
41e6e674e0 | ||
|
|
3d76b49fea | ||
|
|
1d5169af07 | ||
|
|
0945b1e66b | ||
|
|
a2bdf518ab | ||
|
|
0216948d18 | ||
|
|
9e31b020b6 | ||
|
|
bf0025979e | ||
|
|
dda3cbbae5 | ||
|
|
6a07859078 | ||
|
|
8db13e70af | ||
|
|
dfe889692b | ||
|
|
e5b2aada2f | ||
|
|
076da2c0de | ||
|
|
6410c7eb5e | ||
|
|
0d9413dfdf | ||
|
|
87a4115c14 | ||
|
|
096a2b2a70 | ||
|
|
6d6306b3b2 | ||
|
|
611a4c617c | ||
|
|
54bd95c66c | ||
|
|
3cb254e1f5 | ||
|
|
e960ffb825 | ||
|
|
459135f286 | ||
|
|
0efb42f989 | ||
|
|
6f04a0dfb6 | ||
|
|
91f169460e | ||
|
|
f8a6b45af7 | ||
|
|
b48dba1413 | ||
|
|
8bcc7613d9 | ||
|
|
9158887a60 | ||
|
|
b3f2edd18c | ||
|
|
b4379ddf6d | ||
|
|
70a9666f37 | ||
|
|
0b9f860138 | ||
|
|
dae1851e1d | ||
|
|
70d275bb1c | ||
|
|
8b8c6c8e2e | ||
|
|
c5b89e1179 | ||
|
|
18d67abc3f | ||
|
|
38efb0b879 | ||
|
|
04c881a875 | ||
|
|
d98eabe36e | ||
|
|
262a1eac4a | ||
|
|
979bacb928 | ||
|
|
732e0aeb3e | ||
|
|
2c0c544eeb | ||
|
|
528d5d74e0 | ||
|
|
68200aedc4 | ||
|
|
8628a7e1b6 | ||
|
|
5df98c4b91 | ||
|
|
78fbef90cd |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
tests
|
||||
.git
|
||||
4
.env-example
Normal file
4
.env-example
Normal file
@@ -0,0 +1,4 @@
|
||||
PORT=8080
|
||||
DOMAIN=example.com
|
||||
ADMIN_USERNAME=username
|
||||
ADMIN_PASSWORD=password
|
||||
17
.gitattributes
vendored
17
.gitattributes
vendored
@@ -1,7 +1,14 @@
|
||||
* text=auto
|
||||
/.github export-ignore
|
||||
.styleci.yml export-ignore
|
||||
|
||||
/.github export-ignore
|
||||
/tests export-ignore
|
||||
/docs export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.styleci.yml export-ignore
|
||||
.scrutinizer.yml export-ignore
|
||||
BACKERS.md export-ignore
|
||||
CONTRIBUTING.md export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
BACKERS.md export-ignore
|
||||
CONTRIBUTING.md export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
nodemod.json export-ignore
|
||||
phpunit.xml.dist export-ignore
|
||||
|
||||
19
.github/workflows/docker-publish.yml
vendored
Normal file
19
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Publish Docker image
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: beyondcodegmbh/expose-server
|
||||
tag_with_ref: true
|
||||
tags: latest
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@
|
||||
expose.php
|
||||
database/expose.db
|
||||
.expose.php
|
||||
.env
|
||||
|
||||
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0 (2020-07-01)
|
||||
* Feature: Add pagination to admin user interface
|
||||
* Feature: Add request time to CLI output
|
||||
* Feature: Add `X-Forwarded-Host` header
|
||||
* Fix: Fix remaining time calculation
|
||||
* Fix: Don't use underscores for automatic subdomain generation
|
||||
|
||||
## 1.1.0 (2020-06-18)
|
||||
* Feature: Allow overriding the subdomain when using `expose` without specifying `expose share` explicitly
|
||||
* Show badges in the local dashboard for 3xx response statuses
|
||||
* Fix: Updated minimum PHP dependency
|
||||
* Fix: Added support for detecting the Windows user home path
|
||||
* Fix: Use minified VueJS versions
|
||||
* Various spelling fixes
|
||||
|
||||
## 1.0.1 (2020-06-17)
|
||||
* Fixes an issue when setting the auth token
|
||||
|
||||
## 1.0.0 (2020-06-17)
|
||||
* Initial release
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM php:7.4-cli
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y git libzip-dev zip
|
||||
|
||||
RUN docker-php-ext-install zip
|
||||
|
||||
# Get latest Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
# install the dependencies
|
||||
RUN composer install -o --prefer-dist && chmod a+x expose
|
||||
|
||||
ENV port=8080
|
||||
ENV domain=localhost
|
||||
ENV username=username
|
||||
ENV password=password
|
||||
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
|
||||
@@ -49,6 +49,11 @@ class Client
|
||||
}
|
||||
}
|
||||
|
||||
public function sharePort(int $port)
|
||||
{
|
||||
$this->connectToServerAndShareTcp($port, config('expose.auth_token'));
|
||||
}
|
||||
|
||||
protected function prepareSharedUrl(string $sharedUrl): string
|
||||
{
|
||||
if (! $parsedUrl = parse_url($sharedUrl)) {
|
||||
@@ -84,17 +89,15 @@ class Client
|
||||
|
||||
$connection->authenticate($sharedUrl, $subdomain);
|
||||
|
||||
$clientConnection->on('close', function () use ($deferred, $sharedUrl, $subdomain, $authToken) {
|
||||
$clientConnection->on('close', function () use ($sharedUrl, $subdomain, $authToken) {
|
||||
$this->logger->error('Connection to server closed.');
|
||||
|
||||
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken);
|
||||
$this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) {
|
||||
$this->connectToServer($sharedUrl, $subdomain, $authToken);
|
||||
});
|
||||
});
|
||||
|
||||
$connection->on('authenticationFailed', function ($data) use ($deferred) {
|
||||
$this->logger->error($data->message);
|
||||
|
||||
$this->exit($deferred);
|
||||
});
|
||||
$this->attachCommonConnectionListeners($connection, $deferred);
|
||||
|
||||
$connection->on('subdomainTaken', function ($data) use ($deferred) {
|
||||
$this->logger->error($data->message);
|
||||
@@ -102,19 +105,6 @@ class Client
|
||||
$this->exit($deferred);
|
||||
});
|
||||
|
||||
$connection->on('setMaximumConnectionLength', function ($data) {
|
||||
$timeoutSection = $this->logger->getOutput()->section();
|
||||
|
||||
$this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) {
|
||||
$this->timeConnected++;
|
||||
|
||||
$carbon = Carbon::createFromFormat('s', str_pad($data->length * 60 - $this->timeConnected, 2, 0, STR_PAD_LEFT));
|
||||
|
||||
$timeoutSection->clear();
|
||||
$timeoutSection->writeln('Remaining time: '.$carbon->format('H:i:s'));
|
||||
});
|
||||
});
|
||||
|
||||
$connection->on('authenticated', function ($data) use ($deferred, $sharedUrl) {
|
||||
$httpProtocol = $this->configuration->port() === 443 ? 'https' : 'http';
|
||||
$host = $this->configuration->host();
|
||||
@@ -135,7 +125,9 @@ class Client
|
||||
});
|
||||
}, function (\Exception $e) use ($deferred, $sharedUrl, $subdomain, $authToken) {
|
||||
if ($this->connectionRetries > 0) {
|
||||
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken);
|
||||
$this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) {
|
||||
$this->connectToServer($sharedUrl, $subdomain, $authToken);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -148,6 +140,84 @@ class Client
|
||||
return $promise;
|
||||
}
|
||||
|
||||
public function connectToServerAndShareTcp(int $port, $authToken = ''): PromiseInterface
|
||||
{
|
||||
$deferred = new Deferred();
|
||||
$promise = $deferred->promise();
|
||||
|
||||
$wsProtocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
|
||||
|
||||
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [
|
||||
'X-Expose-Control' => 'enabled',
|
||||
], $this->loop)
|
||||
->then(function (WebSocket $clientConnection) use ($port, $deferred, $authToken) {
|
||||
$this->connectionRetries = 0;
|
||||
|
||||
$connection = ControlConnection::create($clientConnection);
|
||||
|
||||
$connection->authenticateTcp($port);
|
||||
|
||||
$this->attachCommonConnectionListeners($connection, $deferred);
|
||||
|
||||
$clientConnection->on('close', function () use ($port, $authToken) {
|
||||
$this->logger->error('Connection to server closed.');
|
||||
|
||||
$this->retryConnectionOrExit(function () use ($port, $authToken) {
|
||||
$this->connectToServerAndShareTcp($port, $authToken);
|
||||
});
|
||||
});
|
||||
|
||||
$connection->on('authenticated', function ($data) use ($deferred, $port) {
|
||||
$host = $this->configuration->host();
|
||||
|
||||
$this->logger->info($data->message);
|
||||
$this->logger->info("Local-Port:\t\t{$port}");
|
||||
$this->logger->info("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('authenticationFailed', function ($data) use ($deferred) {
|
||||
$this->logger->error($data->message);
|
||||
|
||||
$this->exit($deferred);
|
||||
});
|
||||
|
||||
$connection->on('setMaximumConnectionLength', function ($data) {
|
||||
$timeoutSection = $this->logger->getOutput()->section();
|
||||
|
||||
$this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) {
|
||||
$this->timeConnected++;
|
||||
|
||||
$secondsRemaining = $data->length * 60 - $this->timeConnected;
|
||||
$remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining));
|
||||
|
||||
$timeoutSection->clear();
|
||||
$timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected function exit(Deferred $deferred)
|
||||
{
|
||||
$deferred->reject();
|
||||
@@ -157,15 +227,15 @@ class Client
|
||||
});
|
||||
}
|
||||
|
||||
protected function retryConnectionOrExit(string $sharedUrl, $subdomain, $authToken = '')
|
||||
protected function retryConnectionOrExit(callable $retry)
|
||||
{
|
||||
$this->connectionRetries++;
|
||||
|
||||
if ($this->connectionRetries <= static::MAX_CONNECTION_RETRIES) {
|
||||
$this->loop->addTimer($this->connectionRetries, function () use ($sharedUrl, $subdomain, $authToken) {
|
||||
$this->loop->addTimer($this->connectionRetries, function () use ($retry) {
|
||||
$this->logger->info("Retrying connection ({$this->connectionRetries}/".static::MAX_CONNECTION_RETRIES.')');
|
||||
|
||||
$this->connectToServer($sharedUrl, $subdomain, $authToken);
|
||||
$retry();
|
||||
});
|
||||
} else {
|
||||
exit(1);
|
||||
|
||||
@@ -52,17 +52,34 @@ class ControlConnection
|
||||
$this->proxyManager->createProxy($this->clientId, $data);
|
||||
}
|
||||
|
||||
public function createTcpProxy($data)
|
||||
{
|
||||
$this->proxyManager->createTcpProxy($this->clientId, $data);
|
||||
}
|
||||
|
||||
public function authenticate(string $sharedHost, string $subdomain)
|
||||
{
|
||||
$this->socket->send(json_encode([
|
||||
'event' => 'authenticate',
|
||||
'data' => [
|
||||
'type' => 'http',
|
||||
'host' => $sharedHost,
|
||||
'subdomain' => empty($subdomain) ? null : $subdomain,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
public function authenticateTcp(int $port)
|
||||
{
|
||||
$this->socket->send(json_encode([
|
||||
'event' => 'authenticate',
|
||||
'data' => [
|
||||
'type' => 'tcp',
|
||||
'port' => $port,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
public function ping()
|
||||
{
|
||||
$this->socket->send(json_encode([
|
||||
|
||||
@@ -109,6 +109,13 @@ class Factory
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function sharePort(int $port)
|
||||
{
|
||||
app('expose.client')->sharePort($port);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function addRoutes()
|
||||
{
|
||||
$this->router->get('/', DashboardController::class);
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace App\Client;
|
||||
use App\Client\Http\HttpClient;
|
||||
use function Ratchet\Client\connect;
|
||||
use Ratchet\Client\WebSocket;
|
||||
use Ratchet\RFC6455\Messaging\Frame;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use React\Socket\Connector;
|
||||
|
||||
class ProxyManager
|
||||
{
|
||||
@@ -43,6 +45,37 @@ class ProxyManager
|
||||
});
|
||||
}
|
||||
|
||||
public function createTcpProxy(string $clientId, $connectionData)
|
||||
{
|
||||
$protocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
|
||||
|
||||
connect($protocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control", [], [
|
||||
'X-Expose-Control' => 'enabled',
|
||||
], $this->loop)
|
||||
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
|
||||
$connector = new Connector($this->loop);
|
||||
|
||||
$connector->connect('127.0.0.1:'.$connectionData->port)->then(function ($connection) use ($proxyConnection) {
|
||||
$connection->on('data', function ($data) use ($proxyConnection) {
|
||||
$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, $requestId, string $requestData)
|
||||
{
|
||||
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $requestId);
|
||||
|
||||
@@ -8,10 +8,12 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->input->setArgument('host', basename(getcwd()).'.'.$this->detectTld());
|
||||
$subdomain = $this->detectName();
|
||||
$host = $this->prepareSharedHost($subdomain.'.'.$this->detectTld());
|
||||
|
||||
if (! $this->hasOption('subdomain')) {
|
||||
$subdomain = str_replace('.', '_', basename(getcwd()));
|
||||
$this->input->setArgument('host', $host);
|
||||
|
||||
if (! $this->option('subdomain')) {
|
||||
$this->input->setOption('subdomain', $subdomain);
|
||||
}
|
||||
|
||||
@@ -20,7 +22,7 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand
|
||||
|
||||
protected function detectTld(): string
|
||||
{
|
||||
$valetConfigFile = $_SERVER['HOME'] ?? $_SERVER['USERPROFILE'].DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'config.json';
|
||||
$valetConfigFile = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'config.json';
|
||||
|
||||
if (file_exists($valetConfigFile)) {
|
||||
$valetConfig = json_decode(file_get_contents($valetConfigFile));
|
||||
@@ -30,4 +32,41 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand
|
||||
|
||||
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
|
||||
{
|
||||
$certificateFile = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'Certificates'.DIRECTORY_SEPARATOR.$host.'.crt';
|
||||
|
||||
if (file_exists($certificateFile)) {
|
||||
return 'https://'.$host;
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Commands/SharePortCommand.php
Normal file
40
app/Commands/SharePortCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ interface ConnectionManager
|
||||
{
|
||||
public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection;
|
||||
|
||||
public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection;
|
||||
|
||||
public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength);
|
||||
|
||||
public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection;
|
||||
@@ -23,4 +25,8 @@ interface ConnectionManager
|
||||
public function findControlConnectionForClientId(string $clientId): ?ControlConnection;
|
||||
|
||||
public function getConnections(): array;
|
||||
|
||||
public function getConnectionsForAuthToken(string $authToken): array;
|
||||
|
||||
public function getTcpConnectionsForAuthToken(string $authToken): array;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface UserRepository
|
||||
|
||||
public function getUserById($id): PromiseInterface;
|
||||
|
||||
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface;
|
||||
|
||||
public function getUserByToken(string $authToken): PromiseInterface;
|
||||
|
||||
public function storeUser(array $data): PromiseInterface;
|
||||
|
||||
@@ -24,7 +24,7 @@ class CliRequestLogger extends Logger
|
||||
$this->section = $this->output->section();
|
||||
|
||||
$this->table = new Table($this->section);
|
||||
$this->table->setHeaders(['Method', 'URI', 'Response', 'Duration']);
|
||||
$this->table->setHeaders(['Method', 'URI', 'Response', 'Time', 'Duration']);
|
||||
|
||||
$this->requests = new Collection();
|
||||
}
|
||||
@@ -53,6 +53,7 @@ class CliRequestLogger extends Logger
|
||||
$loggedRequest->getRequest()->getMethod(),
|
||||
$loggedRequest->getRequest()->getUri(),
|
||||
optional($loggedRequest->getResponse())->getStatusCode().' '.optional($loggedRequest->getResponse())->getReasonPhrase(),
|
||||
$loggedRequest->getStartTime()->toDateTimeString(),
|
||||
$loggedRequest->getDuration().'ms',
|
||||
];
|
||||
})->toArray());
|
||||
|
||||
@@ -296,6 +296,11 @@ class LoggedRequest implements \JsonSerializable
|
||||
})->get('x-expose-request-id', (string) Str::uuid());
|
||||
}
|
||||
|
||||
public function getStartTime()
|
||||
{
|
||||
return $this->startTime;
|
||||
}
|
||||
|
||||
public function getDuration()
|
||||
{
|
||||
return $this->startTime->diffInMilliseconds($this->stopTime, false);
|
||||
@@ -303,6 +308,12 @@ class LoggedRequest implements \JsonSerializable
|
||||
|
||||
protected function getRequestAsCurl(): string
|
||||
{
|
||||
$maxRequestLength = 256000;
|
||||
|
||||
if (strlen($this->rawRequest) > $maxRequestLength) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return (new CurlFormatter())->format(parse_request($this->rawRequest));
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Logger\CliRequestLogger;
|
||||
use App\Logger\RequestLogger;
|
||||
use Clue\React\Buzz\Browser;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laminas\Uri\Uri;
|
||||
use Laminas\Uri\UriFactory;
|
||||
use React\EventLoop\Factory as LoopFactory;
|
||||
use React\EventLoop\LoopInterface;
|
||||
|
||||
@@ -13,7 +15,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
//
|
||||
UriFactory::registerScheme('chrome-extension', Uri::class);
|
||||
}
|
||||
|
||||
public function register()
|
||||
|
||||
@@ -4,8 +4,11 @@ namespace App\Server\Connections;
|
||||
|
||||
use App\Contracts\ConnectionManager as ConnectionManagerContract;
|
||||
use App\Contracts\SubdomainGenerator;
|
||||
use App\Http\QueryParameters;
|
||||
use App\Server\Exceptions\NoFreePortAvailable;
|
||||
use Ratchet\ConnectionInterface;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use React\Socket\Server;
|
||||
|
||||
class ConnectionManager implements ConnectionManagerContract
|
||||
{
|
||||
@@ -46,13 +49,62 @@ class ConnectionManager implements ConnectionManagerContract
|
||||
|
||||
$connection->client_id = $clientId;
|
||||
|
||||
$storedConnection = new ControlConnection($connection, $host, $subdomain ?? $this->subdomainGenerator->generateSubdomain(), $clientId);
|
||||
$storedConnection = new ControlConnection(
|
||||
$connection,
|
||||
$host,
|
||||
$subdomain ?? $this->subdomainGenerator->generateSubdomain(),
|
||||
$clientId,
|
||||
$this->getAuthTokenFromConnection($connection)
|
||||
);
|
||||
|
||||
$this->connections[] = $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
|
||||
{
|
||||
$this->httpConnections[$requestId] = new HttpConnection($httpConnection);
|
||||
@@ -75,6 +127,16 @@ class ConnectionManager implements ConnectionManagerContract
|
||||
|
||||
if (isset($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) {
|
||||
return $connection->client_id == $clientId;
|
||||
})->toArray();
|
||||
@@ -99,4 +161,41 @@ class ConnectionManager implements ConnectionManagerContract
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,19 @@ class ControlConnection
|
||||
/** @var ConnectionInterface */
|
||||
public $socket;
|
||||
public $host;
|
||||
public $authToken;
|
||||
public $subdomain;
|
||||
public $client_id;
|
||||
public $proxies = [];
|
||||
protected $shared_at;
|
||||
|
||||
public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId)
|
||||
public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId, string $authToken = '')
|
||||
{
|
||||
$this->socket = $socket;
|
||||
$this->host = $host;
|
||||
$this->subdomain = $subdomain;
|
||||
$this->client_id = $clientId;
|
||||
$this->authToken = $authToken;
|
||||
$this->shared_at = now()->toDateTimeString();
|
||||
}
|
||||
|
||||
@@ -55,8 +57,10 @@ class ControlConnection
|
||||
public function toArray()
|
||||
{
|
||||
return [
|
||||
'type' => 'http',
|
||||
'host' => $this->host,
|
||||
'client_id' => $this->client_id,
|
||||
'auth_token' => $this->authToken,
|
||||
'subdomain' => $this->subdomain,
|
||||
'shared_at' => $this->shared_at,
|
||||
];
|
||||
|
||||
107
app/Server/Connections/TcpControlConnection.php
Normal file
107
app/Server/Connections/TcpControlConnection.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
7
app/Server/Exceptions/NoFreePortAvailable.php
Normal file
7
app/Server/Exceptions/NoFreePortAvailable.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Server\Exceptions;
|
||||
|
||||
class NoFreePortAvailable extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -10,10 +10,14 @@ use App\Http\Server as HttpServer;
|
||||
use App\Server\Connections\ConnectionManager;
|
||||
use App\Server\Http\Controllers\Admin\DeleteUsersController;
|
||||
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\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\ListSitesController;
|
||||
use App\Server\Http\Controllers\Admin\ListTcpConnectionsController;
|
||||
use App\Server\Http\Controllers\Admin\ListUsersController;
|
||||
use App\Server\Http\Controllers\Admin\RedirectToUsersController;
|
||||
use App\Server\Http\Controllers\Admin\ShowSettingsController;
|
||||
@@ -119,14 +123,18 @@ class Factory
|
||||
$this->router->get('/users', ListUsersController::class, $adminCondition);
|
||||
$this->router->get('/settings', ShowSettingsController::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->post('/api/settings', StoreSettingsController::class, $adminCondition);
|
||||
$this->router->get('/api/users', GetUsersController::class, $adminCondition);
|
||||
$this->router->post('/api/users', StoreUsersController::class, $adminCondition);
|
||||
$this->router->get('/api/users/{id}', GetUserDetailsController::class, $adminCondition);
|
||||
$this->router->delete('/api/users/{id}', DeleteUsersController::class, $adminCondition);
|
||||
$this->router->get('/api/sites', GetSitesController::class, $adminCondition);
|
||||
$this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition);
|
||||
$this->router->get('/api/tcp', GetTcpConnectionsController::class, $adminCondition);
|
||||
$this->router->delete('/api/tcp/{id}', DisconnectTcpConnectionController::class, $adminCondition);
|
||||
}
|
||||
|
||||
protected function bindConfiguration()
|
||||
|
||||
@@ -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(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Server\Http\Controllers\Admin;
|
||||
|
||||
use App\Contracts\ConnectionManager;
|
||||
use App\Server\Configuration;
|
||||
use App\Server\Connections\ControlConnection;
|
||||
use Illuminate\Http\Request;
|
||||
use Ratchet\ConnectionInterface;
|
||||
|
||||
@@ -23,12 +24,16 @@ class GetSitesController extends AdminController
|
||||
{
|
||||
$httpConnection->send(
|
||||
respond_json([
|
||||
'sites' => collect($this->connectionManager->getConnections())->map(function ($site, $siteId) {
|
||||
$site = $site->toArray();
|
||||
$site['id'] = $siteId;
|
||||
'sites' => collect($this->connectionManager->getConnections())
|
||||
->filter(function ($connection) {
|
||||
return get_class($connection) === ControlConnection::class;
|
||||
})
|
||||
->map(function ($site, $siteId) {
|
||||
$site = $site->toArray();
|
||||
$site['id'] = $siteId;
|
||||
|
||||
return $site;
|
||||
})->values(),
|
||||
return $site;
|
||||
})->values(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Server\Http\Controllers\Admin;
|
||||
|
||||
use App\Contracts\UserRepository;
|
||||
use Illuminate\Http\Request;
|
||||
use Ratchet\ConnectionInterface;
|
||||
|
||||
class GetUserDetailsController extends AdminController
|
||||
{
|
||||
protected $keepConnectionOpen = true;
|
||||
|
||||
/** @var UserRepository */
|
||||
protected $userRepository;
|
||||
|
||||
public function __construct(UserRepository $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
public function handle(Request $request, ConnectionInterface $httpConnection)
|
||||
{
|
||||
$this->userRepository
|
||||
->getUserById($request->get('id'))
|
||||
->then(function ($user) use ($httpConnection) {
|
||||
$httpConnection->send(
|
||||
respond_json(['user' => $user])
|
||||
);
|
||||
|
||||
$httpConnection->close();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class GetUsersController extends AdminController
|
||||
public function handle(Request $request, ConnectionInterface $httpConnection)
|
||||
{
|
||||
$this->userRepository
|
||||
->getUsers()
|
||||
->then(function ($users) use ($httpConnection) {
|
||||
->paginateUsers(20, (int) $request->get('page', 1))
|
||||
->then(function ($paginated) use ($httpConnection) {
|
||||
$httpConnection->send(
|
||||
respond_json(['users' => $users])
|
||||
respond_json(['paginated' => $paginated])
|
||||
);
|
||||
|
||||
$httpConnection->close();
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Server\Http\Controllers\Admin;
|
||||
|
||||
use App\Contracts\ConnectionManager;
|
||||
use App\Server\Configuration;
|
||||
use App\Server\Connections\ControlConnection;
|
||||
use Illuminate\Http\Request;
|
||||
use Ratchet\ConnectionInterface;
|
||||
|
||||
@@ -25,7 +26,17 @@ class ListSitesController extends AdminController
|
||||
$sites = $this->getView($httpConnection, 'server.sites.index', [
|
||||
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
|
||||
'configuration' => $this->configuration,
|
||||
'sites' => $this->connectionManager->getConnections(),
|
||||
'sites' => collect($this->connectionManager->getConnections())
|
||||
->filter(function ($connection) {
|
||||
return get_class($connection) === ControlConnection::class;
|
||||
})
|
||||
->map(function ($site, $siteId) {
|
||||
$site = $site->toArray();
|
||||
$site['id'] = $siteId;
|
||||
|
||||
return $site;
|
||||
})
|
||||
->values(),
|
||||
]);
|
||||
|
||||
$httpConnection->send(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class ListUsersController extends AdminController
|
||||
public function handle(Request $request, ConnectionInterface $httpConnection)
|
||||
{
|
||||
$this->userRepository
|
||||
->getUsers()
|
||||
->then(function ($users) use ($httpConnection) {
|
||||
->paginateUsers(20, (int) $request->get('page', 1))
|
||||
->then(function ($paginated) use ($httpConnection) {
|
||||
$httpConnection->send(
|
||||
respond_html($this->getView($httpConnection, 'server.users.index', ['users' => $users]))
|
||||
respond_html($this->getView($httpConnection, 'server.users.index', ['paginated' => $paginated]))
|
||||
);
|
||||
|
||||
$httpConnection->close();
|
||||
|
||||
@@ -39,6 +39,8 @@ class StoreUsersController extends AdminController
|
||||
$insertData = [
|
||||
'name' => $request->get('name'),
|
||||
'auth_token' => (string) Str::uuid(),
|
||||
'can_specify_subdomains' => (int) $request->get('can_specify_subdomains'),
|
||||
'can_share_tcp_ports' => (int) $request->get('can_share_tcp_ports'),
|
||||
];
|
||||
|
||||
$this->userRepository
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace App\Server\Http\Controllers;
|
||||
use App\Contracts\ConnectionManager;
|
||||
use App\Contracts\UserRepository;
|
||||
use App\Http\QueryParameters;
|
||||
use App\Server\Exceptions\NoFreePortAvailable;
|
||||
use Ratchet\ConnectionInterface;
|
||||
use Ratchet\WebSocket\MessageComponentInterface;
|
||||
use React\Promise\Deferred;
|
||||
use React\Promise\FulfilledPromise;
|
||||
use React\Promise\PromiseInterface;
|
||||
use stdClass;
|
||||
|
||||
@@ -54,6 +54,10 @@ class ControlMessageController implements MessageComponentInterface
|
||||
if (isset($connection->request_id)) {
|
||||
return $this->sendResponseToHttpConnection($connection->request_id, $msg);
|
||||
}
|
||||
if (isset($connection->tcp_request_id)) {
|
||||
$connectionInfo = $this->connectionManager->findControlConnectionForClientId($connection->tcp_client_id);
|
||||
$connectionInfo->proxyConnection->write($msg);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode($msg);
|
||||
@@ -77,23 +81,12 @@ class ControlMessageController implements MessageComponentInterface
|
||||
protected function authenticate(ConnectionInterface $connection, $data)
|
||||
{
|
||||
$this->verifyAuthToken($connection)
|
||||
->then(function () use ($connection, $data) {
|
||||
if (! $this->hasValidSubdomain($connection, $data->subdomain)) {
|
||||
return;
|
||||
->then(function ($user) use ($connection, $data) {
|
||||
if ($data->type === 'http') {
|
||||
$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) {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'authenticationFailed',
|
||||
@@ -105,6 +98,57 @@ class ControlMessageController implements MessageComponentInterface
|
||||
});
|
||||
}
|
||||
|
||||
protected function handleHttpConnection(ConnectionInterface $connection, $data, $user = null)
|
||||
{
|
||||
if (! $this->hasValidSubdomain($connection, $data->subdomain, $user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection);
|
||||
|
||||
$this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length'));
|
||||
|
||||
$connection->send(json_encode([
|
||||
'event' => 'authenticated',
|
||||
'data' => [
|
||||
'message' => config('expose.admin.messages.message_of_the_day'),
|
||||
'subdomain' => $connectionInfo->subdomain,
|
||||
'client_id' => $connectionInfo->client_id,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
protected function handleTcpConnection(ConnectionInterface $connection, $data, $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)
|
||||
{
|
||||
$connection->request_id = $data->request_id;
|
||||
@@ -116,6 +160,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}
|
||||
*/
|
||||
@@ -127,7 +183,7 @@ class ControlMessageController implements MessageComponentInterface
|
||||
protected function verifyAuthToken(ConnectionInterface $connection): PromiseInterface
|
||||
{
|
||||
if (config('expose.admin.validate_auth_tokens') !== true) {
|
||||
return new FulfilledPromise();
|
||||
return \React\Promise\resolve(null);
|
||||
}
|
||||
|
||||
$deferred = new Deferred();
|
||||
@@ -136,7 +192,7 @@ class ControlMessageController implements MessageComponentInterface
|
||||
|
||||
$this->userRepository
|
||||
->getUserByToken($authToken)
|
||||
->then(function ($user) use ($connection, $deferred) {
|
||||
->then(function ($user) use ($deferred) {
|
||||
if (is_null($user)) {
|
||||
$deferred->reject();
|
||||
} else {
|
||||
@@ -147,8 +203,20 @@ class ControlMessageController implements MessageComponentInterface
|
||||
return $deferred->promise();
|
||||
}
|
||||
|
||||
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain): bool
|
||||
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain, ?array $user): bool
|
||||
{
|
||||
if (! is_null($user) && $user['can_specify_subdomains'] === 0 && ! is_null($subdomain)) {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'subdomainTaken',
|
||||
'data' => [
|
||||
'message' => config('expose.admin.messages.custom_subdomain_unauthorized'),
|
||||
],
|
||||
]));
|
||||
$connection->close();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_null($subdomain)) {
|
||||
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
|
||||
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
|
||||
@@ -169,4 +237,21 @@ class ControlMessageController implements MessageComponentInterface
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user)
|
||||
{
|
||||
if (! is_null($user) && $user['can_share_tcp_ports'] === 0) {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'authenticationFailed',
|
||||
'data' => [
|
||||
'message' => config('expose.admin.messages.custom_subdomain_unauthorized'),
|
||||
],
|
||||
]));
|
||||
$connection->close();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class TunnelMessageController extends Controller
|
||||
|
||||
$httpConnection = $this->connectionManager->storeHttpConnection($httpConnection, $requestId);
|
||||
|
||||
transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection, $httpConnection, $requestId) {
|
||||
transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection , $requestId) {
|
||||
$controlConnection->once('proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($request) {
|
||||
// Convert the Laravel request into a PSR7 request
|
||||
$psr17Factory = new Psr17Factory();
|
||||
@@ -119,6 +119,7 @@ class TunnelMessageController extends Controller
|
||||
$request->headers->set('Upgrade-Insecure-Requests', 1);
|
||||
$request->headers->set('X-Exposed-By', config('app.name').' '.config('app.version'));
|
||||
$request->headers->set('X-Original-Host', "{$controlConnection->subdomain}.{$host}");
|
||||
$request->headers->set('X-Forwarded-Host', "{$controlConnection->subdomain}.{$host}");
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Server\UserRepository;
|
||||
|
||||
use App\Contracts\ConnectionManager;
|
||||
use App\Contracts\UserRepository;
|
||||
use Clue\React\SQLite\DatabaseInterface;
|
||||
use Clue\React\SQLite\Result;
|
||||
@@ -13,9 +14,13 @@ class DatabaseUserRepository implements UserRepository
|
||||
/** @var DatabaseInterface */
|
||||
protected $database;
|
||||
|
||||
public function __construct(DatabaseInterface $database)
|
||||
/** @var ConnectionManager */
|
||||
protected $connectionManager;
|
||||
|
||||
public function __construct(DatabaseInterface $database, ConnectionManager $connectionManager)
|
||||
{
|
||||
$this->database = $database;
|
||||
$this->connectionManager = $connectionManager;
|
||||
}
|
||||
|
||||
public function getUsers(): PromiseInterface
|
||||
@@ -31,6 +36,47 @@ class DatabaseUserRepository implements UserRepository
|
||||
return $deferred->promise();
|
||||
}
|
||||
|
||||
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface
|
||||
{
|
||||
$deferred = new Deferred();
|
||||
|
||||
$this->database
|
||||
->query('SELECT * FROM users ORDER by created_at DESC LIMIT :limit OFFSET :offset', [
|
||||
'limit' => $perPage + 1,
|
||||
'offset' => $currentPage < 2 ? 0 : ($currentPage - 1) * $perPage,
|
||||
])
|
||||
->then(function (Result $result) use ($deferred, $perPage, $currentPage) {
|
||||
if (count($result->rows) == $perPage + 1) {
|
||||
array_pop($result->rows);
|
||||
$nextPage = $currentPage + 1;
|
||||
}
|
||||
|
||||
$users = collect($result->rows)->map(function ($user) {
|
||||
return $this->getUserDetails($user);
|
||||
})->toArray();
|
||||
|
||||
$paginated = [
|
||||
'users' => $users,
|
||||
'current_page' => $currentPage,
|
||||
'per_page' => $perPage,
|
||||
'next_page' => $nextPage ?? null,
|
||||
'previous_page' => $currentPage > 1 ? $currentPage - 1 : null,
|
||||
];
|
||||
|
||||
$deferred->resolve($paginated);
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
$deferred = new Deferred();
|
||||
@@ -38,7 +84,13 @@ class DatabaseUserRepository implements UserRepository
|
||||
$this->database
|
||||
->query('SELECT * FROM users WHERE id = :id', ['id' => $id])
|
||||
->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();
|
||||
@@ -62,8 +114,8 @@ class DatabaseUserRepository implements UserRepository
|
||||
$deferred = new Deferred();
|
||||
|
||||
$this->database->query("
|
||||
INSERT INTO users (name, auth_token, created_at)
|
||||
VALUES (:name, :auth_token, DATETIME('now'))
|
||||
INSERT INTO users (name, auth_token, can_specify_subdomains, can_share_tcp_ports, created_at)
|
||||
VALUES (:name, :auth_token, :can_specify_subdomains, :can_share_tcp_ports, DATETIME('now'))
|
||||
", $data)
|
||||
->then(function (Result $result) use ($deferred) {
|
||||
$this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId])
|
||||
|
||||
BIN
builds/expose
BIN
builds/expose
Binary file not shown.
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"name": "beyondcode/expose",
|
||||
"description": "Expose",
|
||||
"keywords": ["expose", "tunnel", "ngrok"],
|
||||
"homepage": "https://sharedwithexpose.com",
|
||||
"type": "project",
|
||||
"description": "Expose",
|
||||
"keywords": [
|
||||
"expose",
|
||||
"tunnel",
|
||||
"ngrok"
|
||||
],
|
||||
"homepage": "https://sharedwithexpose.com",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
@@ -11,44 +15,42 @@
|
||||
"email": "marcel@beyondco.de"
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/seankndy/reactphp-sqlite"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.3.0",
|
||||
"ext-json": "*",
|
||||
"padraic/phar-updater": "^1.0.6"
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"nikic/php-parser": "^4.4",
|
||||
"cboden/ratchet": "^0.4.2",
|
||||
"clue/block-react": "^1.3",
|
||||
"clue/buzz-react": "^2.7",
|
||||
"clue/reactphp-sqlite": "dev-modular-worker-for-phar-support",
|
||||
"guzzlehttp/guzzle": "^6.5",
|
||||
"guzzlehttp/psr7": "dev-master as 1.6.1",
|
||||
"illuminate/http": "5.8.*|^6.0|^7.0",
|
||||
"illuminate/http": "5.8.* || ^6.0 || ^7.0",
|
||||
"illuminate/pipeline": "^7.6",
|
||||
"illuminate/validation": "^7.7",
|
||||
"laminas/laminas-http": "^2.11",
|
||||
"laravel-zero/framework": "^7.0",
|
||||
"mockery/mockery": "^1.3",
|
||||
"namshi/cuzzle": "^2.0",
|
||||
"nikic/php-parser": "^4.4",
|
||||
"nyholm/psr7": "^1.2",
|
||||
"phpunit/phpunit": "^8.5",
|
||||
"ratchet/pawl": "^0.3.4",
|
||||
"react/http": "^0.8.6",
|
||||
"react/socket": "^1.6",
|
||||
"react/stream": "^1.1.1",
|
||||
"react/socket": "^1.4",
|
||||
"riverline/multipart-parser": "^2.0",
|
||||
"symfony/expression-language": "^5.0",
|
||||
"symfony/http-kernel": "^4.0|^5.0",
|
||||
"symfony/http-kernel": "^4.0 || ^5.0",
|
||||
"symfony/psr-http-message-bridge": "^2.0",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
@@ -62,17 +64,20 @@
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"optimize-autoloader": true
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/seankndy/reactphp-sqlite"
|
||||
}
|
||||
],
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"bin": [
|
||||
"builds/expose"
|
||||
],
|
||||
"scripts": {
|
||||
"post-create-project-cmd": [
|
||||
"@php application app:rename"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"bin": ["builds/expose"]
|
||||
}
|
||||
}
|
||||
|
||||
2465
composer.lock
generated
2465
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => app('git.version'),
|
||||
'version' => '1.3.0',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The expose server to connect to. By default, expose is using the free
|
||||
| expose.dev server, offered by Beyond Code. You will need a free
|
||||
| sharedwithexpose.com server, offered by Beyond Code. You will need a free
|
||||
| Beyond Code account in order to authenticate with the server.
|
||||
| Feel free to host your own server and change this value.
|
||||
|
|
||||
@@ -78,7 +78,7 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maximum Allowed Memory
|
||||
| Skip Response Logging
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sometimes, some responses don't need to be logged. Some are too big,
|
||||
@@ -151,6 +151,24 @@ return [
|
||||
*/
|
||||
'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
|
||||
@@ -230,6 +248,10 @@ return [
|
||||
'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.',
|
||||
|
||||
'custom_subdomain_unauthorized' => 'You are not allowed to specify custom subdomains. Please upgrade to Expose Pro.',
|
||||
|
||||
'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD can_specify_subdomains BOOLEAN DEFAULT 1;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD can_share_tcp_ports BOOLEAN DEFAULT 1;
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: "3.7"
|
||||
services:
|
||||
expose:
|
||||
image: beyondcodegmbh/expose-server:latest
|
||||
ports:
|
||||
- 127.0.0.1:8080:${PORT}
|
||||
environment:
|
||||
port: ${PORT}
|
||||
domain: ${DOMAIN}
|
||||
username: ${ADMIN_USERNAME}
|
||||
password: ${ADMIN_PASSWORD}
|
||||
restart: always
|
||||
volumes:
|
||||
- ./database/expose.db:/root/.expose
|
||||
@@ -16,7 +16,7 @@ The result looks like this:
|
||||
```json
|
||||
{
|
||||
"configuration":{
|
||||
"hostname": "expose.dev",
|
||||
"hostname": "sharedwithexpose.com",
|
||||
"port": 8080,
|
||||
"database": "/home/forge/expose/database/expose.db",
|
||||
"validate_auth_tokens": false,
|
||||
|
||||
@@ -28,7 +28,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The expose server to connect to. By default, expose is using the free
|
||||
| expose.dev server, offered by Beyond Code. You will need a free
|
||||
| sharedwithexpose.com server, offered by Beyond Code. You will need a free
|
||||
| Beyond Code account in order to authenticate with the server.
|
||||
| Feel free to host your own server and change this value.
|
||||
|
|
||||
@@ -98,7 +98,7 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maximum Allowed Memory
|
||||
| Skip Response Logging
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sometimes, some responses don't need to be logged. Some are too big,
|
||||
|
||||
@@ -41,6 +41,9 @@ expose share http://192.168.2.100
|
||||
|
||||
# Will share access to http://my-local-site.dev using a randomly generated subdomain
|
||||
expose share my-local-site.dev
|
||||
|
||||
# Will share access to https://my-local-site.dev using a randomly generated subdomain (note the https)
|
||||
expose share https://my-local-site.dev
|
||||
```
|
||||
|
||||
## Share a local site with a given subdomain
|
||||
|
||||
@@ -15,11 +15,11 @@ cd ~/Sites/my-awesome-project/
|
||||
expose
|
||||
```
|
||||
|
||||
This will connect to the provided server at expose.dev and give you a tunnel that you can immediately start using.
|
||||
This will connect to the provided server at sharedwithexpose.com and give you a tunnel that you can immediately start using.
|
||||
|
||||
To learn more about how you can share your local sites, check out the [sharing local sites](/docs/expose/client/sharing) documentation.
|
||||
|
||||
## Using the provided server at expose.dev
|
||||
## Using the provided server at sharedwithexpose.com
|
||||
|
||||
A big advantage of Expose over other alternatives such as ngrok, is the ability to host your own server. To make sharing your sites as easy as possible, we provide and host a custom expose server on our own - so getting started with expose is a breeze.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -32,8 +34,55 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
@@ -98,7 +98,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The expose server to connect to. By default, expose is using the free
|
||||
| expose.dev server, offered by Beyond Code. You will need a free
|
||||
| sharedwithexpose.com server, offered by Beyond Code. You will need a free
|
||||
| Beyond Code account in order to authenticate with the server.
|
||||
| Feel free to host your own server and change this value.
|
||||
|
|
||||
@@ -122,4 +122,21 @@ return [
|
||||
// ...
|
||||
```
|
||||
|
||||
## Running With Docker
|
||||
|
||||
To run Expose with docker use the included `docker-compose.yaml`. Copy `.env-example` to `.env` and update the configuration.
|
||||
|
||||
```
|
||||
PORT=8080
|
||||
DOMAIN=example.com
|
||||
ADMIN_USERNAME=username
|
||||
ADMIN_PASSWORD=password
|
||||
```
|
||||
|
||||
After updating the environment variables you can start the server:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Now that your basic expose server is running, let's take a look at how you can add SSL support.
|
||||
|
||||
@@ -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">
|
||||
Shared sites
|
||||
</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"
|
||||
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 %}
|
||||
|
||||
73
resources/views/server/tcp/index.twig
Normal file
73
resources/views/server/tcp/index.twig
Normal 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 %}
|
||||
@@ -24,6 +24,44 @@
|
||||
</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_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 class="mt-8 border-t border-gray-200 pt-5">
|
||||
<div class="flex justify-end">
|
||||
@@ -51,6 +89,12 @@
|
||||
<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
|
||||
</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">
|
||||
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">
|
||||
Created At
|
||||
</th>
|
||||
@@ -65,6 +109,22 @@
|
||||
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
|
||||
@{ user.auth_token }
|
||||
</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_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">
|
||||
@{ user.created_at }
|
||||
</td>
|
||||
@@ -75,6 +135,27 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-end text-gray-900 p-4" v-if="paginated.current_page > 0">
|
||||
<button
|
||||
:disabled="paginated.previous_page == null"
|
||||
v-on:click="getUsers(paginated.previous_page)"
|
||||
type="button"
|
||||
:class="(paginated.previous_page == null ? 'opacity-50 cursor-not-allowed' : '') + ' mr-3 py-2 px-4 border border-gray-300 rounded-md text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition duration-150 ease-in-out'"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
:disabled="paginated.next_page == null"
|
||||
v-on:click="getUsers(paginated.next_page)"
|
||||
type="button"
|
||||
:class="(paginated.next_page == null ? 'opacity-50 cursor-not-allowed' : '') + ' py-2 px-4 border border-gray-300 rounded-md text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition duration-150 ease-in-out'"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center text-gray-900 p-4" v-else>
|
||||
<span class="text-xl">The expose server does not have any users yet.</span>
|
||||
</div>
|
||||
@@ -92,19 +173,36 @@
|
||||
data: {
|
||||
userForm: {
|
||||
name: '',
|
||||
can_specify_subdomains: true,
|
||||
can_share_tcp_ports: true,
|
||||
errors: {},
|
||||
},
|
||||
users: {{ users|json_encode|raw }}
|
||||
paginated: {{ paginated|json_encode|raw }}
|
||||
},
|
||||
|
||||
computed: {
|
||||
users : function() {
|
||||
return this.paginated.users;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getUsers(page) {
|
||||
fetch('/api/users?page=' + page)
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
}).then((data) => {
|
||||
this.paginated = data.paginated;
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser(user) {
|
||||
fetch('/api/users/' + user.id, {
|
||||
method: 'DELETE',
|
||||
}).then((response) => {
|
||||
return response.json();
|
||||
}).then((data) => {
|
||||
this.users = this.users.filter(u => u.id !== user.id);
|
||||
this.getUsers(1)
|
||||
});
|
||||
},
|
||||
saveUser() {
|
||||
@@ -119,6 +217,8 @@
|
||||
}).then((data) => {
|
||||
if (data.user) {
|
||||
this.userForm.name = '';
|
||||
this.userForm.can_specify_subdomains = true;
|
||||
this.userForm.can_share_tcp_ports = true;
|
||||
this.userForm.errors = {};
|
||||
this.users.unshift(data.user);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Clue\React\Buzz\Browser;
|
||||
use Clue\React\Buzz\Message\ResponseException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Nyholm\Psr7\Request;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Ratchet\Server\IoConnection;
|
||||
use Tests\Feature\TestCase;
|
||||
@@ -149,6 +150,8 @@ class AdminTest extends TestCase
|
||||
$connectionManager = app(ConnectionManager::class);
|
||||
|
||||
$connection = \Mockery::mock(IoConnection::class);
|
||||
$connection->httpRequest = new Request('GET', '/?authToken=some-token');
|
||||
|
||||
$connectionManager->storeConnection('some-host.text', 'fixed-subdomain', $connection);
|
||||
|
||||
/** @var Response $response */
|
||||
|
||||
207
tests/Feature/Server/ApiTest.php
Normal file
207
tests/Feature/Server/ApiTest.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Server;
|
||||
|
||||
use App\Contracts\ConnectionManager;
|
||||
use App\Server\Factory;
|
||||
use Clue\React\Buzz\Browser;
|
||||
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_can_get_user_details()
|
||||
{
|
||||
/** @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/1', [
|
||||
'Host' => 'expose.localhost',
|
||||
'Authorization' => base64_encode('username:secret'),
|
||||
'Content-Type' => 'application/json',
|
||||
]));
|
||||
|
||||
$body = json_decode($response->getBody()->getContents());
|
||||
$user = $body->user;
|
||||
|
||||
$this->assertSame('Marcel', $user->name);
|
||||
$this->assertSame([], $user->sites);
|
||||
$this->assertSame([], $user->tcp_connections);
|
||||
}
|
||||
|
||||
/** @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();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Clue\React\Buzz\Message\ResponseException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use React\Http\Server;
|
||||
use React\Socket\Connection;
|
||||
use Tests\Feature\TestCase;
|
||||
|
||||
class TunnelTest extends TestCase
|
||||
@@ -22,11 +23,17 @@ class TunnelTest extends TestCase
|
||||
/** @var \React\Socket\Server */
|
||||
protected $testHttpServer;
|
||||
|
||||
/** @var \React\Socket\Server */
|
||||
protected $testTcpServer;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->browser = new Browser($this->loop);
|
||||
$this->browser = $this->browser->withOptions([
|
||||
'followRedirects' => false,
|
||||
]);
|
||||
|
||||
$this->startServer();
|
||||
}
|
||||
@@ -39,6 +46,10 @@ class TunnelTest extends TestCase
|
||||
$this->testHttpServer->close();
|
||||
}
|
||||
|
||||
if (isset($this->testTcpServer)) {
|
||||
$this->testTcpServer->close();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
@@ -58,6 +69,8 @@ class TunnelTest extends TestCase
|
||||
{
|
||||
$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.
|
||||
@@ -76,6 +89,93 @@ class TunnelTest extends TestCase
|
||||
$this->assertSame('Hello World!', $response->getBody()->getContents());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_sends_incoming_requests_to_the_connected_client_via_tcp()
|
||||
{
|
||||
$this->createTestTcpServer();
|
||||
|
||||
$this->app['config']['expose.admin.validate_auth_tokens'] = false;
|
||||
|
||||
/**
|
||||
* We create an expose client that connects to our server and shares
|
||||
* the created test HTTP server.
|
||||
*/
|
||||
$client = $this->createClient();
|
||||
$response = $this->await($client->connectToServerAndShareTcp(8085));
|
||||
|
||||
/**
|
||||
* Once the client is connected, we connect to the
|
||||
* created tunnel.
|
||||
*/
|
||||
$connector = new \React\Socket\Connector($this->loop);
|
||||
$connection = $this->await($connector->connect('127.0.0.1:'.$response->shared_port));
|
||||
|
||||
$this->assertInstanceOf(Connection::class, $connection);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_tcp_sharing_if_forbidden()
|
||||
{
|
||||
$this->createTestTcpServer();
|
||||
|
||||
$this->app['config']['expose.admin.validate_auth_tokens'] = true;
|
||||
|
||||
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [
|
||||
'Host' => 'expose.localhost',
|
||||
'Authorization' => base64_encode('username:secret'),
|
||||
'Content-Type' => 'application/json',
|
||||
], json_encode([
|
||||
'name' => 'Marcel',
|
||||
'can_share_tcp_ports' => 0,
|
||||
])));
|
||||
|
||||
$user = json_decode($response->getBody()->getContents())->user;
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
|
||||
/**
|
||||
* We create an expose client that connects to our server and shares
|
||||
* the created test HTTP server.
|
||||
*/
|
||||
$client = $this->createClient();
|
||||
$this->await($client->connectToServerAndShareTcp(8085, $user->auth_token));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_allows_tcp_sharing_if_enabled_for_user()
|
||||
{
|
||||
$this->createTestTcpServer();
|
||||
|
||||
$this->app['config']['expose.admin.validate_auth_tokens'] = true;
|
||||
|
||||
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [
|
||||
'Host' => 'expose.localhost',
|
||||
'Authorization' => base64_encode('username:secret'),
|
||||
'Content-Type' => 'application/json',
|
||||
], json_encode([
|
||||
'name' => 'Marcel',
|
||||
'can_share_tcp_ports' => 1,
|
||||
])));
|
||||
|
||||
$user = json_decode($response->getBody()->getContents())->user;
|
||||
|
||||
/**
|
||||
* We create an expose client that connects to our server and shares
|
||||
* the created test HTTP server.
|
||||
*/
|
||||
$client = $this->createClient();
|
||||
$response = $this->await($client->connectToServerAndShareTcp(8085, $user->auth_token));
|
||||
|
||||
/**
|
||||
* Once the client is connected, we connect to the
|
||||
* created tunnel.
|
||||
*/
|
||||
$connector = new \React\Socket\Connector($this->loop);
|
||||
$connection = $this->await($connector->connect('127.0.0.1:'.$response->shared_port));
|
||||
|
||||
$this->assertInstanceOf(Connection::class, $connection);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_clients_with_invalid_auth_tokens()
|
||||
{
|
||||
@@ -98,22 +198,96 @@ class TunnelTest extends TestCase
|
||||
{
|
||||
$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
|
||||
* the created test HTTP server.
|
||||
*/
|
||||
$client = $this->createClient();
|
||||
$this->await($client->connectToServer('127.0.0.1:8085', 'tunnel'));
|
||||
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel', $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,
|
||||
])));
|
||||
|
||||
$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
|
||||
* the created test HTTP server.
|
||||
*/
|
||||
$client = $this->createClient();
|
||||
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'tunnel', $user->auth_token));
|
||||
|
||||
$this->assertSame('tunnel', $response->subdomain);
|
||||
}
|
||||
|
||||
/** @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', '', $user->auth_token));
|
||||
|
||||
$this->assertInstanceOf(\stdClass::class, $response);
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -142,4 +316,15 @@ class TunnelTest extends TestCase
|
||||
$this->testHttpServer = new \React\Socket\Server(8085, $this->loop);
|
||||
$server->listen($this->testHttpServer);
|
||||
}
|
||||
|
||||
protected function createTestTcpServer()
|
||||
{
|
||||
$this->testTcpServer = new \React\Socket\Server(8085, $this->loop);
|
||||
|
||||
$this->testTcpServer->on('connection', function (\React\Socket\ConnectionInterface $connection) {
|
||||
$connection->write('Hello '.$connection->getRemoteAddress()."!\n");
|
||||
|
||||
$connection->pipe($connection);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@ class LoggedRequestTest extends TestCase
|
||||
$this->assertSame('example-request', $loggedRequest->id());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_retrieves_the_request_for_chrome_extensions()
|
||||
{
|
||||
$rawRequest = str(new Request(200, '/expose', [
|
||||
'Origin' => 'chrome-extension://expose',
|
||||
'X-Expose-Request-ID' => 'example-request',
|
||||
]));
|
||||
$parsedRequest = LaminasRequest::fromString($rawRequest);
|
||||
|
||||
$loggedRequest = new LoggedRequest($rawRequest, $parsedRequest);
|
||||
$this->assertSame('example-request', $loggedRequest->id());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_post_data_for_json_payloads()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user