Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/broadcasting.connections.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'),
'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'),
'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900),

// Environment fallbacks for the admin socket settings (Configure -> Socket). The
// socket server reads these itself; the API reads them only to report the effective
// values. Blank means "the socket server's own default".
'auth_mode' => env('SOCKETCLUSTER_AUTH_MODE'),
'socket_options' => env('SOCKETCLUSTER_OPTIONS'),
],

// for apple apn
Expand Down
57 changes: 55 additions & 2 deletions src/Http/Controllers/Internal/v1/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
use Fleetbase\Services\OAuth\OAuthFlowService;
use Fleetbase\Services\SmsService;
use Fleetbase\Support\PlatformApi;
use Fleetbase\Support\SocketCluster\SocketClusterService;
use Fleetbase\Support\SocketCluster\SocketSettings;
use Fleetbase\Support\SocketCluster\SocketToken;
use Fleetbase\Support\Utils;
use Fleetbase\Twilio\Manager as TwilioManager;
use Fleetbase\Twilio\Support\Laravel\Facade as TwilioFacade;
Expand Down Expand Up @@ -1052,7 +1055,7 @@ public function testSentryConfig(AdminRequest $request)
public function testSocketcluster(AdminRequest $request)
{
$userUuid = session('user');
$scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled();
$scoped = SocketToken::enabled();

if ($scoped && (!is_string($userUuid) || $userUuid === '')) {
return response()->json([
Expand All @@ -1070,7 +1073,7 @@ public function testSocketcluster(AdminRequest $request)
$sent = false;
$response = null;

$socketClusterClient = new \Fleetbase\Support\SocketCluster\SocketClusterService();
$socketClusterClient = new SocketClusterService();

try {
$sent = $socketClusterClient->send($channel, [
Expand Down Expand Up @@ -1103,6 +1106,56 @@ public function testSocketcluster(AdminRequest $request)
);
}

/**
* Socket server settings: the effective values with their sources, and the socket
* server's live status.
*
* The auth key is never part of the response; `settings.key_configured` says whether it is set.
*/
public function getSocketSettings(AdminRequest $request)
{
return response()->json($this->socketSettingsPayload());
}

/**
* Save any subset of the socket settings; a null value clears it back to the environment.
*
* Switching socket authentication on needs SOCKETCLUSTER_AUTH_KEY, which can only be set in
* the environment, so it is refused (422 `key_missing`) without one.
*/
public function saveSocketSettings(AdminRequest $request)
{
$validated = $request->validate(SocketSettings::rules());

if (($validated['enabled'] ?? null) !== null && filter_var($validated['enabled'], FILTER_VALIDATE_BOOLEAN) && SocketToken::key() === null) {
return response()->error('Socket authentication cannot be switched on until SOCKETCLUSTER_AUTH_KEY is set on the API and the socket server.', 422, ['code' => 'key_missing']);
}

$change = SocketSettings::store($validated);

activity('settings')
->causedBy($request->user())
->event('socket_settings_updated')
->withProperties(['fields' => array_keys(array_intersect_key($validated, array_flip(SocketSettings::FIELDS))), 'old' => $change['before'], 'new' => $change['after']])
->log('Socket settings updated');

return response()->json($this->socketSettingsPayload());
}

/**
* @return array{settings: array, status: ?array, status_error: ?string}
*/
protected function socketSettingsPayload(): array
{
$stats = SocketClusterService::stats();

return [
'settings' => SocketSettings::effective(),
'status' => $stats['status'],
'status_error' => $stats['error'],
];
}

/**
* OAuth configuration, as an administrator should see it.
*
Expand Down
23 changes: 21 additions & 2 deletions src/Http/Controllers/SocketAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
use Fleetbase\Support\SocketCluster\ChannelDecision;
use Fleetbase\Support\SocketCluster\SocketChannelRegistry;
use Fleetbase\Support\SocketCluster\SocketPrincipal;
use Fleetbase\Support\SocketCluster\SocketSettings;
use Fleetbase\Support\SocketCluster\SocketToken;
use Fleetbase\Support\Utils;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;

/**
* Socket token minting for console, API and platform clients, and the channel authorize
* endpoint the socket server calls. Every action answers 404 while SOCKETCLUSTER_AUTH_KEY
* is unset, which clients read as "connect anonymously".
* and config endpoints the socket server calls. Every action answers 404 while
* SOCKETCLUSTER_AUTH_KEY is unset, which clients read as "connect anonymously".
*/
class SocketAuthController extends Controller
{
Expand Down Expand Up @@ -106,6 +107,24 @@ public function authorizeChannel(Request $request)
return response()->json($decision->toArray());
}

/**
* GET int/v1/socket/config: the effective socket settings the socket server applies at runtime.
*
* Signed with the authorize key and answered while the switch is off, so the socket
* server learns when an administrator switches socket authentication on.
*/
public function config(Request $request)
{
$settings = SocketSettings::effective();

return response()->json([
'enabled' => $settings['enabled'],
'mode' => $settings['mode'],
'origins' => $settings['origins'],
'updated_at' => SocketSettings::updatedAt(),
]);
}

protected static function disabled()
{
return response()->json(['error' => 'Not Found'], 404);
Expand Down
17 changes: 12 additions & 5 deletions src/Http/Middleware/VerifySocketSignature.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,29 @@
/**
* Admits only requests the socket server signed with the derived authorize key.
*
* Used instead of session or token auth on the channel authorize endpoint, which only the
* socket server calls.
* Used instead of session or token auth on the endpoints only the socket server calls. They
* answer 404 while socket authentication is off; with the `key-only` parameter
* (`VerifySocketSignature:key-only`) a configured key is enough, whatever the switch.
*/
class VerifySocketSignature
{
public function handle(Request $request, \Closure $next)
public const KEY_ONLY = 'key-only';

public function handle(Request $request, \Closure $next, ?string $requirement = null)
{
if (!SocketToken::enabled()) {
$requireSwitch = $requirement !== self::KEY_ONLY;
$configured = $requireSwitch ? SocketToken::enabled() : SocketToken::key() !== null;

if (!$configured) {
return response()->json(['error' => 'Not Found'], 404);
}

$valid = SocketSignature::verify(
SocketSignature::AUTHORIZE,
$request->header(SocketSignature::HEADER_TIMESTAMP),
$request->header(SocketSignature::HEADER_SIGNATURE),
$request->getContent()
$request->getContent(),
$requireSwitch
);

if (!$valid) {
Expand Down
58 changes: 57 additions & 1 deletion src/Support/SocketCluster/SocketClusterService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Support\SocketCluster;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use WebSocket\Client;

Expand Down Expand Up @@ -233,14 +234,61 @@ public static function publishesOverHttp(): bool
* The socket server's internal publish endpoint.
*/
public static function publishUrl(): string
{
return static::internalUrl('/publish');
}

/**
* A path on the socket server's internal listener (SOCKETCLUSTER_PUBLISH_URL).
*/
public static function internalUrl(string $path): string
{
$base = config('broadcasting.connections.socketcluster.publish_url');

if (!is_string($base) || $base === '') {
$base = 'http://' . config('broadcasting.connections.socketcluster.options.host', 'socket') . ':8001';
}

return rtrim($base, '/') . '/publish';
return rtrim($base, '/') . $path;
}

/**
* The socket server's counters from its internal `/stats` endpoint, signed with the publish key.
*
* Never throws: when the key is not configured or the server cannot be reached within two
* seconds, `status` is null and `error` gives a short reason.
*
* @return array{status: ?array, error: ?string}
*/
public static function stats(): array
{
if (SocketToken::key() === null) {
return ['status' => null, 'error' => 'SOCKETCLUSTER_AUTH_KEY is not configured.'];
}

try {
$response = Http::withHeaders(SocketSignature::headers(SocketSignature::PUBLISH, ''))
->acceptJson()
->connectTimeout(2)
->timeout(2)
->get(static::internalUrl('/stats'));
} catch (ConnectionException $e) {
return ['status' => null, 'error' => 'The socket server could not be reached or did not answer within 2 seconds.'];
} catch (\Throwable $e) {
return ['status' => null, 'error' => 'The socket server status request failed.'];
}

if (!$response->successful()) {
return ['status' => null, 'error' => 'The socket server answered with HTTP status ' . $response->status() . '.'];
}

$status = $response->json();

if (!is_array($status)) {
return ['status' => null, 'error' => 'The socket server returned an invalid status response.'];
}

return ['status' => $status, 'error' => null];
}

/**
Expand Down Expand Up @@ -340,11 +388,19 @@ public function getOption(string $key, $default = null)
/**
* Get the connection options.
*
* The handshake Origin is the effective publisher origin (the `system.socket.publisher_origin`
* setting, else SOCKETCLUSTER_ORIGIN); options passed in still take precedence.
*
* @param array|string $options connection options or URI
*/
public function getOptions($options): array
{
$defaultConnectionOptions = config('broadcasting.connections.socketcluster.options', []);
$publisherOrigin = SocketSettings::effective()['publisher_origin'];

if ($publisherOrigin !== null) {
$defaultConnectionOptions['headers'] = array_merge((array) ($defaultConnectionOptions['headers'] ?? []), ['Origin' => $publisherOrigin]);
}

if (is_string($options)) {
$options = parse_url($options);
Expand Down
Loading
Loading