Unraid already enforces CSRF on every POST via auto_prepend, but its injector is jQuery-only — the plugin's native fetch() calls carried no token and were being terminated before the endpoint ran, silently, because csrf_terminate exits with an empty body that r.json() swallows.
194 lines
9.0 KiB
PHP
194 lines
9.0 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Unraid GraphQL API diagnostic. Reports whether this host has a usable API key, what the
|
|
// PHP environment can do, and what the API actually answers — including live schema
|
|
// introspection for the types the monitor layer depends on.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// A hand-run tool, not part of any page. Nothing in the UI links here; it is opened
|
|
// directly at /plugins/varaverk/api/api_test.php when the monitor page starts showing
|
|
// degraded detail and the question is whether the API, the key, or the parsing is at fault.
|
|
//
|
|
// It exists because that question was expensive to answer. The Unraid API renamed and
|
|
// removed types between 7.2.5 and 7.3 — ArrayParity and ArrayCache stopped being distinct
|
|
// types — and the only reliable way to know what the running version exposes is to ask it.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Reports the environment before the result.
|
|
// Key presence, curl availability and allow_url_fopen are answered first, because a
|
|
// failed probe means something entirely different depending on those three.
|
|
//
|
|
// Two transports, same query.
|
|
// curl when available, a stream context otherwise. The fallback exists so the
|
|
// diagnostic still returns something on a PHP build where the probe's failure would
|
|
// otherwise be indistinguishable from the API being down.
|
|
//
|
|
// Returns raw alongside decoded.
|
|
// probe_raw carries the first 1000 bytes verbatim. When the response is not JSON — an
|
|
// HTML error page, a proxy interception — the decoded field is null and the raw text is
|
|
// the only thing that explains why.
|
|
//
|
|
// Introspects the specific types the monitor layer reads.
|
|
// Not a full schema dump. The named type lists are the ones whose field names the
|
|
// collectors depend on, so a rename shows up here as a missing field rather than as a
|
|
// quietly empty card on the monitor page.
|
|
//
|
|
// Pretty-printed on purpose. The only consumer is a person reading it in a browser tab.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Read-only. Every query is a read or an introspection; nothing here mutates array, docker,
|
|
// or VM state through the API.
|
|
//
|
|
// Every request is time-boxed.
|
|
// 5s with a 3s connect timeout on the probe, 8s on each introspection call. A hung or
|
|
// unreachable API returns a diagnostic rather than becoming one.
|
|
//
|
|
// The whole probe is skipped without a key, and reports that as the finding.
|
|
// key_present is false and probe stays null. The absence of a key is the most common
|
|
// answer this tool gives, and it is reported as a result rather than as a failure.
|
|
//
|
|
// Localhost only. The endpoint is hardcoded to http://localhost/graphql — no part of the
|
|
// request selects a target, so this cannot be used to probe another host.
|
|
//
|
|
// The key is truncated in the response.
|
|
// Only the first 8 characters are returned, as key_prefix — enough to confirm which key
|
|
// is in use, not enough to use it.
|
|
//
|
|
// Known exposure: this returns more than a normal endpoint should.
|
|
// A key prefix, the cached API debug log, and the live schema are all visible to anyone
|
|
// with a WebGUI session. That is acceptable for a diagnostic reachable only by typing
|
|
// its URL, but it is the reason it is not linked from any page and should not be
|
|
// wrapped in one. It is a pure GET read, so Unraid's POST-only CSRF guard does not
|
|
// apply to it — the WebGUI session is its whole boundary. See README-unraid.md.
|
|
//
|
|
// REQUEST
|
|
// GET, no parameters
|
|
//
|
|
// RESPONSE
|
|
// {"host","key_present","key_prefix","curl_available","allow_url_fopen","debug_log",
|
|
// "probe":{"url","http_code","curl_err","decoded"},"probe_raw",
|
|
// "schema","pool_drives","schema2"}
|
|
// The schema, pool_drives and schema2 keys are present only when a key exists.
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php vv_detect_host(), vv_conf_vars(), VV_CACHE_DIR
|
|
// Unraid API http://localhost/graphql
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/config.php';
|
|
|
|
$hostId = vv_detect_host();
|
|
$vars = vv_conf_vars();
|
|
$key = $vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? '';
|
|
|
|
$result = [
|
|
'host' => $hostId,
|
|
'key_present' => $key !== '',
|
|
'key_prefix' => $key ? substr($key, 0, 8) . '...' : null,
|
|
'curl_available' => function_exists('curl_init'),
|
|
'allow_url_fopen'=> (bool)ini_get('allow_url_fopen'),
|
|
'debug_log' => null,
|
|
'probe' => null,
|
|
'probe_raw' => null,
|
|
];
|
|
|
|
// Show last debug log if present
|
|
$debugFile = VV_CACHE_DIR . '/vv_api_debug.json';
|
|
if (file_exists($debugFile)) {
|
|
$result['debug_log'] = json_decode(file_get_contents($debugFile), true);
|
|
}
|
|
|
|
// Run a minimal probe query
|
|
if ($key) {
|
|
$url = 'http://localhost/graphql';
|
|
$body = json_encode(['query' => '{ info { os { hostname } } }']);
|
|
|
|
if (function_exists('curl_init')) {
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
|
CURLOPT_POSTFIELDS => $body,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 5,
|
|
CURLOPT_CONNECTTIMEOUT => 3,
|
|
]);
|
|
$raw = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlErr = curl_error($ch);
|
|
curl_close($ch);
|
|
} else {
|
|
$ctx = stream_context_create(['http' => [
|
|
'method' => 'POST',
|
|
'header' => "Content-Type: application/json\r\nx-api-key: {$key}",
|
|
'content' => $body,
|
|
'timeout' => 5,
|
|
'ignore_errors' => true,
|
|
]]);
|
|
$raw = @file_get_contents($url, false, $ctx);
|
|
$httpCode = $raw !== false ? 200 : 0;
|
|
$curlErr = '';
|
|
}
|
|
|
|
$result['probe'] = [
|
|
'url' => $url,
|
|
'http_code' => $httpCode,
|
|
'curl_err' => $curlErr ?: null,
|
|
'decoded' => json_decode((string)$raw, true),
|
|
];
|
|
$result['probe_raw'] = substr((string)$raw, 0, 1000);
|
|
|
|
// ── Schema introspection — discover actual field names ────────────────────────
|
|
if ($key) {
|
|
// ArrayParity and ArrayCache were separate named types in 7.2.5 — removed in 7.3 (now same as ArrayDisk)
|
|
$types = ['InfoOs','InfoCpu','InfoMemory','ArrayDisk','VmDomain'];
|
|
$introspectGql = '{ ' . implode(' ', array_map(fn($t) =>
|
|
"{$t}: __type(name: \"{$t}\") { fields { name type { name kind ofType { name kind } } } }",
|
|
$types
|
|
)) . ' }';
|
|
|
|
$body2 = json_encode(['query' => $introspectGql]);
|
|
$ch2 = curl_init('http://localhost/graphql');
|
|
curl_setopt_array($ch2, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
|
CURLOPT_POSTFIELDS => $body2,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 8,
|
|
]);
|
|
$raw2 = curl_exec($ch2);
|
|
curl_close($ch2);
|
|
$result['schema'] = json_decode((string)$raw2, true)['data'] ?? null;
|
|
|
|
// Pool drive names — what does the API actually return for cache/pool drives?
|
|
$ch_pools = curl_init('http://localhost/graphql');
|
|
curl_setopt_array($ch_pools, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
|
CURLOPT_POSTFIELDS => json_encode(['query' => '{ array { caches { name device type status fsType } } }']),
|
|
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
|
]);
|
|
$result['pool_drives'] = json_decode((string)curl_exec($ch_pools), true)['data'] ?? null;
|
|
curl_close($ch_pools);
|
|
|
|
// Round 2: introspect CpuUtilization and MemoryUtilization field names
|
|
$types2 = ['CpuUtilization','MemoryUtilization','TemperatureMetrics'];
|
|
$gql2 = '{ ' . implode(' ', array_map(fn($t) =>
|
|
"{$t}: __type(name: \"{$t}\") { kind fields { name type { name kind ofType { name kind } } } }",
|
|
$types2
|
|
)) . ' }';
|
|
$ch3 = curl_init('http://localhost/graphql');
|
|
curl_setopt_array($ch3, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "x-api-key: {$key}"],
|
|
CURLOPT_POSTFIELDS => json_encode(['query' => $gql2]),
|
|
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
|
|
]);
|
|
$result['schema2'] = json_decode((string)curl_exec($ch3), true)['data'] ?? null;
|
|
curl_close($ch3);
|
|
}
|
|
}
|
|
|
|
echo json_encode($result, JSON_PRETTY_PRINT);
|