Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions TrueNASCORE/TrueNASApiTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

namespace App\SupportedApps\TrueNASCORE;

use App\Helpers\TrueNASWebSocketClient;
use Illuminate\Support\Facades\Log;

/**
* Shared trait for TrueNAS API communication.
*
* Provides WebSocket (JSON-RPC 2.0) and REST API support with automatic fallback.
* Required for TrueNAS 25.04+ as the REST API is deprecated in 26.04.
*/
trait TrueNASApiTrait
{
private ?TrueNASWebSocketClient $wsClient = null;
private ?string $lastApiMode = null;

/**
* Get the configured API mode.
*
* @return string 'auto', 'websocket', or 'rest'
*/
public function getApiMode(): string
{
return $this->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;
}
}
43 changes: 17 additions & 26 deletions TrueNASCORE/TrueNASCORE.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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) .
Expand Down
14 changes: 14 additions & 0 deletions TrueNASCORE/config.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@
<label>{{ __('app.apps.apikey') }}</label>
{!! Form::text('config[apikey]', isset($item) ? $item->getconfig()->apikey : null, ['placeholder' => __('app.apps.apikey'), 'data-config' => 'apikey', 'class' => 'form-control config-item']) !!}
</div>
<div class="input">
<label>API Mode</label>
<?php
$api_mode = 'auto';
if (isset($item) && !empty($item) && isset($item->getconfig()->api_mode)) {
$api_mode = $item->getconfig()->api_mode;
}
?>
<select name="config[api_mode]" class="form-control config-item" data-config="api_mode">
<option value="auto" {{ $api_mode === 'auto' ? 'selected' : '' }}>Auto (WebSocket preferred, REST fallback)</option>
<option value="websocket" {{ $api_mode === 'websocket' ? 'selected' : '' }}>WebSocket Only (TrueNAS 25.04+)</option>
<option value="rest" {{ $api_mode === 'rest' ? 'selected' : '' }}>REST Only (Legacy)</option>
</select>
</div>
<div class="input">
<label>Skip TLS verification</label>
<div class="toggleinput" style="margin-top: 26px; padding-left: 15px;">
Expand Down
Loading