79 Commits

Author SHA1 Message Date
Marcel Pociot
548c29772a Merge branch 'share-files' of github.com:beyondcode/phunnel into share-files 2020-11-01 20:34:06 +01:00
Marcel Pociot
844a3cd15a Rename page title 2020-11-01 20:33:56 +01:00
Marcel Pociot
e773dfa689 Merge pull request #151 from beyondcode/analysis-64ov5W
Apply fixes from StyleCI
2020-11-01 17:44:14 +01:00
Marcel Pociot
c56f05c030 Apply fixes from StyleCI 2020-11-01 16:44:06 +00:00
Marcel Pociot
ce945e1326 Add fileserver support 2020-11-01 17:43:42 +01:00
Marcel Pociot
880259657f Rewrite location header 2020-10-25 23:40:45 +01:00
Marcel Pociot
538c7da446 Better memory management for binary responses. Fixes #140 2020-10-17 21:07:29 +02:00
Marcel Pociot
26de32d375 Allow users to reserve subdomains (#131) 2020-09-09 21:57:42 +02:00
Marcel Pociot
2f57fa1952 Add ability to expose TCP connections (#123) 2020-09-08 16:27:39 +02:00
Marcel Pociot
c8cfe7b8b4 Merge pull request #121 from beyondcode/add_flag_to_users_to_allow_custom_subdomains
Add a new flag to users to allow the specification of custom subdomains
2020-09-07 20:27:03 +02:00
Marcel Pociot
ab316f6bdc Merge pull request #117 from beyondcode/dependabot/composer/symfony/http-kernel-5.1.5
Bump symfony/http-kernel from 5.1.2 to 5.1.5
2020-09-07 20:24:17 +02:00
Marcel Pociot
b071e81b1d Merge pull request #120 from beyondcode/analysis-NAg54v
Apply fixes from StyleCI
2020-09-07 20:20:20 +02:00
Marcel Pociot
d9ab55f308 Apply fixes from StyleCI 2020-09-07 18:20:13 +00:00
Marcel Pociot
0ebe6a4ce4 Add a new flag to users to allow the specification of custom subdomains 2020-09-07 20:19:56 +02:00
Marcel Pociot
faa3309c70 Merge pull request #119 from beyondcode/associate-sites-with-auth-tokens
Associate shared sites with auth tokens
2020-09-07 13:55:52 +02:00
Marcel Pociot
a83349e6b9 Merge pull request #118 from beyondcode/analysis-gOEJOG
Apply fixes from StyleCI
2020-09-07 13:34:13 +02:00
Marcel Pociot
9363e97d81 Apply fixes from StyleCI 2020-09-07 11:34:05 +00:00
Marcel Pociot
47b2350631 Associate shared sites with auth tokens 2020-09-07 13:33:40 +02:00
dependabot[bot]
74236b6863 Bump symfony/http-kernel from 5.1.2 to 5.1.5
Bumps [symfony/http-kernel](https://github.com/symfony/http-kernel) from 5.1.2 to 5.1.5.
- [Release notes](https://github.com/symfony/http-kernel/releases)
- [Changelog](https://github.com/symfony/http-kernel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-kernel/compare/v5.1.2...v5.1.5)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-04 14:30:06 +00:00
Marcel Pociot
b1d23e1f75 Merge pull request #112 from leemcd56/master
Removed padraic/phar-updater
2020-09-04 16:26:20 +02:00
Nathanael McDaniel
e52659bf59 Removed padraic/phar-updater 2020-08-26 14:47:01 -05:00
Marcel Pociot
13f184a955 Merge pull request #108 from beyondcode/analysis-J2x92V
Apply fixes from StyleCI
2020-08-13 00:20:14 +02:00
Marcel Pociot
55a456d5e1 Apply fixes from StyleCI 2020-08-12 22:20:06 +00:00
Marcel Pociot
f9084c3c31 Merge pull request #83 from ahmedash95/detect-valet-links
Auto detect valet links
2020-08-13 00:19:47 +02:00
Marcel Pociot
730b8457a6 Merge pull request #106 from CDRO/patch-1
[DOCS] Describe apache proxy configuration example
2020-08-07 14:34:10 +02:00
Tizian Schmidlin
188e1efe57 [DOCS] Describe apache proxy configuration exampl 2020-08-03 09:00:55 +02:00
Marcel Pociot
eaf04a8eae Don't try to log requests as curl when the requests are bigger than 256kb 2020-07-31 10:55:32 +02:00
Marcel Pociot
41e6e674e0 wip 2020-07-28 22:34:30 +02:00
Marcel Pociot
3d76b49fea wip 2020-07-28 22:23:31 +02:00
Marcel Pociot
1d5169af07 fix github packages config 2020-07-28 22:08:48 +02:00
Marcel Pociot
0945b1e66b Merge branch 'lukepolo-master' 2020-07-28 21:39:10 +02:00
Marcel Pociot
a2bdf518ab change docker hub namespace 2020-07-28 21:39:03 +02:00
Marcel Pociot
0216948d18 Merge branch 'master' of https://github.com/lukepolo/expose into lukepolo-master 2020-07-28 21:38:00 +02:00
Marcel Pociot
9e31b020b6 Merge pull request #103 from beyondcode/analysis-ZlOyRP
Apply fixes from StyleCI
2020-07-27 22:11:58 +02:00
Marcel Pociot
bf0025979e Apply fixes from StyleCI 2020-07-27 20:11:50 +00:00
Marcel Pociot
dda3cbbae5 Merge pull request #87 from localheinz/feature/normalize
Enhancement: Normalize composer.json
2020-07-27 22:11:29 +02:00
Andreas Möller
6a07859078 Enhancement: Normalize composer.json 2020-07-06 23:18:27 +02:00
Ahmed Ashraf
8db13e70af set path only if exists 2020-07-03 12:53:31 +02:00
Ahmed Ashraf
dfe889692b auto detect valet links 2020-07-03 12:50:08 +02:00
Marcel Pociot
e5b2aada2f Bump build for 1.3.0 2020-07-01 18:17:32 +02:00
Marcel Pociot
076da2c0de Use hardcoded version 2020-07-01 18:14:55 +02:00
Marcel Pociot
6410c7eb5e Merge pull request #81 from beyondcode/analysis-GDEdVA
Apply fixes from StyleCI
2020-07-01 18:11:21 +02:00
Marcel Pociot
0d9413dfdf Apply fixes from StyleCI 2020-07-01 16:11:13 +00:00
Marcel Pociot
87a4115c14 Merge pull request #79 from ahmedash95/users-pagination
Add simple pagination to users page
2020-07-01 18:10:58 +02:00
Luke Policinski
096a2b2a70 Simplified based on githubs docs
https://docs.github.com/en/actions/language-and-framework-guides/publishing-docker-images
2020-07-01 11:59:53 -04:00
Marcel Pociot
6d6306b3b2 Add request time to cli output 2020-07-01 17:58:54 +02:00
Marcel Pociot
611a4c617c Add X-Forwarded-Host header. Fixes #63 2020-07-01 17:46:54 +02:00
Marcel Pociot
54bd95c66c Merge pull request #67 from Ayesh/export-ignore
Update .gitattributes to export-ignore tests, docs, and other build files
2020-07-01 17:41:59 +02:00
Luke Policinski
3cb254e1f5 Update starting-the-server.md 2020-06-30 14:32:47 -04:00
Ahmed Ashraf
e960ffb825 Implement simple pagination 2020-06-26 12:20:01 +02:00
Marcel Pociot
459135f286 Merge pull request #74 from okaufmann/fix-remaining-time
fix remaining time calculation
2020-06-26 00:51:01 +02:00
Marcel Pociot
0efb42f989 Merge pull request #76 from riasvdv/patch-1
Don't use underscore in subdomain
2020-06-26 00:49:43 +02:00
Rias
6f04a0dfb6 Don't use underscore in subdomain
Even though it's perfectly valid, some providers (Paddle in our case) don't validate URLs with an underscore in them as valid, an easy fix is just using a dash instead.
2020-06-25 13:11:55 +02:00
Oliver Kaufmann
91f169460e fix remaining time calculation 2020-06-25 02:09:00 +02:00
Marcel Pociot
f8a6b45af7 Merge pull request #73 from clmntgr/patch-1
Update sharing.md with an https example
2020-06-24 11:22:30 +02:00
Clément Aigreault
b48dba1413 Update sharing.md with an https example 2020-06-24 11:18:20 +02:00
Marcel Pociot
8bcc7613d9 Merge pull request #71 from dakira/patch-1
Document missing nginx header config for ssl
2020-06-24 09:59:40 +02:00
Matthias Niess
9158887a60 document missing nginx header config for ssl 2020-06-24 09:54:13 +02:00
Luke Policinski
b3f2edd18c dont copy tests / .git 2020-06-23 13:03:06 -04:00
Luke Policinski
b4379ddf6d Adding action to push docker image 2020-06-23 12:44:56 -04:00
Luke Policinski
70a9666f37 Adding how to run with the readme 2020-06-23 12:37:15 -04:00
Luke Policinski
0b9f860138 adding a docker-compose file for starting a server 2020-06-23 12:28:21 -04:00
Ayesh Karunaratne
dae1851e1d Update .gitattributes to export-ignore tests, docs, and other build files 2020-06-23 04:47:03 +07:00
Marcel Pociot
70d275bb1c wip 2020-06-22 22:58:09 +02:00
Marcel Pociot
8b8c6c8e2e Fix subdomain check 2020-06-22 22:57:29 +02:00
Marcel Pociot
c5b89e1179 Add build 2020-06-22 22:51:21 +02:00
Marcel Pociot
18d67abc3f detect valet secured sites automatically 2020-06-22 22:49:19 +02:00
Marcel Pociot
38efb0b879 Merge branch 'master' of github.com:beyondcode/phunnel 2020-06-22 09:05:49 +02:00
Marcel Pociot
04c881a875 Register chrome-extension as a valid uri scheme. Fixes #50 2020-06-22 09:05:40 +02:00
Marcel Pociot
d98eabe36e Merge pull request #46 from drbyte/patch-1
Fix duplicate config section header
2020-06-19 09:34:38 +02:00
Marcel Pociot
262a1eac4a Use toArray representation of sites for the server. Fixes #44 2020-06-19 09:13:26 +02:00
Chris Brown
979bacb928 Fix config section heading 2020-06-18 12:04:50 -04:00
Chris Brown
732e0aeb3e Fix config section header 2020-06-18 12:02:41 -04:00
Marcel Pociot
2c0c544eeb Merge pull request #41 from beyondcode/analysis-KZpkkj
Apply fixes from StyleCI
2020-06-18 10:22:56 +02:00
Marcel Pociot
528d5d74e0 Apply fixes from StyleCI 2020-06-18 08:22:48 +00:00
Marcel Pociot
68200aedc4 wip 2020-06-18 10:21:28 +02:00
Marcel Pociot
8628a7e1b6 Update build to 1.1.0 2020-06-18 10:18:06 +02:00
Marcel Pociot
5df98c4b91 Update changelog 2020-06-18 10:17:13 +02:00
Marcel Pociot
78fbef90cd Merge pull request #40 from beyondcode/analysis-lKY3aQ
Apply fixes from StyleCI
2020-06-18 10:10:45 +02:00
73 changed files with 3651 additions and 2034 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
tests
.git

4
.env-example Normal file
View File

@@ -0,0 +1,4 @@
PORT=8080
DOMAIN=example.com
ADMIN_USERNAME=username
ADMIN_PASSWORD=password

17
.gitattributes vendored
View File

@@ -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
View 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
View File

@@ -7,3 +7,4 @@
expose.php
database/expose.db
.expose.php
.env

22
CHANGELOG.md Normal file
View 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
View 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

View File

@@ -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,92 @@ 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('info', function ($data) {
$this->logger->info($data->message);
});
$connection->on('error', function ($data) {
$this->logger->error($data->message);
});
$connection->on('authenticationFailed', function ($data) use ($deferred) {
$this->logger->error($data->message);
$this->exit($deferred);
});
$connection->on('setMaximumConnectionLength', function ($data) {
$timeoutSection = $this->logger->getOutput()->section();
$this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) {
$this->timeConnected++;
$secondsRemaining = $data->length * 60 - $this->timeConnected;
$remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining));
$timeoutSection->clear();
$timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S'));
});
});
}
protected function exit(Deferred $deferred)
{
$deferred->reject();
@@ -157,15 +235,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);

View File

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

View File

@@ -52,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([

View File

@@ -2,6 +2,7 @@
namespace App\Client;
use App\Client\Fileserver\Fileserver;
use App\Client\Http\Controllers\AttachDataToLogController;
use App\Client\Http\Controllers\ClearLogsController;
use App\Client\Http\Controllers\CreateTunnelController;
@@ -33,6 +34,9 @@ class Factory
/** @var App */
protected $app;
/** @var Fileserver */
protected $fileserver;
/** @var RouteGenerator */
protected $router;
@@ -109,6 +113,22 @@ class Factory
return $this;
}
public function sharePort(int $port)
{
app('expose.client')->sharePort($port);
return $this;
}
public function shareFolder(string $folder, string $name, $subdomain = null)
{
$host = $this->createFileServer($folder, $name);
$this->share($host, $subdomain);
return $this;
}
protected function addRoutes()
{
$this->router->get('/', DashboardController::class);
@@ -127,18 +147,18 @@ class Factory
}
}
protected function detectNextFreeDashboardPort($port = 4040): int
protected function detectNextAvailablePort($startPort = 4040): int
{
while (is_resource(@fsockopen('127.0.0.1', $port))) {
$port++;
while (is_resource(@fsockopen('127.0.0.1', $startPort))) {
$startPort++;
}
return $port;
return $startPort;
}
public function createHttpServer()
{
$dashboardPort = $this->detectNextFreeDashboardPort();
$dashboardPort = $this->detectNextAvailablePort();
config()->set('expose.dashboard_port', $dashboardPort);
@@ -149,11 +169,25 @@ class Factory
return $this;
}
public function createFileServer(string $folder, string $name)
{
$port = $this->detectNextAvailablePort(8090);
$this->fileserver = new Fileserver($folder, $name, $port, '0.0.0.0', $this->loop);
return "127.0.0.1:{$port}";
}
public function getApp(): App
{
return $this->app;
}
public function getFileserver(): Fileserver
{
return $this->fileserver;
}
public function run()
{
$this->loop->run();

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Client\Fileserver;
use App\Http\Controllers\Concerns\LoadsViews;
use App\Http\QueryParameters;
use GuzzleHttp\Psr7\ServerRequest;
use Illuminate\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\Http\Response;
use React\Stream\ReadableResourceStream;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\Iterator\FilenameFilterIterator;
class ConnectionHandler
{
use LoadsViews;
/** @var string */
protected $rootFolder;
/** @var string */
protected $name;
/** @var LoopInterface */
protected $loop;
public function __construct(string $rootFolder, string $name, LoopInterface $loop)
{
$this->rootFolder = $rootFolder;
$this->name = $name;
$this->loop = $loop;
}
public function handle(ServerRequestInterface $request)
{
$request = $this->createLaravelRequest($request);
$targetPath = realpath($this->rootFolder.DIRECTORY_SEPARATOR.$request->path());
if (! $this->isValidTarget($targetPath)) {
return new Response(404);
}
if (is_dir($targetPath)) {
// Directory listing
$directoryContent = Finder::create()
->depth(0)
->sort(function ($a, $b) {
return strcmp(strtolower($a->getRealpath()), strtolower($b->getRealpath()));
})
->in($targetPath);
if ($this->name !== '') {
$directoryContent->name($this->name);
}
$parentPath = explode('/', $request->path());
array_pop($parentPath);
$parentPath = implode('/', $parentPath);
return new Response(
200,
['Content-Type' => 'text/html'],
$this->getView(null, 'client.fileserver', [
'currentPath' => $request->path(),
'parentPath' => $parentPath,
'directory' => $targetPath,
'directoryContent' => $directoryContent,
])
);
}
if (is_file($targetPath)) {
return new Response(
200,
['Content-Type' => mime_content_type($targetPath)],
new ReadableResourceStream(fopen($targetPath, 'r'), $this->loop)
);
}
}
protected function isValidTarget(string $targetPath): bool
{
if (! file_exists($targetPath)) {
return false;
}
if ($this->name !== '') {
$filter = new class(basename($targetPath), [$this->name]) extends FilenameFilterIterator {
protected $filename;
public function __construct(string $filename, array $matchPatterns)
{
$this->filename = $filename;
foreach ($matchPatterns as $pattern) {
$this->matchRegexps[] = $this->toRegex($pattern);
}
}
public function accept()
{
return $this->isAccepted($this->filename);
}
};
return $filter->accept();
}
return true;
}
protected function createLaravelRequest(ServerRequestInterface $request): Request
{
try {
parse_str($request->getBody(), $bodyParameters);
} catch (\Throwable $e) {
$bodyParameters = [];
}
$serverRequest = (new ServerRequest(
$request->getMethod(),
$request->getUri(),
$request->getHeaders(),
$request->getBody(),
$request->getProtocolVersion(),
))
->withQueryParams(QueryParameters::create($request)->all())
->withParsedBody($bodyParameters);
return Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest));
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Client\Fileserver;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\Http\Server;
use React\Socket\Server as SocketServer;
class Fileserver
{
/** @var SocketServer */
protected $socket;
public function __construct($rootFolder, $name, $port, $address, LoopInterface $loop)
{
$server = new Server(function (ServerRequestInterface $request) use ($rootFolder, $name, $loop) {
return (new ConnectionHandler($rootFolder, $name, $loop))->handle($request);
});
$this->socket = new SocketServer("{$address}:{$port}", $loop);
$server->listen($this->socket);
}
public function getSocket(): SocketServer
{
return $this->socket;
}
}

View File

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

View File

@@ -5,7 +5,9 @@ namespace App\Client;
use App\Client\Http\HttpClient;
use function Ratchet\Client\connect;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\Frame;
use React\EventLoop\LoopInterface;
use React\Socket\Connector;
class ProxyManager
{
@@ -30,7 +32,7 @@ class ProxyManager
], $this->loop)
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
$proxyConnection->on('message', function ($message) use ($proxyConnection, $connectionData) {
$this->performRequest($proxyConnection, $connectionData->request_id, (string) $message);
$this->performRequest($proxyConnection, (string) $message, $connectionData);
});
$proxyConnection->send(json_encode([
@@ -43,8 +45,39 @@ class ProxyManager
});
}
protected function performRequest(WebSocket $proxyConnection, $requestId, string $requestData)
public function createTcpProxy(string $clientId, $connectionData)
{
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $requestId);
$protocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
connect($protocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
$connector = new Connector($this->loop);
$connector->connect('127.0.0.1:'.$connectionData->port)->then(function ($connection) use ($proxyConnection) {
$connection->on('data', function ($data) use ($proxyConnection) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxyConnection->send($binaryMsg);
});
$proxyConnection->on('message', function ($message) use ($connection) {
$connection->write($message);
});
});
$proxyConnection->send(json_encode([
'event' => 'registerTcpProxy',
'data' => [
'tcp_request_id' => $connectionData->tcp_request_id ?? null,
'client_id' => $clientId,
],
]));
});
}
protected function performRequest(WebSocket $proxyConnection, string $requestData, $connectionData)
{
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $connectionData);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,48 @@
<?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 ShareFilesCommand extends Command
{
protected $signature = 'share-files {folder=.} {--name=} {--subdomain=} {--auth=}';
protected $description = 'Share a local folder with a remote expose server';
protected function configureConnectionLogger()
{
app()->bind(CliRequestLogger::class, function () {
return new CliRequestLogger(new ConsoleOutput());
});
return $this;
}
public function handle()
{
if (! is_dir($this->argument('folder'))) {
throw new \InvalidArgumentException('The folder '.$this->argument('folder').' does not exist.');
}
$this->configureConnectionLogger();
(new Factory())
->setLoop(app(LoopInterface::class))
->setHost(config('expose.host', 'localhost'))
->setPort(config('expose.port', 8080))
->setAuth($this->option('auth'))
->createClient()
->shareFolder(
$this->argument('folder'),
$this->option('name') ?? '',
explode(',', $this->option('subdomain'))
)
->createHttpServer()
->run();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Commands;
use App\Client\Factory;
use App\Logger\CliRequestLogger;
use LaravelZero\Framework\Commands\Command;
use React\EventLoop\LoopInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
class SharePortCommand extends Command
{
protected $signature = 'share-port {port} {--auth=}';
protected $description = 'Share a local port with a remote expose server';
protected function configureConnectionLogger()
{
app()->bind(CliRequestLogger::class, function () {
return new CliRequestLogger(new ConsoleOutput());
});
return $this;
}
public function handle()
{
$this->configureConnectionLogger();
(new Factory())
->setLoop(app(LoopInterface::class))
->setHost(config('expose.host', 'localhost'))
->setPort(config('expose.port', 8080))
->setAuth($this->option('auth'))
->createClient()
->sharePort($this->argument('port'))
->createHttpServer()
->run();
}
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ use Twig\Loader\ArrayLoader;
trait LoadsViews
{
protected function getView(ConnectionInterface $connection, string $view, array $data = [])
protected function getView(?ConnectionInterface $connection, string $view, array $data = [])
{
$templatePath = implode(DIRECTORY_SEPARATOR, explode('.', $view));
@@ -23,7 +23,10 @@ trait LoadsViews
$data = array_merge($data, [
'request' => $connection->laravelRequest ?? null,
]);
return stream_for($twig->render('template', $data));
try {
return stream_for($twig->render('template', $data));
} catch (\Throwable $e) {
var_dump($e->getMessage());
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

@@ -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();
}
@@ -41,6 +43,8 @@ class ControlConnection
$this->socket->send(json_encode([
'event' => 'createProxy',
'data' => [
'host' => $this->host,
'subdomain' => $this->subdomain,
'request_id' => $requestId,
'client_id' => $this->client_id,
],
@@ -55,8 +59,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,
];

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Server\Connections;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\Frame;
use React\Socket\Server;
class TcpControlConnection extends ControlConnection
{
public $proxy;
public $proxyConnection;
public $port;
public $shared_port;
public $shared_server;
public function __construct(ConnectionInterface $socket, int $port, Server $sharedServer, string $clientId, string $authToken = '')
{
$this->socket = $socket;
$this->client_id = $clientId;
$this->shared_server = $sharedServer;
$this->port = $port;
$this->shared_at = now()->toDateTimeString();
$this->shared_port = parse_url($sharedServer->getAddress(), PHP_URL_PORT);
$this->authToken = $authToken;
$this->configureServer($sharedServer);
}
public function setMaximumConnectionLength(int $maximumConnectionLength)
{
$this->socket->send(json_encode([
'event' => 'setMaximumConnectionLength',
'data' => [
'length' => $maximumConnectionLength,
],
]));
}
public function registerProxy($requestId)
{
$this->socket->send(json_encode([
'event' => 'createProxy',
'data' => [
'request_id' => $requestId,
'client_id' => $this->client_id,
],
]));
}
public function registerTcpProxy($requestId)
{
$this->socket->send(json_encode([
'event' => 'createTcpProxy',
'data' => [
'port' => $this->port,
'tcp_request_id' => $requestId,
'client_id' => $this->client_id,
],
]));
}
public function stop()
{
$this->shared_server->close();
$this->shared_server = null;
}
public function close()
{
$this->socket->close();
}
public function toArray()
{
return [
'type' => 'tcp',
'port' => $this->port,
'client_id' => $this->client_id,
'shared_port' => $this->shared_port,
'shared_at' => $this->shared_at,
];
}
protected function configureServer(Server $sharedServer)
{
$requestId = uniqid();
$sharedServer->on('connection', function (\React\Socket\ConnectionInterface $connection) use ($requestId) {
$this->proxyConnection = $connection;
$this->once('tcp_proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($connection) {
$this->proxy = $proxy;
$connection->on('data', function ($data) use ($proxy) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxy->send($binaryMsg);
});
$connection->resume();
});
$connection->pause();
$this->registerTcpProxy($requestId);
});
}
}

View File

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

View File

@@ -4,20 +4,27 @@ namespace App\Server;
use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\SubdomainGenerator;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use App\Http\RouteGenerator;
use App\Http\Server as HttpServer;
use App\Server\Connections\ConnectionManager;
use App\Server\Http\Controllers\Admin\DeleteSubdomainController;
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;
use App\Server\Http\Controllers\Admin\StoreSettingsController;
use App\Server\Http\Controllers\Admin\StoreSubdomainController;
use App\Server\Http\Controllers\Admin\StoreUsersController;
use App\Server\Http\Controllers\ControlMessageController;
use App\Server\Http\Controllers\TunnelMessageController;
@@ -119,14 +126,20 @@ 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->post('/api/subdomains', StoreSubdomainController::class, $adminCondition);
$this->router->delete('/api/subdomains/{subdomain}', DeleteSubdomainController::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()
@@ -163,6 +176,7 @@ class Factory
$this->bindConfiguration()
->bindSubdomainGenerator()
->bindUserRepository()
->bindSubdomainRepository()
->bindDatabase()
->ensureDatabaseIsInitialized()
->bindConnectionManager()
@@ -199,6 +213,15 @@ class Factory
return $this;
}
protected function bindSubdomainRepository()
{
app()->singleton(SubdomainRepository::class, function () {
return app(config('expose.admin.subdomain_repository'));
});
return $this;
}
protected function bindDatabase()
{
app()->singleton(DatabaseInterface::class, function () {

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DeleteSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository->getUserByToken($request->get('auth_token', ''))
->then(function ($user) use ($request, $httpConnection) {
if (is_null($user)) {
$httpConnection->send(respond_json(['error' => 'The user does not exist'], 404));
$httpConnection->close();
return;
}
$this->subdomainRepository->deleteSubdomainForUserId($user['id'], $request->get('subdomain'))
->then(function ($deleted) use ($httpConnection) {
$httpConnection->send(respond_json(['deleted' => $deleted], 200));
$httpConnection->close();
});
});
}
}

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\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(),
])
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,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

View File

@@ -3,12 +3,13 @@
namespace App\Server\Http\Controllers;
use App\Contracts\ConnectionManager;
use App\Contracts\SubdomainRepository;
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;
@@ -20,10 +21,14 @@ class ControlMessageController implements MessageComponentInterface
/** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository)
/** @var SubdomainRepository */
protected $subdomainRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
/**
@@ -54,6 +59,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 +86,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 +103,61 @@ class ControlMessageController implements MessageComponentInterface
});
}
protected function handleHttpConnection(ConnectionInterface $connection, $data, $user = null)
{
$this->hasValidSubdomain($connection, $data->subdomain, $user)->then(function ($subdomain) use ($data, $connection) {
if ($subdomain === false) {
return;
}
$data->subdomain = $subdomain;
$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 +169,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 +192,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 +201,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,24 +212,79 @@ class ControlMessageController implements MessageComponentInterface
return $deferred->promise();
}
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain): bool
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain, ?array $user): PromiseInterface
{
/**
* Check if the user can specify a custom subdomain in the first place.
*/
if (! is_null($user) && $user['can_specify_subdomains'] === 0 && ! is_null($subdomain)) {
$connection->send(json_encode([
'event' => 'info',
'data' => [
'message' => config('expose.admin.messages.custom_subdomain_unauthorized').PHP_EOL,
],
]));
return \React\Promise\resolve(null);
}
/**
* Check if the given subdomain is reserved for a different user.
*/
if (! is_null($subdomain)) {
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
$message = config('expose.admin.messages.subdomain_taken');
$message = str_replace(':subdomain', $subdomain, $message);
return $this->subdomainRepository->getSubdomainByName($subdomain)
->then(function ($foundSubdomain) use ($connection, $subdomain, $user) {
if (! is_null($foundSubdomain) && ! is_null($user) && $foundSubdomain['user_id'] !== $user['id']) {
$message = config('expose.admin.messages.subdomain_reserved');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
return false;
}
return \React\Promise\resolve(false);
}
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
$message = config('expose.admin.messages.subdomain_taken');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
return \React\Promise\resolve(false);
}
return \React\Promise\resolve($subdomain);
});
}
return \React\Promise\resolve($subdomain);
}
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;

View File

@@ -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;
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Server\UserRepository;
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])

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ return [
|
*/
'version' => app('git.version'),
'version' => '1.3.0',
/*
|--------------------------------------------------------------------------

View File

@@ -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
@@ -214,6 +232,8 @@ return [
*/
'user_repository' => \App\Server\UserRepository\DatabaseUserRepository::class,
'subdomain_repository' => \App\Server\SubdomainRepository\DatabaseSubdomainRepository::class,
/*
|--------------------------------------------------------------------------
| Messages
@@ -230,6 +250,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. Assigning a random subdomain instead.',
'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.',
],
],
];

View File

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

View File

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

View File

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

14
docker-compose.yml Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,9 @@ order: 2
Once your Expose server is running, you can only access it over the port that you configure when the server gets started.
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>
```

View File

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

View File

@@ -0,0 +1,93 @@
<html lang="en">
<head>
<title>Expose Fileserver</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css">
<script src="https://cdn.jsdelivr.net/npm/clipboard@2/dist/clipboard.min.js"></script>
<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.1.0/build/styles/github.min.css">
<script src="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.1.0/build/highlight.min.js" async></script>
</head>
<body>
<div id="app" class="">
<div class="relative bg-indigo-600" style="marign-left: -1px">
<div class="max-w-screen-xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
<div class="pr-16 sm:text-center sm:px-16">
<p class="font-medium text-white flex justify-center">
<span class="inline-block font-mono">{{ directory }}</span>
</p>
</div>
</div>
</div>
{% macro bytesToSize(bytes) %}
{% set kilobyte = 1024 %}
{% set megabyte = kilobyte * 1024 %}
{% set gigabyte = megabyte * 1024 %}
{% set terabyte = gigabyte * 1024 %}
{% if bytes < kilobyte %}
{{ bytes ~ ' B' }}
{% elseif bytes < megabyte %}
{{ (bytes / kilobyte)|number_format(2, '.') ~ ' KiB' }}
{% elseif bytes < gigabyte %}
{{ (bytes / megabyte)|number_format(2, '.') ~ ' MiB' }}
{% elseif bytes < terabyte %}
{{ (bytes / gigabyte)|number_format(2, '.') ~ ' GiB' }}
{% else %}
{{ (bytes / terabyte)|number_format(2, '.') ~ ' TiB' }}
{% endif %}
{% endmacro %}
<div class="flex flex-col px-6 py-4">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Date Modified
</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
</tr>
</thead>
<tbody class="bg-white">
{% if currentPath != '/' %}
<tr class="border-b">
<td colspan="3" class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-mono text-gray-900">
<a href="/{{ parentPath }}" class="text-indigo-600 font-bold hover:text-indigo-900">Back</a>
</td>
</tr>
{% endif %}
{% for item in directoryContent %}
<tr class="{% if loop.index % 2 == 0 %} bg-gray-50 {% endif %}">
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-mono text-gray-900">
{% if currentPath != '/' %}
<a href="/{{ currentPath }}/{{ item.getFilename() }}" class="text-indigo-600 hover:text-indigo-900">{{ item.filename }}</a>
{% else %}
<a href="/{{ item.getFilename() }}" class="text-indigo-600 hover:text-indigo-900">{{ item.filename }}</a>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
{{ item.getMTime() | date("m/d/Y H:i:s") }}
</td>
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
{% if item.isDir() %}
-
{% else %}
{{ _self.bytesToSize(item.getSize()) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -27,6 +27,12 @@
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
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 %}

View File

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

View File

@@ -24,6 +24,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);
}

View File

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

View File

@@ -0,0 +1,111 @@
<?php
namespace Tests\Feature\Client;
use App\Client\Configuration;
use App\Client\Factory;
use Clue\React\Buzz\Browser;
use Clue\React\Buzz\Message\ResponseException;
use Psr\Http\Message\ResponseInterface;
use Tests\Feature\TestCase;
class FileserverTest extends TestCase
{
/** @var Browser */
protected $browser;
/** @var Factory */
protected $clientFactory;
/** @var string */
protected $fileserverUrl;
public function setUp(): void
{
parent::setUp();
$this->browser = new Browser($this->loop);
}
public function tearDown(): void
{
parent::tearDown();
$this->clientFactory->getFileserver()->getSocket()->close();
}
/** @test */
public function accessing_the_fileserver_works()
{
$this->shareFolder(__DIR__);
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://'.$this->fileserverUrl));
$this->assertSame(200, $response->getStatusCode());
}
/** @test */
public function accessing_invalid_files_returns_404()
{
$this->shareFolder(__DIR__);
$this->expectException(ResponseException::class);
$this->expectExceptionMessage(404);
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/invalid-file'));
$this->assertSame(404, $response->getStatusCode());
}
/** @test */
public function it_can_return_filtered_responses()
{
$this->shareFolder(__DIR__.'/../../fixtures', '*.md');
$this->expectException(ResponseException::class);
$this->expectExceptionMessage(404);
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt'));
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents());
}
/** @test */
public function it_can_return_file_responses()
{
$this->shareFolder(__DIR__.'/../../fixtures');
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt'));
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents());
}
/** @test */
public function it_can_return_file_responses_for_valid_filtered_files()
{
$this->shareFolder(__DIR__.'/../../fixtures', '*.txt');
/** @var ResponseInterface $response */
$response = $this->await($this->browser->get('http://'.$this->fileserverUrl.'/test.txt'));
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('test-file'.PHP_EOL, $response->getBody()->getContents());
}
protected function shareFolder(string $folder, string $name = '')
{
app()->singleton(Configuration::class, function ($app) {
return new Configuration('localhost', '8080', false);
});
$factory = (new Factory())->setLoop($this->loop);
$this->fileserverUrl = $factory->createFileServer($folder, $name);
$this->clientFactory = $factory;
}
}

View File

@@ -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 */

View File

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

View File

@@ -9,6 +9,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,179 @@ 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,
])));
$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->assertNotSame('tunnel', $response->subdomain);
}
/** @test */
public function it_rejects_users_that_want_to_use_a_reserved_subdomain()
{
$this->app['config']['expose.admin.validate_auth_tokens'] = true;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'name' => 'Marcel',
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'auth_token' => $user->auth_token,
])));
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'name' => 'Test-User',
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$this->createTestHttpServer();
$this->expectException(\UnexpectedValueException::class);
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'reserved', $user->auth_token));
$this->assertSame('reserved', $response->subdomain);
}
/** @test */
public function it_allows_users_to_use_their_own_reserved_subdomains()
{
$this->app['config']['expose.admin.validate_auth_tokens'] = true;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/users', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'name' => 'Marcel',
'can_specify_subdomains' => 1,
])));
$user = json_decode($response->getBody()->getContents())->user;
$response = $this->await($this->browser->post('http://127.0.0.1:8080/api/subdomains', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
], json_encode([
'subdomain' => 'reserved',
'auth_token' => $user->auth_token,
])));
$this->createTestHttpServer();
/**
* We create an expose client that connects to our server and shares
* the created test HTTP server.
*/
$client = $this->createClient();
$response = $this->await($client->connectToServer('127.0.0.1:8085', 'reserved', $user->auth_token));
$this->assertSame('reserved', $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 +399,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);
});
}
}

View File

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

1
tests/fixtures/test.md vendored Normal file
View File

@@ -0,0 +1 @@
# Markdown

1
tests/fixtures/test.txt vendored Normal file
View File

@@ -0,0 +1 @@
test-file