From f1fc196b762574339c2bdd750b18dbb67e9910ee Mon Sep 17 00:00:00 2001 From: Jay Collett <486430+jaycollett@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:38:32 -0500 Subject: [PATCH] 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) --- TrueNASCORE/TrueNASApiTrait.php | 238 ++++++++++++++++++++++++++++++++ TrueNASCORE/TrueNASCORE.php | 43 +++--- TrueNASCORE/config.blade.php | 14 ++ TrueNASSCALE/TrueNASSCALE.php | 45 +++--- TrueNASSCALE/config.blade.php | 14 ++ 5 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 TrueNASCORE/TrueNASApiTrait.php diff --git a/TrueNASCORE/TrueNASApiTrait.php b/TrueNASCORE/TrueNASApiTrait.php new file mode 100644 index 0000000000..113c4b0928 --- /dev/null +++ b/TrueNASCORE/TrueNASApiTrait.php @@ -0,0 +1,238 @@ +getConfigValue('api_mode', 'auto'); + } + + /** + * Check if WebSocket library is available. + * + * @return bool + */ + public function isWebSocketAvailable(): bool + { + return class_exists('WebSocket\Client'); + } + + /** + * Make an API call using the configured transport. + * + * @param string $method The method name (e.g., 'system.info' or 'alert.list') + * @param array $params Optional parameters + * @return mixed The result from the API + * @throws \Exception If the call fails + */ + public function apiCall(string $method, array $params = []) + { + $mode = $this->getApiMode(); + + if ($mode === 'websocket') { + return $this->callWebSocket($method, $params); + } + + if ($mode === 'rest') { + return $this->callRest($method, $params); + } + + // Auto mode: try WebSocket first, fall back to REST + if ($this->isWebSocketAvailable()) { + try { + return $this->callWebSocket($method, $params); + } catch (\Exception $e) { + Log::debug('WebSocket failed, falling back to REST: ' . $e->getMessage()); + } + } + + return $this->callRest($method, $params); + } + + /** + * Make a WebSocket JSON-RPC 2.0 call. + * + * @param string $method The JSON-RPC method name + * @param array $params Optional parameters + * @return mixed The result + * @throws \Exception If the call fails + */ + private function callWebSocket(string $method, array $params = []) + { + $this->ensureWebSocketConnected(); + $this->lastApiMode = 'websocket'; + return $this->wsClient->call($method, $params); + } + + /** + * Make a REST API call. + * + * @param string $method The method name (will be converted to REST endpoint) + * @param array $params Optional parameters (sent as JSON body for POST) + * @return mixed The result + * @throws \Exception If the call fails + */ + private function callRest(string $method, array $params = []) + { + $this->lastApiMode = 'rest'; + + // Map JSON-RPC methods to REST endpoints + $endpointMap = [ + 'core.ping' => 'core/ping', + 'system.info' => 'system/info', + 'alert.list' => 'alert/list', + ]; + + $endpoint = $endpointMap[$method] ?? str_replace('.', '/', $method); + $url = $this->url($endpoint); + $attrs = $this->attrs(); + + if (!empty($params)) { + $attrs['json'] = $params; + } + + $res = parent::execute($url, $attrs); + + if ($res === null) { + throw new \Exception('REST API call failed'); + } + + $body = $res->getBody()->getContents(); + + // Handle simple string responses (like "pong") + $decoded = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + // Return raw response for non-JSON (e.g., "pong" string) + return trim($body, '"'); + } + + return $decoded; + } + + /** + * Ensure WebSocket client is connected. + * + * @throws \Exception If connection fails + */ + private function ensureWebSocketConnected(): void + { + if ($this->wsClient !== null && $this->wsClient->isConnected()) { + return; + } + + if (!$this->isWebSocketAvailable()) { + throw new \Exception('WebSocket library not available'); + } + + $baseUrl = parent::normaliseurl($this->config->url, false); + $apiKey = $this->config->apikey; + $ignoreTls = $this->getConfigValue('ignore_tls', false); + + $this->wsClient = new TrueNASWebSocketClient($baseUrl, $apiKey, $ignoreTls); + $this->wsClient->connect(); + } + + /** + * Close WebSocket connection if open. + */ + public function disconnectWebSocket(): void + { + if ($this->wsClient !== null) { + $this->wsClient->disconnect(); + $this->wsClient = null; + } + } + + /** + * Test API connection. + * + * @return object Test result with code, status, and response + */ + public function testApi(): object + { + if (empty($this->config->url)) { + return (object) [ + 'code' => 404, + 'status' => 'No URL has been specified', + 'response' => 'No URL has been specified', + ]; + } + + $mode = $this->getApiMode(); + + // Try WebSocket if enabled + if ($mode !== 'rest' && $this->isWebSocketAvailable()) { + try { + $this->ensureWebSocketConnected(); + $result = $this->wsClient->ping(); + + if ($result) { + return (object) [ + 'code' => 200, + 'status' => 'Successfully communicated with the API (WebSocket)', + 'response' => 'pong', + ]; + } + } catch (\Exception $e) { + if ($mode === 'websocket') { + return (object) [ + 'code' => null, + 'status' => 'WebSocket connection failed: ' . $e->getMessage(), + 'response' => 'Connection failed', + ]; + } + Log::debug('WebSocket test failed, trying REST: ' . $e->getMessage()); + } finally { + $this->disconnectWebSocket(); + } + } + + // Try REST if WebSocket failed or not available + if ($mode !== 'websocket') { + $test = parent::appTest($this->url('core/ping'), $this->attrs()); + if ($test->code === 200) { + if ($test->response != '"pong"') { + $test->status = 'Failed: ' . $test->response; + } else { + $test->status = 'Successfully communicated with the API (REST)'; + } + } + return $test; + } + + return (object) [ + 'code' => null, + 'status' => 'WebSocket not available and REST mode disabled', + 'response' => 'Connection failed', + ]; + } + + /** + * Get the API mode that was used for the last call. + * + * @return string|null 'websocket' or 'rest', or null if no call made + */ + public function getLastApiMode(): ?string + { + return $this->lastApiMode; + } +} diff --git a/TrueNASCORE/TrueNASCORE.php b/TrueNASCORE/TrueNASCORE.php index d667813a45..06ca30d994 100644 --- a/TrueNASCORE/TrueNASCORE.php +++ b/TrueNASCORE/TrueNASCORE.php @@ -4,25 +4,17 @@ class TrueNASCORE extends \App\SupportedApps implements \App\EnhancedApps { - public $config; + use TrueNASApiTrait; - //protected $login_first = true; // Uncomment if api requests need to be authed first - //protected $method = 'POST'; // Uncomment if requests to the API should be set by POST + public $config; public function __construct() { - //$this->jar = new \GuzzleHttp\Cookie\CookieJar; // Uncomment if cookies need to be set } public function test() { - $test = parent::appTest($this->url("core/ping"), $this->attrs()); - if ($test->code === 200) { - $data = $test->response; - if ($test->response != '"pong"') { - $test->status = "Failed: " . $data; - } - } + $test = $this->testApi(); echo $test->status; } @@ -31,14 +23,20 @@ public function livestats() $status = "inactive"; $data = []; - $res = parent::execute($this->url("system/info"), $this->attrs()); - $details = json_decode($res->getBody()); - $seconds = $details->uptime_seconds ?? 0; - $data["uptime"] = $this->uptime($seconds); - - $res = parent::execute($this->url("alert/list"), $this->attrs()); - $details = json_decode($res->getBody(), true); - list($data["alert_tot"], $data["alert_crit"]) = $this->alerts($details); + try { + $systemInfo = $this->apiCall('system.info'); + $seconds = $systemInfo['uptime_seconds'] ?? 0; + $data["uptime"] = $this->uptime($seconds); + + $alerts = $this->apiCall('alert.list'); + list($data["alert_tot"], $data["alert_crit"]) = $this->alerts($alerts); + } catch (\Exception $e) { + $data["uptime"] = "Error"; + $data["alert_tot"] = "?"; + $data["alert_crit"] = "?"; + } finally { + $this->disconnectWebSocket(); + } return parent::getLiveStats($status, $data); } @@ -68,29 +66,22 @@ public function attrs() public function uptime($inputSeconds) { - // Adapted from https://stackoverflow.com/questions/8273804/convert-seconds-into-days-hours-minutes-and-seconds - $res = ""; $secondsInAMinute = 60; $secondsInAnHour = 60 * $secondsInAMinute; $secondsInADay = 24 * $secondsInAnHour; - // extract days $days = floor($inputSeconds / $secondsInADay); - // extract hours $hourSeconds = $inputSeconds % $secondsInADay; $hours = floor($hourSeconds / $secondsInAnHour); - // extract minutes $minuteSeconds = $hourSeconds % $secondsInAnHour; $minutes = floor($minuteSeconds / $secondsInAMinute); - // extract the remaining seconds $remainingSeconds = $minuteSeconds % $secondsInAMinute; $seconds = ceil($remainingSeconds); - //$res = strval($days).'d '.strval($hours).':'.sprintf('%02d', $minutes).':'.sprintf('%02d', $seconds); if ($days > 0) { $res = strval($days) . diff --git a/TrueNASCORE/config.blade.php b/TrueNASCORE/config.blade.php index a97c9979e8..feeb0eeb7b 100644 --- a/TrueNASCORE/config.blade.php +++ b/TrueNASCORE/config.blade.php @@ -8,6 +8,20 @@ {!! Form::text('config[apikey]', isset($item) ? $item->getconfig()->apikey : null, ['placeholder' => __('app.apps.apikey'), 'data-config' => 'apikey', 'class' => 'form-control config-item']) !!} +