This commit is contained in:
Marcel Pociot
2020-04-24 12:37:31 +02:00
parent 018d778e5f
commit 9ce19f975e
28 changed files with 1160 additions and 91 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Client;
use App\Client\Connections\ControlConnection;
use App\Logger\CliRequestLogger;
use Ratchet\Client\WebSocket;
use React\EventLoop\LoopInterface;
use function Ratchet\Client\connect;
@@ -15,16 +16,22 @@ class Client
/** @var Configuration */
protected $configuration;
/** @var CliRequestLogger */
protected $logger;
public static $subdomains = [];
public function __construct(LoopInterface $loop, Configuration $configuration)
public function __construct(LoopInterface $loop, Configuration $configuration, CliRequestLogger $logger)
{
$this->loop = $loop;
$this->configuration = $configuration;
$this->logger = $logger;
}
public function share(string $sharedUrl, array $subdomains = [])
{
$this->logger->info("Sharing http://{$sharedUrl}");
foreach ($subdomains as $subdomain) {
$this->connectToServer($sharedUrl, $subdomain);
}
@@ -32,7 +39,7 @@ class Client
protected function connectToServer(string $sharedUrl, $subdomain)
{
connect("ws://{$this->configuration->host()}:{$this->configuration->port()}/__expose_control__", [], [
connect("ws://{$this->configuration->host()}:{$this->configuration->port()}/__expose_control__?authToken={$this->configuration->authToken()}", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain) {
@@ -40,8 +47,14 @@ class Client
$connection->authenticate($sharedUrl, $subdomain);
$connection->on('authenticationFailed', function ($data) {
$this->logger->error("Authentication failed. Please check your authentication token and try again.");
exit(1);
});
$connection->on('authenticated', function ($data) {
dump("Connected to http://$data->subdomain.{$this->configuration->host()}:{$this->configuration->port()}");
$this->logger->info("Connected to http://$data->subdomain.{$this->configuration->host()}:{$this->configuration->port()}");
static::$subdomains[] = "$data->subdomain.{$this->configuration->host()}:{$this->configuration->port()}";
});

View File

@@ -13,13 +13,18 @@ class Configuration
/** @var string|null */
protected $auth;
public function __construct(string $host, int $port, ?string $auth = null)
/** @var string|null */
protected $authToken;
public function __construct(string $host, int $port, ?string $auth = null, string $authToken = null)
{
$this->host = $host;
$this->port = $port;
$this->auth = $auth;
$this->authToken = $authToken;
}
public function host(): string
@@ -36,4 +41,9 @@ class Configuration
{
return $this->port;
}
public function authToken()
{
return $this->authToken;
}
}

View File

@@ -34,9 +34,8 @@ class ControlConnection
$this->socket->on('message', function (Message $message) {
$decodedEntry = json_decode($message);
$this->emit($decodedEntry->event ?? '', [$decodedEntry]);
if (method_exists($this, $decodedEntry->event ?? '')) {
$this->emit($decodedEntry->event, [$decodedEntry]);
call_user_func([$this, $decodedEntry->event], $decodedEntry);
}
});

View File

@@ -27,6 +27,9 @@ class Factory
/** @var string */
protected $auth = '';
/** @var string */
protected $authToken = '';
/** @var \React\EventLoop\LoopInterface */
protected $loop;
@@ -52,13 +55,20 @@ class Factory
return $this;
}
public function setAuth(string $auth)
public function setAuth(?string $auth)
{
$this->auth = $auth;
return $this;
}
public function setAuthToken(?string $authToken)
{
$this->authToken = $authToken;
return $this;
}
public function setLoop(LoopInterface $loop)
{
$this->loop = $loop;
@@ -69,7 +79,7 @@ class Factory
protected function bindConfiguration()
{
app()->singleton(Configuration::class, function ($app) {
return new Configuration($this->host, $this->port, $this->auth);
return new Configuration($this->host, $this->port, $this->auth, $this->authToken);
});
}

View File

@@ -47,8 +47,6 @@ class HttpClient
$request = $this->passRequestThroughModifiers(parse_request($requestData), $proxyConnection);
dump($this->request->getMethod() . ' ' . $this->request->getUri()->getPath());
transform($request, function ($request) use ($proxyConnection) {
$this->sendRequestToApplication($request, $proxyConnection);
});

View File

@@ -8,7 +8,7 @@ use React\EventLoop\LoopInterface;
class ServeCommand extends Command
{
protected $signature = 'serve {host=0.0.0.0} {hostname=localhost}';
protected $signature = 'serve {host=0.0.0.0} {hostname=localhost} {--validateAuthTokens}';
protected $description = 'Start the shaft server';
@@ -18,6 +18,7 @@ class ServeCommand extends Command
->setLoop(app(LoopInterface::class))
->setHost($this->argument('host'))
->setHostname($this->argument('hostname'))
->validateAuthTokens($this->option('validateAuthTokens'))
->createServer()
->run();
}

View File

@@ -3,23 +3,37 @@
namespace App\Commands;
use App\Client\Factory;
use App\Logger\CliRequestLogger;
use Illuminate\Console\Scheduling\Schedule;
use LaravelZero\Framework\Commands\Command;
use React\EventLoop\LoopInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
class ShareCommand extends Command
{
protected $signature = 'share {host} {--subdomain=} {--auth=}';
protected $signature = 'share {host} {--subdomain=} {--auth=} {--token=}';
protected $description = 'Share a local url with a remote shaft 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('beyond.sh') // TODO: Read from (local/global) config file
// ->setPort(8080) // TODO: Read from (local/global) config file
->setAuth($this->option('auth'))
->setAuthToken($this->option('token'))
->createClient($this->argument('host'), explode(',', $this->option('subdomain')))
->createHttpServer()
->run();

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Commands;
use Illuminate\Console\Command;
class ShareCurrentWorkingDirectoryCommand extends ShareCommand
{
protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=}';
public function handle()
{
$this->input->setArgument('host', basename(getcwd()).'.test');
parent::handle();
}
}

View File

@@ -68,13 +68,21 @@ abstract class PostController extends Controller
protected function createLaravelRequest(ConnectionInterface $connection): Request
{
try {
parse_str($connection->requestBuffer, $bodyParameters);
} catch (\Throwable $e) {
$bodyParameters = [];
}
$serverRequest = (new ServerRequest(
$connection->request->getMethod(),
$connection->request->getUri(),
$connection->request->getHeaders(),
$connection->requestBuffer,
$connection->request->getProtocolVersion()
))->withQueryParams(QueryParameters::create($connection->request)->all());
))
->withQueryParams(QueryParameters::create($connection->request)->all())
->withParsedBody($bodyParameters);
return Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest));
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Logger;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Collection;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
class CliRequestLogger extends Logger
{
/** @var Table */
protected $table;
/** @var Collection */
protected $requests;
/** @var \Symfony\Component\Console\Output\ConsoleSectionOutput */
protected $section;
public function __construct(ConsoleOutputInterface $consoleOutput)
{
parent::__construct($consoleOutput);
$this->section = $this->output->section();
$this->table = new Table($this->section);
$this->table->setHeaders(['Method', 'URI', 'Response', 'Duration']);
$this->requests = new Collection();
}
public function logRequest(LoggedRequest $loggedRequest)
{
if ($this->requests->has($loggedRequest->id())) {
$this->requests[$loggedRequest->id()] = $loggedRequest;
} else {
$this->requests->prepend($loggedRequest, $loggedRequest->id());
}
$this->requests = $this->requests->slice(0, 10);
$this->section->clear();
$this->table->setRows($this->requests->map(function (LoggedRequest $loggedRequest) {
return [
$loggedRequest->getRequest()->getMethod(),
$loggedRequest->getRequest()->getUri(),
optional($loggedRequest->getResponse())->getStatusCode() . ' ' . optional($loggedRequest->getResponse())->getReasonPhrase(),
$loggedRequest->getDuration().'ms'
];
})->toArray());
$this->table->render();
}
}

View File

@@ -56,7 +56,7 @@ class LoggedRequest implements \JsonSerializable
$data = [
'id' => $this->id,
'performed_at' => $this->startTime->toDateTimeString(),
'duration' => $this->startTime->diffInMilliseconds($this->stopTime, false),
'duration' => $this->getDuration(),
'subdomain' => $this->detectSubdomain(),
'request' => [
'raw' => $this->isBinary($this->rawRequest) ? 'BINARY' : $this->rawRequest,
@@ -132,6 +132,11 @@ class LoggedRequest implements \JsonSerializable
return $this->rawRequest;
}
public function getResponse()
{
return $this->parsedResponse;
}
protected function getResponseBody()
{
return \Laminas\Http\Response::fromString($this->rawResponse)->getBody();
@@ -198,4 +203,9 @@ class LoggedRequest implements \JsonSerializable
{
return Arr::get($this->parsedRequest->getHeaders()->toArray(), 'X-Expose-Request-ID', (string)Str::uuid());
}
public function getDuration()
{
return $this->startTime->diffInMilliseconds($this->stopTime, false);
}
}

View File

@@ -2,64 +2,21 @@
namespace App\Logger;
use Illuminate\Console\Concerns\InteractsWithIO;
use Illuminate\Console\OutputStyle;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Logger
{
/** @var \Symfony\Component\Console\Output\OutputInterface */
protected $consoleOutput;
use InteractsWithIO;
/** @var bool */
protected $enabled = false;
/** @var ConsoleOutputInterface */
protected $output;
/** @var bool */
protected $verbose = false;
public function __construct(OutputInterface $consoleOutput)
public function __construct(ConsoleOutputInterface $consoleOutput)
{
$this->consoleOutput = $consoleOutput;
}
public function enable($enabled = true)
{
$this->enabled = $enabled;
return $this;
}
public function verbose($verbose = false)
{
$this->verbose = $verbose;
return $this;
}
protected function info(string $message)
{
$this->line($message, 'info');
}
protected function warn(string $message)
{
if (! $this->consoleOutput->getFormatter()->hasStyle('warning')) {
$style = new OutputFormatterStyle('yellow');
$this->consoleOutput->getFormatter()->setStyle('warning', $style);
}
$this->line($message, 'warning');
}
protected function error(string $message)
{
$this->line($message, 'error');
}
protected function line(string $message, string $style)
{
$styled = $style ? "<$style>$message</$style>" : $message;
$this->consoleOutput->writeln($styled);
$this->output = $consoleOutput;
}
}

View File

@@ -10,12 +10,19 @@ use function GuzzleHttp\Psr7\stream_for;
class RequestLogger
{
/** @var array */
protected $requests = [];
/** @var array */
protected $responses = [];
public function __construct(Browser $browser)
/** @var CliRequestLogger */
protected $cliRequestLogger;
public function __construct(Browser $browser, CliRequestLogger $cliRequestLogger)
{
$this->client = $browser;
$this->cliRequestLogger = $cliRequestLogger;
}
public function findLoggedRequest(string $id): ?LoggedRequest
@@ -27,10 +34,14 @@ class RequestLogger
public function logRequest(string $rawRequest, Request $request)
{
array_unshift($this->requests, new LoggedRequest($rawRequest, $request));
$loggedRequest = new LoggedRequest($rawRequest, $request);
array_unshift($this->requests, $loggedRequest);
$this->requests = array_slice($this->requests, 0, 10);
$this->cliRequestLogger->logRequest($loggedRequest);
$this->pushLogs();
}
@@ -42,6 +53,8 @@ class RequestLogger
if ($loggedRequest) {
$loggedRequest->setResponse($rawResponse, Response::fromString($rawResponse));
$this->cliRequestLogger->logRequest($loggedRequest);
$this->pushLogs();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Logger\CliRequestLogger;
use App\Logger\RequestLogger;
use Clue\React\Buzz\Browser;
use Illuminate\Support\ServiceProvider;
@@ -21,10 +22,8 @@ class AppServiceProvider extends ServiceProvider
return LoopFactory::create();
});
$this->app->singleton(RequestLogger::class, function () {
$browser = new Browser(app(LoopInterface::class));
return new RequestLogger($browser);
$this->app->singleton(RequestLogger::class, function ($app) {
return new RequestLogger($app->make(Browser::class), $app->make(CliRequestLogger::class));
});
}
}

View File

@@ -6,15 +6,21 @@ use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\SubdomainGenerator;
use App\HttpServer\HttpServer;
use App\Server\Connections\ConnectionManager;
use App\Server\Http\Controllers\Admin\DeleteUsersController;
use App\Server\Http\Controllers\Admin\ListUsersController;
use App\Server\Http\Controllers\Admin\StoreUsersController;
use App\Server\Http\Controllers\ControlMessageController;
use App\Server\Http\Controllers\TunnelMessageController;
use App\Server\SubdomainGenerator\RandomSubdomainGenerator;
use Clue\React\SQLite\DatabaseInterface;
use Ratchet\Http\Router;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;
use React\Socket\Server;
use React\EventLoop\LoopInterface;
use React\EventLoop\Factory as LoopFactory;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
@@ -34,9 +40,13 @@ class Factory
/** @var \React\EventLoop\LoopInterface */
protected $loop;
/** @var RouteCollection */
protected $routes;
public function __construct()
{
$this->loop = LoopFactory::create();
$this->routes = new RouteCollection();
}
public function setHost(string $host)
@@ -67,25 +77,42 @@ class Factory
return $this;
}
protected function getRoutes(): RouteCollection
protected function addExposeRoutes()
{
$routes = new RouteCollection();
$routes->add('control',
$this->routes->add('control',
new Route('/__expose_control__', [
'_controller' => new WsServer(app(ControlMessageController::class))
], [], [], null, [], []
)
);
$routes->add('tunnel',
$this->routes->add('tunnel',
new Route('/{__catchall__}', [
'_controller' => app(TunnelMessageController::class),
], [
'__catchall__' => '.*'
]));
}
return $routes;
protected function addAdminRoutes()
{
$this->routes->add('admin.users.index',
new Route('/expose/users', [
'_controller' => app(ListUsersController::class),
], [], [], null, [], ['GET'])
);
$this->routes->add('admin.users.store',
new Route('/expose/users', [
'_controller' => app(StoreUsersController::class),
], [], [], null, [], ['POST'])
);
$this->routes->add('admin.users.delete',
new Route('/expose/users/delete/{id}', [
'_controller' => app(DeleteUsersController::class),
], [], [], null, [], ['DELETE'])
);
}
protected function bindConfiguration()
@@ -117,9 +144,17 @@ class Factory
$this->bindSubdomainGenerator();
$this->bindDatabase();
$this->ensureDatabaseIsInitialized();
$this->bindConnectionManager();
$urlMatcher = new UrlMatcher($this->getRoutes(), new RequestContext);
$this->addAdminRoutes();
$this->addExposeRoutes();
$urlMatcher = new UrlMatcher($this->routes, new RequestContext);
$router = new Router($urlMatcher);
@@ -128,4 +163,36 @@ class Factory
return new IoServer($http, $socket, $this->loop);
}
protected function bindDatabase()
{
app()->singleton(DatabaseInterface::class, function() {
$factory = new \Clue\React\SQLite\Factory($this->loop);
return $factory->openLazy(base_path('database/expose.db'));
});
}
protected function ensureDatabaseIsInitialized()
{
/** @var DatabaseInterface $db */
$db = app(DatabaseInterface::class);
$migrations = (new Finder())
->files()
->ignoreDotFiles(true)
->in(database_path('migrations'))
->name('*.sql');
/** @var SplFileInfo $migration */
foreach ($migrations as $migration) {
$db->exec($migration->getContents());
}
}
public function validateAuthTokens(bool $validate)
{
dump($validate);
return $this;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\HttpServer\Controllers\PostController;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\str;
use function GuzzleHttp\Psr7\stream_for;
class DeleteUsersController extends PostController
{
protected $keepConnectionOpen = true;
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->database->query("DELETE FROM users WHERE id = :id", ['id' => $request->id])
->then(function (Result $result) use ($httpConnection) {
$httpConnection->send(respond_json(['deleted' => true], 200));
$httpConnection->close();
});
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\HttpServer\Controllers\PostController;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\str;
use function GuzzleHttp\Psr7\stream_for;
class ListUsersController extends PostController
{
protected $keepConnectionOpen = true;
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->database->query('SELECT * FROM users ORDER by created_at DESC')->then(function (Result $result) use ($httpConnection) {
$httpConnection->send(
respond_html($this->getView(['users' => $result->rows]))
);
$httpConnection->close();
}, function (\Exception $exception) use ($httpConnection) {
$httpConnection->send(respond_html('Something went wrong: '.$exception->getMessage(), 500));
$httpConnection->close();
});
}
protected function getView(array $data)
{
$twig = new Environment(
new ArrayLoader([
'template' => file_get_contents(base_path('resources/views/admin/users/index.twig')),
])
);
return stream_for($twig->render('template', $data));
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\HttpServer\Controllers\PostController;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use function GuzzleHttp\Psr7\str;
use function GuzzleHttp\Psr7\stream_for;
class StoreUsersController extends PostController
{
protected $keepConnectionOpen = true;
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$validator = Validator::make($request->all(), [
'name' => 'required',
], [
'required' => 'The :attribute field is required.',
]);
if ($validator->fails()) {
$httpConnection->send(respond_json(['errors' => $validator->getMessageBag()], 401));
$httpConnection->close();
return;
}
$insertData = [
'name' => $request->get('name'),
'auth_token' => (string)Str::uuid()
];
$this->database->query("
INSERT INTO users (name, auth_token, created_at)
VALUES (:name, :auth_token, DATETIME('now'))
", $insertData)
->then(function (Result $result) use ($httpConnection) {
$this->database->query("SELECT * FROM users WHERE id = :id", ['id' => $result->insertId])
->then(function (Result $result) use ($httpConnection) {
$httpConnection->send(respond_json(['user' => $result->rows[0]], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Server\Http\Controllers;
use App\Contracts\ConnectionManager;
use App\HttpServer\QueryParameters;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use stdClass;
use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
@@ -13,9 +16,21 @@ class ControlMessageController implements MessageComponentInterface
/** @var ConnectionManager */
protected $connectionManager;
public function __construct(ConnectionManager $connectionManager)
/** @var DatabaseInterface */
protected $database;
public function __construct(ConnectionManager $connectionManager, DatabaseInterface $database)
{
$this->connectionManager = $connectionManager;
$this->database = $database;
}
/**
* @inheritDoc
*/
function onOpen(ConnectionInterface $connection)
{
$this->verifyAuthToken($connection);
}
/**
@@ -80,14 +95,6 @@ class ControlMessageController implements MessageComponentInterface
]);
}
/**
* @inheritDoc
*/
function onOpen(ConnectionInterface $conn)
{
//
}
/**
* @inheritDoc
*/
@@ -95,4 +102,21 @@ class ControlMessageController implements MessageComponentInterface
{
//
}
protected function verifyAuthToken(ConnectionInterface $connection)
{
$authToken = QueryParameters::create($connection->httpRequest)->get('authToken');
$this->database
->query("SELECT * FROM users WHERE auth_token = :token", ['token' => $authToken])
->then(function (Result $result) use ($connection) {
if (count($result->rows) === 0) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => []
]));
$connection->close();
}
});
}
}

27
app/helpers.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
function expose_view_path(string $path = '')
{
return base_path('resources/views/' . $path);
}
function respond_json($responseData, int $statusCode = 200)
{
return str(new Response(
$statusCode,
['Content-Type' => 'application/json'],
json_encode($responseData)
));
}
function respond_html(string $html, int $statusCode = 200)
{
return str(new Response(
$statusCode,
['Content-Type' => 'text/html'],
$html
));
}