This commit is contained in:
Marcel Pociot
2021-05-21 17:20:48 +02:00
parent 9220e83798
commit 717e8cf05c
25 changed files with 585 additions and 128 deletions

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Client\Exceptions;
class InvalidServerProvided extends \Exception
{
public function __construct($server)
{
$message = "No such server {$server}.";
parent::__construct($message);
}
}

View File

@@ -2,13 +2,20 @@
namespace App\Commands; namespace App\Commands;
use App\Client\Exceptions\InvalidServerProvided;
use App\Logger\CliRequestLogger; use App\Logger\CliRequestLogger;
use Illuminate\Console\Parser; use Illuminate\Console\Parser;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command; use LaravelZero\Framework\Commands\Command;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
abstract class ServerAwareCommand extends Command abstract class ServerAwareCommand extends Command
{ {
const DEFAULT_HOSTNAME = 'sharedwithexpose.com';
const DEFAULT_PORT = 443;
const DEFAULT_SERVER_ENDPOINT = 'https://beyondco.de/api/expose/servers';
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
@@ -31,11 +38,81 @@ abstract class ServerAwareCommand extends Command
protected function getServerHost() protected function getServerHost()
{ {
return $this->option('server-host') ?? config('expose.servers.'.$this->option('server').'.host', 'localhost'); if ($this->option('server-host')) {
return $this->option('server-host');
}
/**
* Try to find the server in the servers array.
* If no array exists at all (when upgrading from v1),
* always return sharedwithexpose.com
*/
if (config('expose.servers') === null) {
return static::DEFAULT_HOSTNAME;
}
$server = $this->option('server');
$host = config('expose.servers.'.$server.'.host');
if (! is_null($host)) {
return $host;
}
return $this->lookupRemoteServerHost($server);
} }
protected function getServerPort() protected function getServerPort()
{ {
return $this->option('server-port') ?? config('expose.servers.'.$this->option('server').'.port', 8080); if ($this->option('server-port')) {
return $this->option('server-port');
}
/**
* Try to find the server in the servers array.
* If no array exists at all (when upgrading from v1),
* always return sharedwithexpose.com
*/
if (config('expose.servers') === null) {
return static::DEFAULT_PORT;
}
$server = $this->option('server');
$host = config('expose.servers.'.$server.'.port');
if (! is_null($host)) {
return $host;
}
return $this->lookupRemoteServerPort($server);
}
protected function lookupRemoteServers()
{
try {
return Http::get(config('expose.server_endpoint', static::DEFAULT_SERVER_ENDPOINT))->json();
} catch (\Throwable $e) {
return [];
}
}
protected function lookupRemoteServerHost($server)
{
$servers = $this->lookupRemoteServers();
$host = Arr::get($servers, $server.'.host');
throw_if(is_null($host), new InvalidServerProvided($server));
return $host;
}
protected function lookupRemoteServerPort($server)
{
$servers = $this->lookupRemoteServers();
$port = Arr::get($servers, $server.'.port');
throw_if(is_null($port), new InvalidServerProvided($server));
return $port;
} }
} }

View File

@@ -4,7 +4,7 @@ namespace App\Commands;
class ShareCurrentWorkingDirectoryCommand extends ShareCommand class ShareCurrentWorkingDirectoryCommand extends ShareCommand
{ {
protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=}'; protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=} {--dns=}';
public function handle() public function handle()
{ {

View File

@@ -10,11 +10,13 @@ interface UserRepository
public function getUserById($id): PromiseInterface; public function getUserById($id): PromiseInterface;
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface; public function paginateUsers(string $searchQuery, int $perPage, int $currentPage): PromiseInterface;
public function getUserByToken(string $authToken): PromiseInterface; public function getUserByToken(string $authToken): PromiseInterface;
public function storeUser(array $data): PromiseInterface; public function storeUser(array $data): PromiseInterface;
public function deleteUser($id): PromiseInterface; public function deleteUser($id): PromiseInterface;
public function getUsersByTokens(array $authTokens): PromiseInterface;
} }

View File

@@ -76,6 +76,7 @@ class TcpControlConnection extends ControlConnection
return [ return [
'type' => 'tcp', 'type' => 'tcp',
'port' => $this->port, 'port' => $this->port,
'auth_token' => $this->authToken,
'client_id' => $this->client_id, 'client_id' => $this->client_id,
'shared_port' => $this->shared_port, 'shared_port' => $this->shared_port,
'shared_at' => $this->shared_at, 'shared_at' => $this->shared_at,

View File

@@ -3,6 +3,7 @@
namespace App\Server\Http\Controllers\Admin; namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager; use App\Contracts\ConnectionManager;
use App\Contracts\UserRepository;
use App\Server\Configuration; use App\Server\Configuration;
use App\Server\Connections\ControlConnection; use App\Server\Connections\ControlConnection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,31 +11,54 @@ use Ratchet\ConnectionInterface;
class GetSitesController extends AdminController class GetSitesController extends AdminController
{ {
protected $keepConnectionOpen = true;
/** @var ConnectionManager */ /** @var ConnectionManager */
protected $connectionManager; protected $connectionManager;
/** @var Configuration */ /** @var Configuration */
protected $configuration; protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration) /** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration, UserRepository $userRepository)
{ {
$this->connectionManager = $connectionManager; $this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
} }
public function handle(Request $request, ConnectionInterface $httpConnection) public function handle(Request $request, ConnectionInterface $httpConnection)
{ {
$httpConnection->send( $authTokens = [];
respond_json([
'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; $sites = collect($this->connectionManager->getConnections())
})->values(), ->filter(function ($connection) {
]) return get_class($connection) === ControlConnection::class;
); })
->map(function ($site, $siteId) use (&$authTokens) {
$site = $site->toArray();
$site['id'] = $siteId;
$authTokens[] = $site['auth_token'];
return $site;
})->values();
$this->userRepository->getUsersByTokens($authTokens)
->then(function ($users) use ($httpConnection, $sites) {
$users = collect($users);
$sites = collect($sites)->map(function ($site) use ($users) {
$site['user'] = $users->firstWhere('auth_token', $site['auth_token']);
return $site;
})->toArray();
$httpConnection->send(
respond_json([
'sites' => $sites,
])
);
$httpConnection->close();
});
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Server\Http\Controllers\Admin; namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager; use App\Contracts\ConnectionManager;
use App\Contracts\UserRepository;
use App\Server\Configuration; use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection; use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,32 +11,54 @@ use Ratchet\ConnectionInterface;
class GetTcpConnectionsController extends AdminController class GetTcpConnectionsController extends AdminController
{ {
protected $keepConnectionOpen = true;
/** @var ConnectionManager */ /** @var ConnectionManager */
protected $connectionManager; protected $connectionManager;
/** @var Configuration */ /** @var Configuration */
protected $configuration; protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration) /** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration, UserRepository $userRepository)
{ {
$this->connectionManager = $connectionManager; $this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
} }
public function handle(Request $request, ConnectionInterface $httpConnection) public function handle(Request $request, ConnectionInterface $httpConnection)
{ {
$httpConnection->send( $authTokens = [];
respond_json([ $connections = collect($this->connectionManager->getConnections())
'tcp_connections' => collect($this->connectionManager->getConnections()) ->filter(function ($connection) {
->filter(function ($connection) { return get_class($connection) === TcpControlConnection::class;
return get_class($connection) === TcpControlConnection::class; })
}) ->map(function ($site, $siteId) use (&$authTokens) {
->map(function ($site, $siteId) { $site = $site->toArray();
$site = $site->toArray(); $site['id'] = $siteId;
$site['id'] = $siteId; $authTokens[] = $site['auth_token'];
return $site; return $site;
}) })
->values(), ->values();
])
); $this->userRepository->getUsersByTokens($authTokens)
->then(function ($users) use ($httpConnection, $connections) {
$users = collect($users);
$connections = collect($connections)->map(function ($connection) use ($users) {
$connection['user'] = $users->firstWhere('auth_token', $connection['auth_token']);
return $connection;
})->toArray();
$httpConnection->send(
respond_json([
'tcp_connections' => $connections,
])
);
$httpConnection->close();
});
} }
} }

View File

@@ -21,7 +21,7 @@ class GetUsersController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection) public function handle(Request $request, ConnectionInterface $httpConnection)
{ {
$this->userRepository $this->userRepository
->paginateUsers(20, (int) $request->get('page', 1)) ->paginateUsers($request->get('search', ''), (int) $request->get('perPage', 20), (int) $request->get('page', 1))
->then(function ($paginated) use ($httpConnection) { ->then(function ($paginated) use ($httpConnection) {
$httpConnection->send( $httpConnection->send(
respond_json(['paginated' => $paginated]) respond_json(['paginated' => $paginated])

View File

@@ -26,17 +26,6 @@ class ListSitesController extends AdminController
$sites = $this->getView($httpConnection, 'server.sites.index', [ $sites = $this->getView($httpConnection, 'server.sites.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http', 'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration, 'configuration' => $this->configuration,
'sites' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site;
})
->values(),
]); ]);
$httpConnection->send( $httpConnection->send(

View File

@@ -26,17 +26,6 @@ class ListTcpConnectionsController extends AdminController
$sites = $this->getView($httpConnection, 'server.tcp.index', [ $sites = $this->getView($httpConnection, 'server.tcp.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http', 'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration, '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( $httpConnection->send(

View File

@@ -21,7 +21,7 @@ class ListUsersController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection) public function handle(Request $request, ConnectionInterface $httpConnection)
{ {
$this->userRepository $this->userRepository
->paginateUsers(20, (int) $request->get('page', 1)) ->paginateUsers($request->get('search', ''), 20, (int) $request->get('page', 1))
->then(function ($paginated) use ($httpConnection) { ->then(function ($paginated) use ($httpConnection) {
$httpConnection->send( $httpConnection->send(
respond_html($this->getView($httpConnection, 'server.users.index', ['paginated' => $paginated])) respond_html($this->getView($httpConnection, 'server.users.index', ['paginated' => $paginated]))

View File

@@ -31,6 +31,14 @@ class StoreSettingsController extends AdminController
config()->set('expose.admin.messages.message_of_the_day', Arr::get($messages, 'message_of_the_day')); config()->set('expose.admin.messages.message_of_the_day', Arr::get($messages, 'message_of_the_day'));
config()->set('expose.admin.messages.custom_subdomain_unauthorized', Arr::get($messages, 'custom_subdomain_unauthorized'));
config()->set('expose.admin.messages.no_free_tcp_port_available', Arr::get($messages, 'no_free_tcp_port_available'));
config()->set('expose.admin.messages.tcp_port_sharing_unauthorized', Arr::get($messages, 'tcp_port_sharing_unauthorized'));
config()->set('expose.admin.messages.tcp_port_sharing_disabled', Arr::get($messages, 'tcp_port_sharing_disabled'));
$httpConnection->send( $httpConnection->send(
respond_json([ respond_json([
'configuration' => $this->configuration, 'configuration' => $this->configuration,

View File

@@ -275,6 +275,18 @@ class ControlMessageController implements MessageComponentInterface
protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user) protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user)
{ {
if (! config('expose.admin.allow_tcp_port_sharing', true)) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.tcp_port_sharing_disabled'),
],
]));
$connection->close();
return false;
}
if (! is_null($user) && $user['can_share_tcp_ports'] === 0) { if (! is_null($user) && $user['can_share_tcp_ports'] === 0) {
$connection->send(json_encode([ $connection->send(json_encode([
'event' => 'authenticationFailed', 'event' => 'authenticationFailed',

View File

@@ -36,34 +36,52 @@ class DatabaseUserRepository implements UserRepository
return $deferred->promise(); return $deferred->promise();
} }
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface public function paginateUsers(string $searchQuery, int $perPage, int $currentPage): PromiseInterface
{ {
$deferred = new Deferred(); $deferred = new Deferred();
$this->database $this->database
->query('SELECT * FROM users ORDER by created_at DESC LIMIT :limit OFFSET :offset', [ ->query('SELECT COUNT(*) AS count FROM users')
'limit' => $perPage + 1, ->then(function (Result $result) use ($searchQuery, $deferred, $perPage, $currentPage) {
'offset' => $currentPage < 2 ? 0 : ($currentPage - 1) * $perPage, $totalUsers = $result->rows[0]['count'];
])
->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) { $query = 'SELECT * FROM users ';
return $this->getUserDetails($user);
})->toArray();
$paginated = [ $bindings = [
'users' => $users, 'limit' => $perPage + 1,
'current_page' => $currentPage, 'offset' => $currentPage < 2 ? 0 : ($currentPage - 1) * $perPage,
'per_page' => $perPage,
'next_page' => $nextPage ?? null,
'previous_page' => $currentPage > 1 ? $currentPage - 1 : null,
]; ];
$deferred->resolve($paginated); if ($searchQuery !== '') {
$query .= "WHERE name LIKE '%".$searchQuery."%' ";
$bindings['search'] = $searchQuery;
}
$query .= ' ORDER by created_at DESC LIMIT :limit OFFSET :offset';
$this->database
->query($query, $bindings)
->then(function (Result $result) use ($deferred, $perPage, $currentPage, $totalUsers) {
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 = [
'total' => $totalUsers,
'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(); return $deferred->promise();
@@ -138,4 +156,25 @@ class DatabaseUserRepository implements UserRepository
return $deferred->promise(); return $deferred->promise();
} }
public function getUsersByTokens(array $authTokens): PromiseInterface
{
$deferred = new Deferred();
$authTokenString = collect($authTokens)->map(function ($token) {
return '"'.$token.'"';
})->join(',');
$this->database->query('SELECT * FROM users WHERE auth_token IN ('.$authTokenString.')')
->then(function (Result $result) use ($deferred) {
$users = collect($result->rows)->map(function ($user) {
return $this->getUserDetails($user);
})->toArray();
$deferred->resolve($users);
});
return $deferred->promise();
}
} }

Binary file not shown.

View File

@@ -26,7 +26,7 @@ return [
| |
*/ */
'version' => '1.4.2', 'version' => '2.0.0-beta',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -19,6 +19,21 @@ return [
], ],
], ],
/*
|--------------------------------------------------------------------------
| Server Endpoint
|--------------------------------------------------------------------------
|
| When you specify a server that does not exist in above static array,
| Expose will perform a GET request to this URL and tries to retrieve
| a JSON payload that looks like the configurations servers array.
|
| Expose then tries to load the configuration for the given server
| if available.
|
*/
'server_endpoint' => 'https://beyondco.de/api/expose/servers',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| DNS | DNS
@@ -165,6 +180,19 @@ return [
*/ */
'validate_auth_tokens' => false, 'validate_auth_tokens' => false,
/*
|--------------------------------------------------------------------------
| TCP Port Sharing
|--------------------------------------------------------------------------
|
| Control if you want to allow users to share TCP ports with your Expose
| server. You can add fine-grained control per authentication token,
| but if you want to disable TCP port sharing in general, set this
| value to false.
|
*/
'allow_tcp_port_sharing' => true,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| TCP Port Range | TCP Port Range
@@ -281,6 +309,8 @@ return [
'tcp_port_sharing_unauthorized' => 'You are not allowed to share TCP ports. Please upgrade to Expose Pro.', 'tcp_port_sharing_unauthorized' => 'You are not allowed to share TCP ports. Please upgrade to Expose Pro.',
'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.', 'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.',
'tcp_port_sharing_disabled' => 'TCP port sharing is not available on this Expose server.',
], ],
], ],
]; ];

View File

@@ -5,7 +5,7 @@
<script src="https://unpkg.com/tailwindcss-jit-cdn"></script> <script src="https://unpkg.com/tailwindcss-jit-cdn"></script>
<script type="tailwind-config"> <script type="tailwind-config">
{ {
"darkMode": "media", "darkMode": "class",
"theme": { "theme": {
"extend": { "extend": {
"colors": { "colors": {
@@ -119,11 +119,34 @@
<div class="pt-8 flex flex-col md:flex-row"> <div class="pt-8 flex flex-col md:flex-row">
<div class="w-full md:w-1/3 flex flex-col mr-5"> <div class="w-full md:w-1/3 flex flex-col mr-5">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> <div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="flex items-center pb-4"> <div class="flex items-center pb-4 justify-between">
<span
@click.prevent="useDarkMode = !useDarkMode"
class="mr-3 cursor-pointer" id="annual-billing-label">
<span class="text-sm font-medium dark:text-gray-200 text-gray-900">Dark-Mode:</span>
</span>
<button type="button"
:class="{
'bg-pink-500': useDarkMode,
'dark:bg-gray-700 bg-gray-200': ! useDarkMode,
}"
@click.prevent="useDarkMode = !useDarkMode"
class=" relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500" role="switch" aria-checked="false" aria-labelledby="annual-billing-label">
<span class="sr-only">Use Dark Mode</span>
<!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" -->
<span aria-hidden="true"
:class="{
'translate-x-5': useDarkMode,
'translate-x-0': ! useDarkMode,
}"
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button>
</div>
<div class="flex items-center pb-4 justify-between">
<span <span
@click.prevent="followLogs = !followLogs" @click.prevent="followLogs = !followLogs"
class="mr-3 cursor-pointer" id="annual-billing-label"> class="mr-3 cursor-pointer" id="annual-billing-label">
<span class="text-sm font-medium dark:text-gray-200 text-gray-900">Follow requests:</span> <span class="text-sm font-medium dark:text-gray-200 text-gray-900">Follow Requests:</span>
</span> </span>
<button type="button" <button type="button"
:class="{ :class="{
@@ -132,14 +155,14 @@
}" }"
@click.prevent="followLogs = !followLogs" @click.prevent="followLogs = !followLogs"
class=" relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500" role="switch" aria-checked="false" aria-labelledby="annual-billing-label"> class=" relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500" role="switch" aria-checked="false" aria-labelledby="annual-billing-label">
<span class="sr-only">Use setting</span> <span class="sr-only">Follow Request</span>
<!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" --> <!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" -->
<span aria-hidden="true" <span aria-hidden="true"
:class="{ :class="{
'translate-x-5': followLogs, 'translate-x-5': followLogs,
'translate-x-0': ! followLogs, 'translate-x-0': ! followLogs,
}" }"
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span> class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
</button> </button>
</div> </div>
<div class="flex mb-4"> <div class="flex mb-4">
@@ -176,7 +199,7 @@
<tbody class="bg-white dark:bg-gray-700"> <tbody class="bg-white dark:bg-gray-700">
<tr v-for="log in filteredLogs" <tr v-for="log in filteredLogs"
:class="{ :class="{
'dark:bg-pink-500 dark:bg-opacity-25 bg-gray-100': currentLog === log, 'dark:bg-pink-500 dark:bg-opacity-25 bg-pink-500 bg-opacity-25': currentLog === log,
'even:bg-gray-50 dark:even:bg-gray-800': currentLog !== log 'even:bg-gray-50 dark:even:bg-gray-800': currentLog !== log
}" }"
@click="setLog(log.id)"> @click="setLog(log.id)">
@@ -477,6 +500,20 @@
maxLogs: {{ max_logs }}, maxLogs: {{ max_logs }},
highlightNextLog: false, highlightNextLog: false,
followLogs: true, followLogs: true,
useDarkMode: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches,
},
watch: {
useDarkMode: {
handler: function(value) {
if (value) {
document.querySelector('html').classList.add('dark')
} else {
document.querySelector('html').classList.remove('dark')
}
},
immediate: true,
}
}, },
computed: { computed: {

View File

@@ -2,17 +2,37 @@
<head> <head>
<title>Expose</title> <title>Expose</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css"> <script src="https://unpkg.com/tailwindcss-jit-cdn"></script>
<script type="tailwind-config">
{
"darkMode": "class",
"theme": {
"extend": {
"colors": {
"dark-blue-800": "#ff9900"
}
}
}
}
</script>
<style type="postcss">
::selection {
@apply bg-pink-500 bg-opacity-50;
}
</style>
</head> </head>
<body> <body>
<div class="min-h-screen bg-white" id="app"> <div class="min-h-screen bg-white">
<nav class="bg-white border-b border-gray-200"> <nav class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex"> <div class="flex items-center">
<div class="flex-shrink-0 flex items-center font-bold"> <div class="flex-shrink-0 flex items-center font-bold">
Expose <a href="https://expose.beyondco.de" target="_blank" class="inline-flex items-center self-start">
<img src="https://beyondco.de/apps/icons/expose.png" class="h-12">
<p class="ml-4 font-headline text-lg">Expose</p>
</a>
</div> </div>
<div class="hidden sm:-my-px sm:ml-6 sm:flex"> <div class="hidden sm:-my-px sm:ml-6 sm:flex">
<a href="/users" <a href="/users"
@@ -60,6 +80,38 @@
</div> </div>
</div> </div>
</nav> </nav>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8" id="stats">
<div class="py-8">
<dl class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Total Users
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
@{ users.total }
</dd>
</div>
<div class="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Shared Sites
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
@{ sites.length }
</dd>
</div>
<div class="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Shared TCP connections
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
@{ tcp_connections.length }
</dd>
</div>
</dl>
</div>
</div>
<div class="py-10"> <div class="py-10">
<header> <header>
@@ -70,12 +122,60 @@
</div> </div>
</header> </header>
<main> <main>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8" id="app">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main> </main>
</div> </div>
</div> </div>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<script>
new Vue({
el: '#stats',
delimiters: ['@{', '}'],
data: {
users: [],
sites: [],
tcp_connections: [],
},
methods: {
getUsers(page) {
fetch('/api/users')
.then((response) => {
return response.json();
}).then((data) => {
this.users = data.paginated;
});
},
getConnections() {
fetch('/api/tcp')
.then((response) => {
return response.json();
}).then((data) => {
this.tcp_connections = data.tcp_connections;
});
},
getSites() {
fetch('/api/sites')
.then((response) => {
return response.json();
}).then((data) => {
this.sites = data.sites;
});
},
},
mounted() {
this.getUsers();
this.getConnections();
this.getSites();
}
})
</script>
</body> </body>
</html> </html>

View File

@@ -49,7 +49,7 @@
<div class="max-w-lg flex rounded-md shadow-sm"> <div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="motd" name="motd" rows="3" <textarea id="motd" name="motd" rows="3"
v-model="configuration.messages.message_of_the_day" v-model="configuration.messages.message_of_the_day"
class="form-textarea block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea> class="form-textarea border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div> </div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when a <p class="mt-2 text-sm text-gray-500">This message will be shown when a
successful connection can be established.</p> successful connection can be established.</p>
@@ -66,7 +66,7 @@
<div class="max-w-lg flex rounded-md shadow-sm"> <div class="max-w-lg flex rounded-md shadow-sm">
<input type="number" id="maximum_connection_length" name="maximum_connection_length" <input type="number" id="maximum_connection_length" name="maximum_connection_length"
v-model="configuration.maximum_connection_length" v-model="configuration.maximum_connection_length"
class="form-textarea block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5" /> class="form-input border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5" />
</div> </div>
<p class="mt-2 text-sm text-gray-500">The amount of minutes that clients may be connected to this expose server. 0 means there is no limit.</p> <p class="mt-2 text-sm text-gray-500">The amount of minutes that clients may be connected to this expose server. 0 means there is no limit.</p>
</div> </div>
@@ -84,7 +84,7 @@
name="invalid_auth_token" name="invalid_auth_token"
rows="3" rows="3"
v-model="configuration.messages.invalid_auth_token" v-model="configuration.messages.invalid_auth_token"
class="form-textarea block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea> class="form-input border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div> </div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when a <p class="mt-2 text-sm text-gray-500">This message will be shown when a
user tries to connect with an invalid authentication token.</p> user tries to connect with an invalid authentication token.</p>
@@ -101,7 +101,7 @@
<div class="max-w-lg flex rounded-md shadow-sm"> <div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="subdomain_taken" name="subdomain_taken" rows="3" <textarea id="subdomain_taken" name="subdomain_taken" rows="3"
v-model="configuration.messages.subdomain_taken" v-model="configuration.messages.subdomain_taken"
class="form-textarea block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea> class="border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div> </div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when a <p class="mt-2 text-sm text-gray-500">This message will be shown when a
user tries to connect with an already registered subdomain. You can use user tries to connect with an already registered subdomain. You can use
@@ -109,6 +109,70 @@
a placeholder.</p> a placeholder.</p>
</div> </div>
</div> </div>
<div
class="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="motd"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Custom Subdomain Unauthorized
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="motd" name="motd" rows="3"
v-model="configuration.messages.custom_subdomain_unauthorized"
class="form-textarea border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when a user tries to use a custom subdomain, but is not allowed to.</p>
</div>
</div>
<div
class="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="motd"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
TCP Port Sharing Unauthorized
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="motd" name="motd" rows="3"
v-model="configuration.messages.tcp_port_sharing_unauthorized"
class="form-textarea border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when a user tries to use share a TCP port, but is not allowed to.</p>
</div>
</div>
<div
class="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="motd"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
No free TCP port available
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="motd" name="motd" rows="3"
v-model="configuration.messages.no_free_tcp_port_available"
class="form-textarea border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when no TCP port can be allocated.</p>
</div>
</div>
<div
class="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="motd"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
TCP Port Sharing Disabled
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<textarea id="motd" name="motd" rows="3"
v-model="configuration.messages.tcp_port_sharing_disabled"
class="form-textarea border-gray-300 rounded-md block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5"></textarea>
</div>
<p class="mt-2 text-sm text-gray-500">This message will be shown when TCP port sharing is not allowed.</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,9 @@
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Subdomain Subdomain
</th> </th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Shared At Shared At
</th> </th>
@@ -28,6 +31,9 @@
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ site.subdomain }.{{ configuration.hostname()}}:{{ configuration.port() }} @{ site.subdomain }.{{ configuration.hostname()}}:{{ configuration.port() }}
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ site.user?.name }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ site.shared_at } @{ site.shared_at }
</td> </td>
@@ -56,10 +62,18 @@
delimiters: ['@{', '}'], delimiters: ['@{', '}'],
data: { data: {
sites: {{ sites|json_encode|raw }} sites: []
}, },
methods: { methods: {
getSites() {
fetch('/api/sites')
.then((response) => {
return response.json();
}).then((data) => {
this.sites = data.sites;
});
},
disconnectSite(id) { disconnectSite(id) {
fetch('/api/sites/' + id, { fetch('/api/sites/' + id, {
method: 'DELETE', method: 'DELETE',
@@ -69,6 +83,10 @@
this.sites = data.sites; this.sites = data.sites;
}); });
} }
},
mounted() {
this.getSites();
} }
}) })
</script> </script>

View File

@@ -5,7 +5,7 @@
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> <div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div <div
class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200"> 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"> <table class="min-w-full" v-if="tcp_connections.length > 0">
<thead> <thead>
<tr> <tr>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
@@ -14,6 +14,9 @@
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Expose Port Expose Port
</th> </th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider"> <th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Shared At Shared At
</th> </th>
@@ -21,13 +24,16 @@
</tr> </tr>
</thead> </thead>
<tbody class="bg-white"> <tbody class="bg-white">
<tr v-for="connection in connections"> <tr v-for="connection in tcp_connections">
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 font-medium text-gray-900"> <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 } @{ connection.port }
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ connection.shared_port } @{ connection.shared_port }
</td> </td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ connection.user?.name }
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500"> <td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{ connection.shared_at } @{ connection.shared_at }
</td> </td>
@@ -54,19 +60,32 @@
delimiters: ['@{', '}'], delimiters: ['@{', '}'],
data: { data: {
connections: {{ connections|json_encode|raw }} tcp_connections: []
}, },
methods: { methods: {
getConnections() {
fetch('/api/tcp')
.then((response) => {
return response.json();
}).then((data) => {
this.tcp_connections = data.tcp_connections;
});
},
disconnectConnection(id) { disconnectConnection(id) {
fetch('/api/tcp/' + id, { fetch('/api/tcp/' + id, {
method: 'DELETE', method: 'DELETE',
}).then((response) => { }).then((response) => {
return response.json(); return response.json();
}).then((data) => { }).then((data) => {
this.connections = data.connections; this.tcp_connections = data.tcp_connections;
}); });
} }
},
mounted() {
this.getConnections();
} }
}) })
</script> </script>

View File

@@ -19,8 +19,9 @@
<div class="mt-1 sm:mt-0 sm:col-span-2"> <div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm"> <div class="max-w-lg flex rounded-md shadow-sm">
<input id="username" <input id="username"
type="text"
v-model="userForm.name" v-model="userForm.name"
class="flex-1 form-input block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/> class="flex-1 border-gray-300 block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/>
</div> </div>
</div> </div>
</div> </div>
@@ -78,6 +79,15 @@
</div> </div>
</form> </form>
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> <div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="max-w-lg flex rounded-md shadow-sm mb-8">
<input id="search"
type="text"
v-model="search"
autocomplete="off"
placeholder="Search users"
class="flex-1 border-gray-300 block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/>
</div>
<div <div
class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200"> 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="users.length > 0"> <table class="min-w-full" v-if="users.length > 0">
@@ -171,6 +181,7 @@
delimiters: ['@{', '}'], delimiters: ['@{', '}'],
data: { data: {
search: '',
userForm: { userForm: {
name: '', name: '',
can_specify_subdomains: true, can_specify_subdomains: true,
@@ -181,14 +192,26 @@
}, },
computed: { computed: {
total : function() {
return this.paginated.total;
},
users : function() { users : function() {
return this.paginated.users; return this.paginated.users;
} }
}, },
watch: {
search(val) {
if (val.length < 3) {
return;
}
this.getUsers(1);
}
},
methods: { methods: {
getUsers(page) { getUsers(page) {
fetch('/api/users?page=' + page) fetch('/api/users?search='+this.search+'&page=' + page)
.then((response) => { .then((response) => {
return response.json(); return response.json();
}).then((data) => { }).then((data) => {

View File

@@ -143,30 +143,6 @@ class AdminTest extends TestCase
$this->assertTrue(Str::contains($body, 'Marcel')); $this->assertTrue(Str::contains($body, 'Marcel'));
} }
/** @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.text', 'fixed-subdomain', $connection);
/** @var Response $response */
$response = $this->await($this->browser->get('http://127.0.0.1:8080/sites', [
'Host' => 'expose.localhost',
'Authorization' => base64_encode('username:secret'),
'Content-Type' => 'application/json',
]));
$body = $response->getBody()->getContents();
$this->assertTrue(Str::contains($body, 'some-host.text'));
$this->assertTrue(Str::contains($body, 'fixed-subdomain'));
}
protected function startServer() protected function startServer()
{ {
$this->app['config']['expose.admin.subdomain'] = 'expose'; $this->app['config']['expose.admin.subdomain'] = 'expose';

View File

@@ -115,6 +115,19 @@ class TunnelTest extends TestCase
$this->assertInstanceOf(Connection::class, $connection); $this->assertInstanceOf(Connection::class, $connection);
} }
/** @test */
public function it_rejects_tcp_sharing_if_disabled()
{
$this->createTestTcpServer();
$this->app['config']['expose.admin.allow_tcp_port_sharing'] = false;
$this->expectException(\UnexpectedValueException::class);
$client = $this->createClient();
$this->await($client->connectToServerAndShareTcp(8085));
}
/** @test */ /** @test */
public function it_rejects_tcp_sharing_if_forbidden() public function it_rejects_tcp_sharing_if_forbidden()
{ {