fix: harden IPC callbacks and decouple auth lookup via configurable resolver
This commit is contained in:
parent
ed371ac051
commit
2ad8d490b7
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue