Skip to content

Commit f1fc196

Browse files
committed
Migrate TrueNAS apps to JSON-RPC 2.0 WebSocket 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 updates TrueNASCORE and TrueNASSCALE apps: - Add TrueNASApiTrait with shared WebSocket/REST logic - Support three API modes: auto, websocket, rest - Auto mode tries WebSocket first with REST fallback - Add API mode selector to config UI - Maintain backward compatibility with older TrueNAS versions API mapping: - core/ping -> core.ping - system/info -> system.info - alert/list -> alert.list Requires: linuxserver/Heimdall#PR (WebSocket support)
1 parent d3ab2ff commit f1fc196

File tree

5 files changed

+302
-52
lines changed

5 files changed

+302
-52
lines changed

TrueNASCORE/TrueNASApiTrait.php

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
<?php
2+
3+
namespace App\SupportedApps\TrueNASCORE;
4+
5+
use App\Helpers\TrueNASWebSocketClient;
6+
use Illuminate\Support\Facades\Log;
7+
8+
/**
9+
* Shared trait for TrueNAS API communication.
10+
*
11+
* Provides WebSocket (JSON-RPC 2.0) and REST API support with automatic fallback.
12+
* Required for TrueNAS 25.04+ as the REST API is deprecated in 26.04.
13+
*/
14+
trait TrueNASApiTrait
15+
{
16+
private ?TrueNASWebSocketClient $wsClient = null;
17+
private ?string $lastApiMode = null;
18+
19+
/**
20+
* Get the configured API mode.
21+
*
22+
* @return string 'auto', 'websocket', or 'rest'
23+
*/
24+
public function getApiMode(): string
25+
{
26+
return $this->getConfigValue('api_mode', 'auto');
27+
}
28+
29+
/**
30+
* Check if WebSocket library is available.
31+
*
32+
* @return bool
33+
*/
34+
public function isWebSocketAvailable(): bool
35+
{
36+
return class_exists('WebSocket\Client');
37+
}
38+
39+
/**
40+
* Make an API call using the configured transport.
41+
*
42+
* @param string $method The method name (e.g., 'system.info' or 'alert.list')
43+
* @param array $params Optional parameters
44+
* @return mixed The result from the API
45+
* @throws \Exception If the call fails
46+
*/
47+
public function apiCall(string $method, array $params = [])
48+
{
49+
$mode = $this->getApiMode();
50+
51+
if ($mode === 'websocket') {
52+
return $this->callWebSocket($method, $params);
53+
}
54+
55+
if ($mode === 'rest') {
56+
return $this->callRest($method, $params);
57+
}
58+
59+
// Auto mode: try WebSocket first, fall back to REST
60+
if ($this->isWebSocketAvailable()) {
61+
try {
62+
return $this->callWebSocket($method, $params);
63+
} catch (\Exception $e) {
64+
Log::debug('WebSocket failed, falling back to REST: ' . $e->getMessage());
65+
}
66+
}
67+
68+
return $this->callRest($method, $params);
69+
}
70+
71+
/**
72+
* Make a WebSocket JSON-RPC 2.0 call.
73+
*
74+
* @param string $method The JSON-RPC method name
75+
* @param array $params Optional parameters
76+
* @return mixed The result
77+
* @throws \Exception If the call fails
78+
*/
79+
private function callWebSocket(string $method, array $params = [])
80+
{
81+
$this->ensureWebSocketConnected();
82+
$this->lastApiMode = 'websocket';
83+
return $this->wsClient->call($method, $params);
84+
}
85+
86+
/**
87+
* Make a REST API call.
88+
*
89+
* @param string $method The method name (will be converted to REST endpoint)
90+
* @param array $params Optional parameters (sent as JSON body for POST)
91+
* @return mixed The result
92+
* @throws \Exception If the call fails
93+
*/
94+
private function callRest(string $method, array $params = [])
95+
{
96+
$this->lastApiMode = 'rest';
97+
98+
// Map JSON-RPC methods to REST endpoints
99+
$endpointMap = [
100+
'core.ping' => 'core/ping',
101+
'system.info' => 'system/info',
102+
'alert.list' => 'alert/list',
103+
];
104+
105+
$endpoint = $endpointMap[$method] ?? str_replace('.', '/', $method);
106+
$url = $this->url($endpoint);
107+
$attrs = $this->attrs();
108+
109+
if (!empty($params)) {
110+
$attrs['json'] = $params;
111+
}
112+
113+
$res = parent::execute($url, $attrs);
114+
115+
if ($res === null) {
116+
throw new \Exception('REST API call failed');
117+
}
118+
119+
$body = $res->getBody()->getContents();
120+
121+
// Handle simple string responses (like "pong")
122+
$decoded = json_decode($body, true);
123+
if (json_last_error() !== JSON_ERROR_NONE) {
124+
// Return raw response for non-JSON (e.g., "pong" string)
125+
return trim($body, '"');
126+
}
127+
128+
return $decoded;
129+
}
130+
131+
/**
132+
* Ensure WebSocket client is connected.
133+
*
134+
* @throws \Exception If connection fails
135+
*/
136+
private function ensureWebSocketConnected(): void
137+
{
138+
if ($this->wsClient !== null && $this->wsClient->isConnected()) {
139+
return;
140+
}
141+
142+
if (!$this->isWebSocketAvailable()) {
143+
throw new \Exception('WebSocket library not available');
144+
}
145+
146+
$baseUrl = parent::normaliseurl($this->config->url, false);
147+
$apiKey = $this->config->apikey;
148+
$ignoreTls = $this->getConfigValue('ignore_tls', false);
149+
150+
$this->wsClient = new TrueNASWebSocketClient($baseUrl, $apiKey, $ignoreTls);
151+
$this->wsClient->connect();
152+
}
153+
154+
/**
155+
* Close WebSocket connection if open.
156+
*/
157+
public function disconnectWebSocket(): void
158+
{
159+
if ($this->wsClient !== null) {
160+
$this->wsClient->disconnect();
161+
$this->wsClient = null;
162+
}
163+
}
164+
165+
/**
166+
* Test API connection.
167+
*
168+
* @return object Test result with code, status, and response
169+
*/
170+
public function testApi(): object
171+
{
172+
if (empty($this->config->url)) {
173+
return (object) [
174+
'code' => 404,
175+
'status' => 'No URL has been specified',
176+
'response' => 'No URL has been specified',
177+
];
178+
}
179+
180+
$mode = $this->getApiMode();
181+
182+
// Try WebSocket if enabled
183+
if ($mode !== 'rest' && $this->isWebSocketAvailable()) {
184+
try {
185+
$this->ensureWebSocketConnected();
186+
$result = $this->wsClient->ping();
187+
188+
if ($result) {
189+
return (object) [
190+
'code' => 200,
191+
'status' => 'Successfully communicated with the API (WebSocket)',
192+
'response' => 'pong',
193+
];
194+
}
195+
} catch (\Exception $e) {
196+
if ($mode === 'websocket') {
197+
return (object) [
198+
'code' => null,
199+
'status' => 'WebSocket connection failed: ' . $e->getMessage(),
200+
'response' => 'Connection failed',
201+
];
202+
}
203+
Log::debug('WebSocket test failed, trying REST: ' . $e->getMessage());
204+
} finally {
205+
$this->disconnectWebSocket();
206+
}
207+
}
208+
209+
// Try REST if WebSocket failed or not available
210+
if ($mode !== 'websocket') {
211+
$test = parent::appTest($this->url('core/ping'), $this->attrs());
212+
if ($test->code === 200) {
213+
if ($test->response != '"pong"') {
214+
$test->status = 'Failed: ' . $test->response;
215+
} else {
216+
$test->status = 'Successfully communicated with the API (REST)';
217+
}
218+
}
219+
return $test;
220+
}
221+
222+
return (object) [
223+
'code' => null,
224+
'status' => 'WebSocket not available and REST mode disabled',
225+
'response' => 'Connection failed',
226+
];
227+
}
228+
229+
/**
230+
* Get the API mode that was used for the last call.
231+
*
232+
* @return string|null 'websocket' or 'rest', or null if no call made
233+
*/
234+
public function getLastApiMode(): ?string
235+
{
236+
return $this->lastApiMode;
237+
}
238+
}

TrueNASCORE/TrueNASCORE.php

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,17 @@
44

55
class TrueNASCORE extends \App\SupportedApps implements \App\EnhancedApps
66
{
7-
public $config;
7+
use TrueNASApiTrait;
88

9-
//protected $login_first = true; // Uncomment if api requests need to be authed first
10-
//protected $method = 'POST'; // Uncomment if requests to the API should be set by POST
9+
public $config;
1110

1211
public function __construct()
1312
{
14-
//$this->jar = new \GuzzleHttp\Cookie\CookieJar; // Uncomment if cookies need to be set
1513
}
1614

1715
public function test()
1816
{
19-
$test = parent::appTest($this->url("core/ping"), $this->attrs());
20-
if ($test->code === 200) {
21-
$data = $test->response;
22-
if ($test->response != '"pong"') {
23-
$test->status = "Failed: " . $data;
24-
}
25-
}
17+
$test = $this->testApi();
2618
echo $test->status;
2719
}
2820

@@ -31,14 +23,20 @@ public function livestats()
3123
$status = "inactive";
3224
$data = [];
3325

34-
$res = parent::execute($this->url("system/info"), $this->attrs());
35-
$details = json_decode($res->getBody());
36-
$seconds = $details->uptime_seconds ?? 0;
37-
$data["uptime"] = $this->uptime($seconds);
38-
39-
$res = parent::execute($this->url("alert/list"), $this->attrs());
40-
$details = json_decode($res->getBody(), true);
41-
list($data["alert_tot"], $data["alert_crit"]) = $this->alerts($details);
26+
try {
27+
$systemInfo = $this->apiCall('system.info');
28+
$seconds = $systemInfo['uptime_seconds'] ?? 0;
29+
$data["uptime"] = $this->uptime($seconds);
30+
31+
$alerts = $this->apiCall('alert.list');
32+
list($data["alert_tot"], $data["alert_crit"]) = $this->alerts($alerts);
33+
} catch (\Exception $e) {
34+
$data["uptime"] = "Error";
35+
$data["alert_tot"] = "?";
36+
$data["alert_crit"] = "?";
37+
} finally {
38+
$this->disconnectWebSocket();
39+
}
4240

4341
return parent::getLiveStats($status, $data);
4442
}
@@ -68,29 +66,22 @@ public function attrs()
6866

6967
public function uptime($inputSeconds)
7068
{
71-
// Adapted from https://stackoverflow.com/questions/8273804/convert-seconds-into-days-hours-minutes-and-seconds
72-
7369
$res = "";
7470
$secondsInAMinute = 60;
7571
$secondsInAnHour = 60 * $secondsInAMinute;
7672
$secondsInADay = 24 * $secondsInAnHour;
7773

78-
// extract days
7974
$days = floor($inputSeconds / $secondsInADay);
8075

81-
// extract hours
8276
$hourSeconds = $inputSeconds % $secondsInADay;
8377
$hours = floor($hourSeconds / $secondsInAnHour);
8478

85-
// extract minutes
8679
$minuteSeconds = $hourSeconds % $secondsInAnHour;
8780
$minutes = floor($minuteSeconds / $secondsInAMinute);
8881

89-
// extract the remaining seconds
9082
$remainingSeconds = $minuteSeconds % $secondsInAMinute;
9183
$seconds = ceil($remainingSeconds);
9284

93-
//$res = strval($days).'d '.strval($hours).':'.sprintf('%02d', $minutes).':'.sprintf('%02d', $seconds);
9485
if ($days > 0) {
9586
$res =
9687
strval($days) .

TrueNASCORE/config.blade.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@
88
<label>{{ __('app.apps.apikey') }}</label>
99
{!! Form::text('config[apikey]', isset($item) ? $item->getconfig()->apikey : null, ['placeholder' => __('app.apps.apikey'), 'data-config' => 'apikey', 'class' => 'form-control config-item']) !!}
1010
</div>
11+
<div class="input">
12+
<label>API Mode</label>
13+
<?php
14+
$api_mode = 'auto';
15+
if (isset($item) && !empty($item) && isset($item->getconfig()->api_mode)) {
16+
$api_mode = $item->getconfig()->api_mode;
17+
}
18+
?>
19+
<select name="config[api_mode]" class="form-control config-item" data-config="api_mode">
20+
<option value="auto" {{ $api_mode === 'auto' ? 'selected' : '' }}>Auto (WebSocket preferred, REST fallback)</option>
21+
<option value="websocket" {{ $api_mode === 'websocket' ? 'selected' : '' }}>WebSocket Only (TrueNAS 25.04+)</option>
22+
<option value="rest" {{ $api_mode === 'rest' ? 'selected' : '' }}>REST Only (Legacy)</option>
23+
</select>
24+
</div>
1125
<div class="input">
1226
<label>Skip TLS verification</label>
1327
<div class="toggleinput" style="margin-top: 26px; padding-left: 15px;">

0 commit comments

Comments
 (0)