$hostKey) { $hostId = strtolower($hostKey); if ($hostId === $myHostId || isset($seen[$hostId])) continue; $seen[$hostId] = true; $hostname = trim($m[2][$i]); $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) { $results[] = ['host' => $hostKey, 'ok' => false, 'error' => 'Tailscale IP not found']; continue; } $dest = escapeshellarg('root@' . $ip . ':' . CONF_DIR . '/master.conf'); $cmd = 'scp -i ' . escapeshellarg($sshKey) . ' -o ConnectTimeout=10 -o StrictHostKeyChecking=no' . ' ' . escapeshellarg($localPath) . ' ' . $dest . ' 2>&1'; exec($cmd, $out, $rc); $results[] = [ 'host' => $hostKey, 'ok' => $rc === 0, 'error' => $rc !== 0 ? implode('; ', $out) : '', ]; } return $results; } function vv_get_hostname(): string { return trim(shell_exec('hostname -s') ?: ''); } // Mirror of common.sh resolve_tailscale_ip(): tries `tailscale ip -4` first (Tailscale manages // the mapping so this survives IP changes), falls back to parsing `tailscale status` text. function vv_resolve_tailscale_ip(string $hostname): string { $h = strtolower($hostname); $ip = trim(shell_exec('tailscale ip -4 ' . escapeshellarg($h) . ' 2>/dev/null') ?: ''); if ($ip) return $ip; $out = shell_exec('tailscale status 2>/dev/null') ?: ''; foreach (explode("\n", $out) as $line) { $cols = preg_split('/\s+/', trim($line)); if (isset($cols[1]) && stripos($cols[1], $h . '.') === 0) return $cols[0]; } return ''; } function vv_detect_host(): string { // Reads master.conf for HOST1="name" (or HOST1_NAME="name") and matches running hostname. // Returns 'host1', 'host2', 'host3', ... or 'unknown'. Works for any number of hosts. $master = vv_read_conf_raw('master.conf'); preg_match_all('/^\s*(HOST\d+)(?:_NAME)?\s*=\s*["\']?(\S+?)["\']?\s*$/m', $master, $m); $hostname = vv_get_hostname(); foreach ($m[1] as $i => $key) { if (strcasecmp($hostname, trim($m[2][$i])) === 0) return strtolower($key); } return 'unknown'; } function vv_is_owner(): bool { return vv_detect_host() === 'host1'; } function vv_read_conf_raw(string $filename): string { $path = CONF_DIR . '/' . $filename; return file_exists($path) ? file_get_contents($path) : ''; } function vv_write_conf_raw(string $filename, string $content): bool { $path = CONF_DIR . '/' . $filename; $tmp = $path . '.vv.tmp'; if (file_put_contents($tmp, $content) === false) return false; return rename($tmp, $path); } function vv_get_conf_files(): array { // Returns conf files this host is allowed to view/edit $host = vv_detect_host(); $files = []; if ($host === 'host1') { // Owner sees master.conf + their own host conf $files[] = 'master.conf'; $files[] = 'host1.conf'; } elseif (preg_match('/^host(\d+)$/', $host)) { // Any other numbered host sees only their own conf $files[] = $host . '.conf'; } else { // Unknown host — show all for dev/debug foreach (glob(CONF_DIR . '/*.conf') as $f) { $files[] = basename($f); } } return $files; } // Parse conf into key=>value map for $VAR substitution in docs function vv_conf_vars(): array { $vars = []; $files = ['master.conf']; $host = vv_detect_host(); if (preg_match('/^host\d+$/', $host)) $files[] = $host . '.conf'; foreach ($files as $f) { $raw = vv_read_conf_raw($f); // Match: VAR_NAME="value" or VAR_NAME=value (no quotes) preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m); foreach ($m[1] as $i => $key) { $vars[$key] = trim($m[2][$i]); } } return $vars; } // Query the Unraid GraphQL API for a given host. // For the local host queries http://localhost/graphql; for remote hosts uses the Tailscale IP. // $apiKey may be passed explicitly (needed when querying a remote host from the local host, // since vv_conf_vars() only loads the current host's conf file). // Returns the decoded 'data' object on success, null on any failure. // Debug log written to /tmp/vv_api_debug.json on failure. function vv_unraid_api_query(string $hostId, string $gql, int $timeoutSec = 5, string $apiKey = ''): ?array { $vars = vv_conf_vars(); $key = $apiKey ?: ($vars[strtoupper($hostId) . '_UNRAID_API_KEY'] ?? ''); if (!$key) return null; $myHostId = vv_detect_host(); if (strtolower($hostId) === strtolower($myHostId)) { $url = 'http://localhost/graphql'; } else { $hostname = $vars[strtoupper($hostId)] ?? ''; if (!$hostname) return null; $ip = vv_resolve_tailscale_ip($hostname); if (!$ip) return null; $url = "http://{$ip}/graphql"; } $body = json_encode(['query' => $gql]); // Use curl (preferred — doesn't require allow_url_fopen, better error handling). 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 => $timeoutSec, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false, ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); } else { // Fallback to file_get_contents if curl is unavailable. $ctx = stream_context_create(['http' => [ 'method' => 'POST', 'header' => "Content-Type: application/json\r\nx-api-key: {$key}", 'content' => $body, 'timeout' => $timeoutSec, 'ignore_errors' => true, ]]); $resp = @file_get_contents($url, false, $ctx); $httpCode = $resp !== false ? 200 : 0; $curlErr = ''; } if ($resp === false || $resp === '' || ($httpCode !== 0 && $httpCode !== 200)) { @file_put_contents('/tmp/vv_api_debug.json', json_encode([ 'ts' => time(), 'host' => $hostId, 'url' => $url, 'http_code' => $httpCode, 'curl_err' => $curlErr, 'response' => substr((string)$resp, 0, 800), ], JSON_PRETTY_PRINT)); return null; } $decoded = json_decode((string)$resp, true); // If the API returned GraphQL errors, log them for diagnosis. if (!empty($decoded['errors'])) { @file_put_contents('/tmp/vv_api_debug.json', json_encode([ 'ts' => time(), 'host' => $hostId, 'url' => $url, 'http_code' => $httpCode, 'errors' => $decoded['errors'], 'data' => $decoded['data'] ?? null, ], JSON_PRETTY_PRINT)); } // data key present (even if null means query ran but returned nothing useful). return array_key_exists('data', $decoded ?? []) ? $decoded['data'] : null; }