Skip to content

Commit 704c370

Browse files
committed
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
1 parent 7861ae1 commit 704c370

File tree

2 files changed

+188
-0
lines changed

2 files changed

+188
-0
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
3+
namespace App\Helpers;
4+
5+
use Illuminate\Support\Facades\Log;
6+
use Phrity\Net\Context;
7+
use WebSocket\Client;
8+
use WebSocket\ConnectionException;
9+
10+
/**
11+
* TrueNAS JSON-RPC 2.0 WebSocket Client
12+
*
13+
* Handles WebSocket communication with TrueNAS using the JSON-RPC 2.0 protocol.
14+
* Required for TrueNAS 25.04+ as the REST API is deprecated.
15+
*
16+
* @see https://api.truenas.com/v25.10/jsonrpc.html
17+
*/
18+
class TrueNASWebSocketClient
19+
{
20+
private ?Client $client = null;
21+
private string $url;
22+
private string $apiKey;
23+
private bool $ignoreTls;
24+
private bool $authenticated = false;
25+
private int $requestId = 1;
26+
27+
/**
28+
* Create a new TrueNAS WebSocket client instance.
29+
*
30+
* @param string $baseUrl The base URL of the TrueNAS instance (e.g., https://truenas.local)
31+
* @param string $apiKey The API key for authentication
32+
* @param bool $ignoreTls Whether to skip TLS certificate verification
33+
*/
34+
public function __construct(string $baseUrl, string $apiKey, bool $ignoreTls = false)
35+
{
36+
$baseUrl = rtrim($baseUrl, '/');
37+
$scheme = parse_url($baseUrl, PHP_URL_SCHEME);
38+
$host = parse_url($baseUrl, PHP_URL_HOST);
39+
$port = parse_url($baseUrl, PHP_URL_PORT);
40+
41+
$wsScheme = ($scheme === 'https') ? 'wss' : 'ws';
42+
$portPart = $port ? ':' . $port : '';
43+
44+
$this->url = "{$wsScheme}://{$host}{$portPart}/api/current";
45+
$this->apiKey = $apiKey;
46+
$this->ignoreTls = $ignoreTls;
47+
}
48+
49+
/**
50+
* Connect to the TrueNAS WebSocket API and authenticate.
51+
*
52+
* @return bool True if connection and authentication succeeded
53+
* @throws \Exception If connection or authentication fails
54+
*/
55+
public function connect(): bool
56+
{
57+
if ($this->client !== null && $this->authenticated) {
58+
return true;
59+
}
60+
61+
// Build SSL options - always force HTTP/1.1 via ALPN for WebSocket compatibility
62+
// TrueNAS nginx defaults to HTTP/2 which doesn't support WebSocket upgrade
63+
$sslOptions = [
64+
'alpn_protocols' => 'http/1.1',
65+
];
66+
67+
if ($this->ignoreTls) {
68+
$sslOptions['verify_peer'] = false;
69+
$sslOptions['verify_peer_name'] = false;
70+
$sslOptions['allow_self_signed'] = true;
71+
}
72+
73+
// Create context using phrity/net-stream Context class (required by phrity/websocket v3.x)
74+
$streamContext = stream_context_create(['ssl' => $sslOptions]);
75+
$context = new Context($streamContext);
76+
77+
try {
78+
$this->client = new Client($this->url);
79+
$this->client->setTimeout(15);
80+
$this->client->setContext($context);
81+
82+
$authResult = $this->call('auth.login_with_api_key', [$this->apiKey]);
83+
84+
if ($authResult === true) {
85+
$this->authenticated = true;
86+
return true;
87+
}
88+
89+
throw new \Exception('Authentication failed: Invalid API key');
90+
} catch (ConnectionException $e) {
91+
Log::error('TrueNAS WebSocket connection failed: ' . $e->getMessage());
92+
$this->disconnect();
93+
throw new \Exception('WebSocket connection failed: ' . $e->getMessage());
94+
}
95+
}
96+
97+
/**
98+
* Make a JSON-RPC 2.0 call to the TrueNAS API.
99+
*
100+
* @param string $method The JSON-RPC method name (e.g., 'system.info')
101+
* @param array $params Optional parameters for the method
102+
* @return mixed The result from the API call
103+
* @throws \Exception If the call fails or returns an error
104+
*/
105+
public function call(string $method, array $params = [])
106+
{
107+
if ($this->client === null) {
108+
throw new \Exception('WebSocket client not connected');
109+
}
110+
111+
$request = [
112+
'jsonrpc' => '2.0',
113+
'method' => $method,
114+
'id' => $this->requestId++,
115+
];
116+
117+
if (!empty($params)) {
118+
$request['params'] = $params;
119+
}
120+
121+
try {
122+
$this->client->text(json_encode($request));
123+
$response = $this->client->receive();
124+
$decoded = json_decode($response->getContent(), true);
125+
126+
if (isset($decoded['error'])) {
127+
$errorMsg = $decoded['error']['message'] ?? 'Unknown error';
128+
$errorCode = $decoded['error']['code'] ?? 0;
129+
throw new \Exception("API error ({$errorCode}): {$errorMsg}");
130+
}
131+
132+
return $decoded['result'] ?? null;
133+
} catch (ConnectionException $e) {
134+
Log::error('TrueNAS WebSocket call failed: ' . $e->getMessage());
135+
throw new \Exception('WebSocket call failed: ' . $e->getMessage());
136+
}
137+
}
138+
139+
/**
140+
* Close the WebSocket connection.
141+
*/
142+
public function disconnect(): void
143+
{
144+
if ($this->client !== null) {
145+
try {
146+
$this->client->close();
147+
} catch (\Exception $e) {
148+
Log::debug('Error closing WebSocket: ' . $e->getMessage());
149+
}
150+
$this->client = null;
151+
$this->authenticated = false;
152+
}
153+
}
154+
155+
/**
156+
* Check if the client is connected and authenticated.
157+
*
158+
* @return bool
159+
*/
160+
public function isConnected(): bool
161+
{
162+
return $this->client !== null && $this->authenticated;
163+
}
164+
165+
/**
166+
* Test the connection by calling core.ping.
167+
*
168+
* @return bool True if the ping succeeds
169+
*/
170+
public function ping(): bool
171+
{
172+
try {
173+
$result = $this->call('core.ping');
174+
return $result === 'pong';
175+
} catch (\Exception $e) {
176+
return false;
177+
}
178+
}
179+
180+
/**
181+
* Clean up on destruction.
182+
*/
183+
public function __destruct()
184+
{
185+
$this->disconnect();
186+
}
187+
}

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"laravel/ui": "^4.4",
2020
"league/flysystem-aws-s3-v3": "^3.0",
2121
"nunomaduro/collision": "^8.0",
22+
"phrity/websocket": "^3.6",
2223
"spatie/laravel-html": "^3.11",
2324
"spatie/laravel-ignition": "^2.4",
2425
"symfony/yaml": "^7.0"

0 commit comments

Comments
 (0)