From 704c370eda576a0dbc0ebd4b8e2f4836022313ed Mon Sep 17 00:00:00 2001 From: Jay Collett <486430+jaycollett@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:38:27 -0500 Subject: [PATCH] Add WebSocket support for TrueNAS JSON-RPC 2.0 API TrueNAS is deprecating the REST API (api/v2.0/) in version 26.04, requiring migration to JSON-RPC 2.0 over WebSocket. This commit adds: - phrity/websocket dependency for WebSocket communication - TrueNASWebSocketClient helper class that handles: - Connection to ws(s)://host/api/current - Authentication via auth.login_with_api_key - JSON-RPC 2.0 request/response formatting - TLS verification toggle - Proper connection cleanup Refs: #1530 --- app/Helpers/TrueNASWebSocketClient.php | 187 +++++++++++++++++++++++++ composer.json | 1 + 2 files changed, 188 insertions(+) create mode 100644 app/Helpers/TrueNASWebSocketClient.php diff --git a/app/Helpers/TrueNASWebSocketClient.php b/app/Helpers/TrueNASWebSocketClient.php new file mode 100644 index 000000000..17c32df08 --- /dev/null +++ b/app/Helpers/TrueNASWebSocketClient.php @@ -0,0 +1,187 @@ +url = "{$wsScheme}://{$host}{$portPart}/api/current"; + $this->apiKey = $apiKey; + $this->ignoreTls = $ignoreTls; + } + + /** + * Connect to the TrueNAS WebSocket API and authenticate. + * + * @return bool True if connection and authentication succeeded + * @throws \Exception If connection or authentication fails + */ + public function connect(): bool + { + if ($this->client !== null && $this->authenticated) { + return true; + } + + // Build SSL options - always force HTTP/1.1 via ALPN for WebSocket compatibility + // TrueNAS nginx defaults to HTTP/2 which doesn't support WebSocket upgrade + $sslOptions = [ + 'alpn_protocols' => 'http/1.1', + ]; + + if ($this->ignoreTls) { + $sslOptions['verify_peer'] = false; + $sslOptions['verify_peer_name'] = false; + $sslOptions['allow_self_signed'] = true; + } + + // Create context using phrity/net-stream Context class (required by phrity/websocket v3.x) + $streamContext = stream_context_create(['ssl' => $sslOptions]); + $context = new Context($streamContext); + + try { + $this->client = new Client($this->url); + $this->client->setTimeout(15); + $this->client->setContext($context); + + $authResult = $this->call('auth.login_with_api_key', [$this->apiKey]); + + if ($authResult === true) { + $this->authenticated = true; + return true; + } + + throw new \Exception('Authentication failed: Invalid API key'); + } catch (ConnectionException $e) { + Log::error('TrueNAS WebSocket connection failed: ' . $e->getMessage()); + $this->disconnect(); + throw new \Exception('WebSocket connection failed: ' . $e->getMessage()); + } + } + + /** + * Make a JSON-RPC 2.0 call to the TrueNAS API. + * + * @param string $method The JSON-RPC method name (e.g., 'system.info') + * @param array $params Optional parameters for the method + * @return mixed The result from the API call + * @throws \Exception If the call fails or returns an error + */ + public function call(string $method, array $params = []) + { + if ($this->client === null) { + throw new \Exception('WebSocket client not connected'); + } + + $request = [ + 'jsonrpc' => '2.0', + 'method' => $method, + 'id' => $this->requestId++, + ]; + + if (!empty($params)) { + $request['params'] = $params; + } + + try { + $this->client->text(json_encode($request)); + $response = $this->client->receive(); + $decoded = json_decode($response->getContent(), true); + + if (isset($decoded['error'])) { + $errorMsg = $decoded['error']['message'] ?? 'Unknown error'; + $errorCode = $decoded['error']['code'] ?? 0; + throw new \Exception("API error ({$errorCode}): {$errorMsg}"); + } + + return $decoded['result'] ?? null; + } catch (ConnectionException $e) { + Log::error('TrueNAS WebSocket call failed: ' . $e->getMessage()); + throw new \Exception('WebSocket call failed: ' . $e->getMessage()); + } + } + + /** + * Close the WebSocket connection. + */ + public function disconnect(): void + { + if ($this->client !== null) { + try { + $this->client->close(); + } catch (\Exception $e) { + Log::debug('Error closing WebSocket: ' . $e->getMessage()); + } + $this->client = null; + $this->authenticated = false; + } + } + + /** + * Check if the client is connected and authenticated. + * + * @return bool + */ + public function isConnected(): bool + { + return $this->client !== null && $this->authenticated; + } + + /** + * Test the connection by calling core.ping. + * + * @return bool True if the ping succeeds + */ + public function ping(): bool + { + try { + $result = $this->call('core.ping'); + return $result === 'pong'; + } catch (\Exception $e) { + return false; + } + } + + /** + * Clean up on destruction. + */ + public function __destruct() + { + $this->disconnect(); + } +} diff --git a/composer.json b/composer.json index 549aa17c1..23dd6e99c 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "laravel/ui": "^4.4", "league/flysystem-aws-s3-v3": "^3.0", "nunomaduro/collision": "^8.0", + "phrity/websocket": "^3.6", "spatie/laravel-html": "^3.11", "spatie/laravel-ignition": "^2.4", "symfony/yaml": "^7.0"