fix: harden IPC callbacks and decouple auth lookup via configurable resolver

This commit is contained in:
Fabian @ Blax Software 2026-04-17 11:03:02 +02:00
parent ed371ac051
commit 2ad8d490b7
6 changed files with 138 additions and 29 deletions

View File

@ -39,6 +39,22 @@ return [
*/ */
'introspection' => env('WEBSOCKET_INTROSPECTION', false), 'introspection' => env('WEBSOCKET_INTROSPECTION', false),
/*
|--------------------------------------------------------------------------
| Auth Resolver
|--------------------------------------------------------------------------
|
| Callable that receives the `authtoken` string from an incoming message and
| returns either a user model (Authenticatable) or null. Used by Controller
| self-heal when `need_auth = true` and the connection has no user yet.
|
| Defaults to Laravel Sanctum lookup when Sanctum is installed. Applications
| can supply their own by binding `websockets.auth_resolver` in the container
| or by setting this to a `[Class::class, 'method']` / Closure reference.
|
*/
'auth_resolver' => null,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Max Concurrent Children (Fork Limit) | Max Concurrent Children (Fork Limit)

View File

@ -146,7 +146,7 @@ class Logger
try { try {
$channel = config('logging.channels.websocket') ? 'websocket' : null; $channel = config('logging.channels.websocket') ? 'websocket' : null;
Log::channel($channel)->log($logLevel, '[WebSocket] '.$message); Log::channel($channel)->log($logLevel, '[WebSocket] ' . $message);
} catch (\Throwable) { } catch (\Throwable) {
// Logging must never crash the WS server // Logging must never crash the WS server
} }

View File

@ -88,7 +88,7 @@ class WebSocketHandler implements MessageComponentInterface
public function onMessage(ConnectionInterface $connection, MessageInterface $message) public function onMessage(ConnectionInterface $connection, MessageInterface $message)
{ {
if (! isset($connection->app)) { if (! isset($connection->app)) {
$this->wsLog('warning', 'Message dropped: connection has no app (likely failed auth). Payload: '.Str::limit($message->getPayload(), 200)); $this->wsLog('warning', 'Message dropped: connection has no app (likely failed auth). Payload: ' . Str::limit($message->getPayload(), 200));
return; return;
} }
@ -129,7 +129,10 @@ class WebSocketHandler implements MessageComponentInterface
$ch = $this->channelManager->find($connection->app->id, $channel); $ch = $this->channelManager->find($connection->app->id, $channel);
if ($ch) { if ($ch) {
$ch->broadcastToEveryoneExcept( $ch->broadcastToEveryoneExcept(
$payload, $connection->socketId, $connection->app->id, false $payload,
$connection->socketId,
$connection->app->id,
false
); );
} }
} }
@ -210,7 +213,7 @@ class WebSocketHandler implements MessageComponentInterface
App::findByKey($appKey) App::findByKey($appKey)
->then(function ($app) use ($appKey, $connection, $deferred) { ->then(function ($app) use ($appKey, $connection, $deferred) {
if (! $app) { if (! $app) {
$this->wsLog('error', "Unknown app key: '{$appKey}'. Check that PUSHER_APP_KEY in .env matches the key used by the frontend. Configured apps: ".implode(', ', array_map(fn ($a) => $a['key'] ?? 'null', config('websockets.apps', [])))); $this->wsLog('error', "Unknown app key: '{$appKey}'. Check that PUSHER_APP_KEY in .env matches the key used by the frontend. Configured apps: " . implode(', ', array_map(fn($a) => $a['key'] ?? 'null', config('websockets.apps', []))));
$deferred->reject(new Exceptions\UnknownAppKey($appKey)); $deferred->reject(new Exceptions\UnknownAppKey($appKey));
} }
@ -325,7 +328,7 @@ class WebSocketHandler implements MessageComponentInterface
{ {
try { try {
$channel = config('logging.channels.websocket') ? 'websocket' : config('logging.default'); $channel = config('logging.channels.websocket') ? 'websocket' : config('logging.default');
Log::channel($channel)->log($level, '[WebSocket] '.$message); Log::channel($channel)->log($level, '[WebSocket] ' . $message);
} catch (\Throwable) { } catch (\Throwable) {
// Logging must never break the server // Logging must never break the server
} }

View File

@ -10,7 +10,6 @@ use BlaxSoftware\LaravelWebSockets\Channels\PresenceChannel;
use BlaxSoftware\LaravelWebSockets\Channels\PrivateChannel; use BlaxSoftware\LaravelWebSockets\Channels\PrivateChannel;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\PersonalAccessToken;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
class Controller class Controller
@ -98,9 +97,9 @@ class Controller
$authtoken = @$message['data']['authtoken'] ?? null; $authtoken = @$message['data']['authtoken'] ?? null;
if ($authtoken) { if ($authtoken) {
try { try {
$tokenRecord = PersonalAccessToken::findToken($authtoken); $resolved = self::resolveUserFromToken($authtoken);
if ($tokenRecord?->tokenable) { if ($resolved) {
$connection->user = $tokenRecord->tokenable; $connection->user = $resolved;
Auth::login($connection->user); Auth::login($connection->user);
// Clear parent's stale auth cache so it re-authenticates // Clear parent's stale auth cache so it re-authenticates
if ($connection instanceof MockConnectionSocketPair) { if ($connection instanceof MockConnectionSocketPair) {
@ -166,6 +165,48 @@ class Controller
} }
} }
/**
* Resolve a user from an authtoken string. First tries the configured
* `websockets.auth_resolver` callable; falls back to Laravel Sanctum's
* `PersonalAccessToken::findToken()` if the class exists.
*
* Returns an Authenticatable user or null.
*/
protected static function resolveUserFromToken(string $authtoken)
{
// 1. Configured resolver (closure or [Class, method])
$resolver = config('websockets.auth_resolver');
if ($resolver && is_callable($resolver)) {
$user = $resolver($authtoken);
if ($user) {
return $user;
}
}
// 2. Container binding (useful for class-based resolvers)
if (app()->bound('websockets.auth_resolver')) {
$bound = app('websockets.auth_resolver');
if (is_callable($bound)) {
$user = $bound($authtoken);
if ($user) {
return $user;
}
}
}
// 3. Fallback to Sanctum if available (string class name to avoid
// autoload errors when the package isn't installed)
$sanctumClass = 'Laravel\\Sanctum\\PersonalAccessToken';
if (class_exists($sanctumClass)) {
$tokenRecord = $sanctumClass::findToken($authtoken);
if ($tokenRecord?->tokenable) {
return $tokenRecord->tokenable;
}
}
return null;
}
final public function progress( final public function progress(
mixed $payload = null, mixed $payload = null,
?string $event = null, ?string $event = null,

View File

@ -105,11 +105,11 @@ class ControllerResolver
private static function resolveWithHotReload(string $eventPrefix): ?string private static function resolveWithHotReload(string $eventPrefix): ?string
{ {
$directName = self::kebabToPascal($eventPrefix) . 'Controller'; $directName = self::kebabToPascal($eventPrefix) . 'Controller';
// Try app namespace first // Try app namespace first
$appClass = self::APP_NAMESPACE . $directName; $appClass = self::APP_NAMESPACE . $directName;
$appFile = self::getControllerFilePath($appClass); $appFile = self::getControllerFilePath($appClass);
if ($appFile && file_exists($appFile)) { if ($appFile && file_exists($appFile)) {
self::invalidateAndReload($appFile); self::invalidateAndReload($appFile);
if (class_exists($appClass, true)) { if (class_exists($appClass, true)) {
@ -120,7 +120,7 @@ class ControllerResolver
// Try vendor namespace // Try vendor namespace
$vendorClass = self::VENDOR_NAMESPACE . $directName; $vendorClass = self::VENDOR_NAMESPACE . $directName;
$vendorFile = self::getControllerFilePath($vendorClass); $vendorFile = self::getControllerFilePath($vendorClass);
if ($vendorFile && file_exists($vendorFile)) { if ($vendorFile && file_exists($vendorFile)) {
self::invalidateAndReload($vendorFile); self::invalidateAndReload($vendorFile);
if (class_exists($vendorClass, true)) { if (class_exists($vendorClass, true)) {
@ -141,7 +141,7 @@ class ControllerResolver
// Try app namespace with folder // Try app namespace with folder
$appClass = self::APP_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name; $appClass = self::APP_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
$appFile = self::getControllerFilePath($appClass); $appFile = self::getControllerFilePath($appClass);
if ($appFile && file_exists($appFile)) { if ($appFile && file_exists($appFile)) {
self::invalidateAndReload($appFile); self::invalidateAndReload($appFile);
if (class_exists($appClass, true)) { if (class_exists($appClass, true)) {
@ -178,7 +178,7 @@ class ControllerResolver
return $appPath . '/' . $relativePath . '.php'; return $appPath . '/' . $relativePath . '.php';
} }
} }
// For vendor namespace // For vendor namespace
if (str_starts_with($className, self::VENDOR_NAMESPACE)) { if (str_starts_with($className, self::VENDOR_NAMESPACE)) {
$relativePath = str_replace(self::VENDOR_NAMESPACE, '', $className); $relativePath = str_replace(self::VENDOR_NAMESPACE, '', $className);
@ -199,7 +199,7 @@ class ControllerResolver
// e.g., 'app' → '\App\Websocket\Controllers\AppController' // e.g., 'app' → '\App\Websocket\Controllers\AppController'
$directName = self::kebabToPascal($eventPrefix) . 'Controller'; $directName = self::kebabToPascal($eventPrefix) . 'Controller';
$appClass = self::APP_NAMESPACE . $directName; $appClass = self::APP_NAMESPACE . $directName;
// class_exists with autoload=true is fast for already-loaded classes // class_exists with autoload=true is fast for already-loaded classes
if (class_exists($appClass, true)) { if (class_exists($appClass, true)) {
return $appClass; return $appClass;

View File

@ -686,9 +686,40 @@ class Handler implements MessageComponentInterface
$startTime = microtime(true); $startTime = microtime(true);
$ipc->setupParent( $ipc->setupParent(
// onData callback - called INSTANTLY when child sends // onData callback - called INSTANTLY when child sends.
// CRITICAL: this callback runs inside the React event loop. Any
// uncaught throwable here would propagate up through ExtEvLoop and
// crash the entire WebSocket server (supervisor would then restart
// it, dropping every connected client). We must catch and log.
function ($data) use ($connection, $message, $startTime) { function ($data) use ($connection, $message, $startTime) {
$this->handleChildData($connection, $message, $data); try {
$this->handleChildData($connection, $message, $data);
} catch (\Throwable $e) {
Log::channel('websocket')->error('handleChildData failed: ' . $e->getMessage(), [
'event' => $message['event'] ?? 'unknown',
'file' => $e->getFile() . ':' . $e->getLine(),
'data_preview' => is_string($data) ? substr($data, 0, 200) : gettype($data),
]);
if (app()->bound('sentry')) {
try {
app('sentry')->captureException($e);
} catch (\Throwable $_) {
// Sentry capture failed — never let logging crash the loop.
}
}
// Best-effort: notify the client so it doesn't hang forever.
try {
$connection->send(json_encode([
'event' => ($message['event'] ?? 'unknown') . ':error',
'data' => ['message' => 'Internal server error'],
'channel' => $message['channel'] ?? null,
]));
} catch (\Throwable $_) {
// Connection may already be gone — swallow.
}
}
// Log latency for debugging // Log latency for debugging
$elapsed = (microtime(true) - $startTime) * 1000; $elapsed = (microtime(true) - $startTime) * 1000;
@ -696,14 +727,21 @@ class Handler implements MessageComponentInterface
Log::channel('websocket')->debug('IPC latency: ' . round($elapsed, 2) . 'ms'); Log::channel('websocket')->debug('IPC latency: ' . round($elapsed, 2) . 'ms');
} }
}, },
// onClose callback - child process ended // onClose callback - child process ended.
// Same isolation rules apply: must not throw out of the loop.
function () { function () {
// Cleanup zombie process try {
pcntl_waitpid(-1, $status, WNOHANG); // Cleanup zombie process
pcntl_waitpid(-1, $status, WNOHANG);
// Free up a child slot and process any queued messages // Free up a child slot and process any queued messages
$this->activeChildCount = max(0, $this->activeChildCount - 1); $this->activeChildCount = max(0, $this->activeChildCount - 1);
$this->processDeferredMessages(); $this->processDeferredMessages();
} catch (\Throwable $e) {
Log::channel('websocket')->error('IPC onClose failed: ' . $e->getMessage(), [
'file' => $e->getFile() . ':' . $e->getLine(),
]);
}
} }
); );
} }
@ -841,8 +879,14 @@ class Handler implements MessageComponentInterface
unset($connection->authLoaded); unset($connection->authLoaded);
$connection->user = null; $connection->user = null;
// Clear any custom connection data that was stored via C:SET // Clear any custom connection data that was stored via C:SET.
foreach (($connection->_connectionDataKeys ?? []) as $key => $_) { // Read-modify-write the tracker via a local copy because the
// connection may be wrapped in a decorator (e.g. ConnectionLogger)
// whose __get returns by value — direct array mutation on the
// overloaded property would raise "Indirect modification has no
// effect" and Laravel's error handler turns that into a fatal.
$keys = $connection->_connectionDataKeys ?? [];
foreach ($keys as $key => $_) {
unset($connection->$key); unset($connection->$key);
} }
$connection->_connectionDataKeys = []; $connection->_connectionDataKeys = [];
@ -854,15 +898,20 @@ class Handler implements MessageComponentInterface
$key = substr($rest, 0, $pos); $key = substr($rest, 0, $pos);
$value = json_decode(substr($rest, $pos + 1)); $value = json_decode(substr($rest, $pos + 1));
$connection->$key = $value; $connection->$key = $value;
$connection->_connectionDataKeys ??= [];
$connection->_connectionDataKeys[$key] = true; // Read-modify-write via local copy (see note above).
$keys = $connection->_connectionDataKeys ?? [];
$keys[$key] = true;
$connection->_connectionDataKeys = $keys;
} }
} elseif (str_starts_with($op, 'DEL:')) { } elseif (str_starts_with($op, 'DEL:')) {
// C:DEL:key // C:DEL:key
$key = substr($op, 4); $key = substr($op, 4);
unset($connection->$key); unset($connection->$key);
if (isset($connection->_connectionDataKeys[$key])) { $keys = $connection->_connectionDataKeys ?? [];
unset($connection->_connectionDataKeys[$key]); if (isset($keys[$key])) {
unset($keys[$key]);
$connection->_connectionDataKeys = $keys;
} }
} }