false, 'error' => $message]); exit(1); } function ini_sections(string $path): array { if (!is_readable($path)) return []; $result = parse_ini_file($path, true, INI_SCANNER_RAW); return is_array($result) ? $result : []; } function ini_flat(string $path): array { if (!is_readable($path)) return []; $result = parse_ini_file($path, false, INI_SCANNER_RAW); return is_array($result) ? $result : []; } function number_value($value): int|float { if (!is_numeric($value)) return 0; return str_contains((string)$value, '.') ? (float)$value : (int)$value; } function clean_text(string $text, int $max = 500): string { $text = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $text) ?? ''; $text = preg_replace('/((?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\s*[=:]\s*)([^\s,;]+)/i', '$1[REDACTED]', $text) ?? ''; $text = preg_replace('/("(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', $text) ?? ''; $text = trim(preg_replace('/\s+/', ' ', $text) ?? ''); return mb_substr($text, 0, $max); } function bytes_from_meminfo(string $key): int { $raw = @file_get_contents('/proc/meminfo') ?: ''; if (preg_match('/^' . preg_quote($key, '/') . ':\s+(\d+)\s+kB$/mi', $raw, $m)) { return (int)$m[1] * 1024; } return 0; } function thermal_readings(): array { $out = []; foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) { $chip = trim(@file_get_contents($dir . '/name') ?: basename($dir)); foreach (glob($dir . '/temp*_input') ?: [] as $input) { $raw = trim(@file_get_contents($input) ?: ''); if (!is_numeric($raw)) continue; $value = (float)$raw / 1000; if ($value < -20 || $value > 150) continue; $prefix = preg_replace('/_input$/', '', $input); $label = trim(@file_get_contents($prefix . '_label') ?: basename((string)$prefix)); $out[] = ['sensor' => $chip . ':' . $label, 'celsius' => round($value, 1)]; if (count($out) >= 24) break 2; } } return $out; } function system_health(): array { $total = bytes_from_meminfo('MemTotal'); $available = bytes_from_meminfo('MemAvailable'); $load = array_map('floatval', array_slice(preg_split('/\s+/', trim(@file_get_contents('/proc/loadavg') ?: '0 0 0')), 0, 3)); $uptime = (int)floor((float)explode(' ', trim(@file_get_contents('/proc/uptime') ?: '0'))[0]); return [ 'schema_version' => '1.0', 'hostname' => gethostname() ?: '', 'uptime_seconds' => $uptime, 'cpu_logical' => (int)trim((string)shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null')), 'load_average' => ['one' => $load[0] ?? 0, 'five' => $load[1] ?? 0, 'fifteen' => $load[2] ?? 0], 'memory' => [ 'total_bytes' => $total, 'available_bytes' => $available, 'used_bytes' => max(0, $total - $available), 'used_percent' => $total > 0 ? round((($total - $available) / $total) * 100, 1) : 0, ], 'temperatures' => thermal_readings(), ]; } function selected_disk(array $disk): array { $size = number_value($disk['fsSize'] ?? $disk['size'] ?? 0); $used = number_value($disk['fsUsed'] ?? 0); $free = number_value($disk['fsFree'] ?? 0); return [ 'name' => $disk['name'] ?? '', 'device' => $disk['device'] ?? '', 'type' => $disk['type'] ?? '', 'status' => $disk['status'] ?? '', 'state' => $disk['state'] ?? '', 'temperature_c' => number_value($disk['temp'] ?? 0), 'errors' => number_value($disk['numErrors'] ?? 0), 'filesystem' => $disk['fsType'] ?? '', 'filesystem_status' => $disk['fsStatus'] ?? '', 'size_bytes' => $size, 'used_bytes' => $used, 'free_bytes' => $free, 'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0, ]; } function storage_status(): array { $var = ini_flat('/var/local/emhttp/var.ini'); $disks = array_values(array_map('selected_disk', ini_sections('/var/local/emhttp/disks.ini'))); $disabled = number_value($var['mdNumDisabled'] ?? 0); $missing = number_value($var['mdNumMissing'] ?? 0); $invalid = number_value($var['mdNumInvalid'] ?? 0); $alerts = []; if ($disabled > 0) $alerts[] = "$disabled disk(s) disabled"; if ($missing > 0) $alerts[] = "$missing disk(s) missing"; if ($invalid > 0) $alerts[] = "$invalid disk(s) invalid"; return [ 'schema_version' => '1.0', 'array_state' => $var['mdState'] ?? '', 'filesystem_state' => $var['fsState'] ?? '', 'disabled_disks' => $disabled, 'missing_disks' => $missing, 'invalid_disks' => $invalid, 'parity' => [ 'running' => number_value($var['mdResync'] ?? 0) > 0, 'action' => $var['mdResyncAction'] ?? '', 'position' => number_value($var['mdResyncPos'] ?? 0), 'size' => number_value($var['mdResyncSize'] ?? 0), 'corrections' => number_value($var['mdResyncCorr'] ?? 0), ], 'alerts' => $alerts, 'disk_count' => count($disks), 'disks' => $disks, ]; } function smart_attributes(string $name): array { $path = '/var/local/emhttp/smart/' . $name; if (!is_readable($path)) return []; $wanted = [5, 9, 187, 188, 197, 198, 199, 202, 231, 241, 242]; $result = []; foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { if (!preg_match('/^\s*(\d+)\s+([A-Za-z0-9_-]+)\s+.*?\s(-|FAILING_NOW)\s+(.+)$/', $line, $m)) continue; $id = (int)$m[1]; if (!in_array($id, $wanted, true)) continue; $result[] = ['id' => $id, 'name' => $m[2], 'when_failed' => $m[3], 'raw' => clean_text($m[4], 80)]; } return $result; } function disk_health(?string $requested): array { $sections = ini_sections('/var/local/emhttp/disks.ini'); $result = []; foreach ($sections as $section => $disk) { $name = trim((string)($disk['name'] ?? trim($section, '"'))); if ($requested !== null && $requested !== '' && $name !== $requested) continue; $entry = selected_disk($disk); $entry['smart_attributes'] = smart_attributes($name); $entry['healthy'] = number_value($disk['numErrors'] ?? 0) === 0 && !in_array($disk['status'] ?? '', ['DISK_DSBL', 'DISK_NP'], true); $result[] = $entry; } if ($requested && count($result) === 0) fail_status('Unknown disk name'); return ['schema_version' => '1.0', 'disk_count' => count($result), 'disks' => $result]; } function notification_list(int $limit, ?string $importance): array { $limit = max(1, min(50, $limit)); $files = array_merge(glob('/tmp/notifications/*.notify') ?: [], glob('/tmp/notifications/archive/*.notify') ?: []); usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a)); $items = []; foreach ($files as $file) { $n = parse_ini_file($file, false, INI_SCANNER_RAW); if (!is_array($n)) continue; $level = trim((string)($n['importance'] ?? 'normal'), '"'); if ($importance && $importance !== 'all' && $level !== $importance) continue; $items[] = [ 'timestamp' => number_value(trim((string)($n['timestamp'] ?? filemtime($file)), '"')), 'event' => clean_text(trim((string)($n['event'] ?? ''), '"'), 100), 'importance' => $level, 'subject' => clean_text(trim((string)($n['subject'] ?? ''), '"'), 180), 'description' => clean_text(trim((string)($n['description'] ?? ''), '"'), 500), 'archived' => str_contains($file, '/archive/'), ]; if (count($items) >= $limit) break; } return ['schema_version' => '1.0', 'count' => count($items), 'notifications' => $items]; } function share_entry(array $share, bool $details): array { $size = number_value($share['size'] ?? 0); $used = number_value($share['used'] ?? 0); $entry = [ 'name' => $share['name'] ?? '', 'pool' => $share['cachePool'] ?? '', 'secondary_pool' => $share['cachePool2'] ?? '', 'size_bytes' => $size, 'used_bytes' => $used, 'free_bytes' => number_value($share['free'] ?? 0), 'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0, 'exclusive' => ($share['exclusive'] ?? '') === 'yes', 'status_color' => $share['color'] ?? '', ]; if ($details) { $entry += [ 'comment' => clean_text((string)($share['comment'] ?? ''), 300), 'allocation_method' => $share['allocator'] ?? '', 'split_level' => $share['splitLevel'] ?? '', 'minimum_free_space' => number_value($share['floor'] ?? 0), 'included_disks' => $share['include'] ?? '', 'excluded_disks' => $share['exclude'] ?? '', 'cache_mode' => $share['useCache'] ?? '', 'has_config' => ($share['hasCfg'] ?? '') === 'yes', ]; } return $entry; } function shares_list(?string $requested): array { $sections = ini_sections('/var/local/emhttp/shares.ini'); $items = []; foreach ($sections as $section => $share) { $name = (string)($share['name'] ?? trim($section, '"')); if ($requested !== null && $requested !== '' && $name !== $requested) continue; $items[] = share_entry($share, $requested !== null && $requested !== ''); } if ($requested && count($items) === 0) fail_status('Unknown share name'); return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items]; } function files_inventory( string $share, string $relative_path, string $name_contains, int $max_depth, int $max_entries, bool $include_files, bool $include_directories ): array { $known = []; foreach (ini_sections('/var/local/emhttp/shares.ini') as $section => $entry) { $name = (string)($entry['name'] ?? trim($section, '"')); if ($name !== '') $known[$name] = true; } if (!isset($known[$share])) fail_status('Unknown share name'); if ($relative_path !== '' && ($relative_path[0] === '/' || str_contains($relative_path, "\0"))) { fail_status('relative_path must stay below the selected share'); } $parts = array_values(array_filter(explode('/', str_replace('\\', '/', $relative_path)), fn($p) => $p !== '')); if (in_array('..', $parts, true) || in_array('.', $parts, true)) { fail_status('relative_path traversal is not allowed'); } $relative_path = implode('/', $parts); $root = '/mnt/user/' . $share . ($relative_path !== '' ? '/' . $relative_path : ''); if (!is_dir($root) || !is_readable($root)) fail_status('Selected inventory root is not a readable directory'); $max_depth = max(1, min(20, $max_depth)); $max_entries = max(1, min(5000, $max_entries)); $needle = mb_strtolower(trim($name_contains)); $queue = [[$root, '', 0]]; $entries = []; $scanned = 0; // Discovery often needs to inspect many names while returning only a few // matching directories. Keep the scan bounded, but do not tie it too // tightly to the small result limit chosen for directory discovery. $max_scanned = max(25000, min(250000, $max_entries * 100)); $truncated = false; while ($queue) { [$directory, $directory_relative, $depth] = array_shift($queue); if ($depth >= $max_depth) continue; $children = @scandir($directory); if (!is_array($children)) continue; natcasesort($children); foreach ($children as $name) { if ($name === '.' || $name === '..') continue; $full = $directory . '/' . $name; $relative = $directory_relative === '' ? $name : $directory_relative . '/' . $name; $is_link = is_link($full); $is_dir = !$is_link && is_dir($full); $matches = $needle === '' || mb_stripos($name, $needle) !== false; $selected = $matches; $scanned++; if ($scanned > $max_scanned) { $truncated = true; break 2; } if ($selected && (($is_dir && $include_directories) || (!$is_dir && $include_files))) { $extension = $is_dir ? '' : strtolower((string)pathinfo($name, PATHINFO_EXTENSION)); $entries[] = [ 'relative_path' => $relative, 'name' => $name, 'type' => $is_link ? 'symlink' : ($is_dir ? 'directory' : 'file'), 'extension' => $extension, 'size_bytes' => (!$is_dir && !$is_link) ? max(0, (int)@filesize($full)) : 0, 'depth' => $depth + 1, ]; if (count($entries) >= $max_entries) { $truncated = true; break 2; } } // A filtered discovery call is meant to locate collection roots. // Once a directory itself matches, return that root but do not also // flood the bounded result with matching descendants. A follow-up // call with this path and an empty filter inventories the collection. if ($is_dir && !($needle !== '' && $matches)) { $queue[] = [$full, $relative, $depth + 1]; } } } return [ 'schema_version' => '1.0', 'share' => $share, 'inventory_root' => $relative_path, 'name_contains' => $name_contains, 'entry_count' => count($entries), 'scanned_entries' => $scanned, 'truncated' => $truncated, 'max_depth' => $max_depth, 'max_entries' => $max_entries, 'max_scanned_entries' => $max_scanned, 'content_read' => false, 'entries' => $entries, ]; } function docker_update_state_value(mixed $value): ?string { if (is_array($value)) { foreach (['update_available', 'updateAvailable'] as $field) { if (!array_key_exists($field, $value)) continue; $flag = $value[$field]; if (is_bool($flag)) return $flag ? 'update-available' : 'up-to-date'; $flag = strtolower(trim((string)$flag)); if (in_array($flag, ['true', '1', 'yes'], true)) return 'update-available'; if (in_array($flag, ['false', '0', 'no'], true)) return 'up-to-date'; } foreach (['status', 'updated', 'update_status', 'updateStatus'] as $field) { if (array_key_exists($field, $value)) return docker_update_state_value($value[$field]); } return null; } if (is_bool($value)) return $value ? 'up-to-date' : 'update-available'; if (!is_string($value) && !is_int($value)) return null; $state = strtolower(trim((string)$value)); if (in_array($state, ['true', '1', 'up-to-date', 'up_to_date', 'current', 'updated'], true)) { return 'up-to-date'; } if (in_array($state, ['false', '0', 'update-available', 'update_available', 'outdated'], true)) { return 'update-available'; } return null; } function docker_image_aliases(string $image): array { $image = trim($image); $without_digest = preg_replace('/@sha256:[a-f0-9]+$/i', '', $image) ?? $image; $aliases = [$image, $without_digest]; $last_part = basename($without_digest); if (!str_contains($last_part, ':')) $aliases[] = $without_digest . ':latest'; if (!str_contains($without_digest, '/')) { $aliases[] = 'library/' . $without_digest; if (!str_contains($last_part, ':')) $aliases[] = 'library/' . $without_digest . ':latest'; } elseif (str_starts_with($without_digest, 'library/')) { $aliases[] = substr($without_digest, strlen('library/')); } return array_values(array_unique(array_filter($aliases, fn($value) => $value !== ''))); } function docker_update_state(array $updates, string $name, string $image): ?string { // Unraid's native Docker manager stores the cache by container name. // Older/plugin-generated caches may instead use an image reference, so // keep those forms as compatibility fallbacks. $cache = isset($updates['containers']) && is_array($updates['containers']) ? $updates['containers'] : $updates; $keys = array_values(array_unique(array_merge([$name], docker_image_aliases($image)))); $key_lookup = []; foreach ($cache as $key => $_value) { if (is_string($key)) $key_lookup[strtolower($key)] = $key; } foreach ($keys as $candidate) { $actual = $key_lookup[strtolower($candidate)] ?? null; if ($actual === null) continue; $state = docker_update_state_value($cache[$actual]); if ($state !== null) return $state; } // Also understand list/digest keyed formats that carry their identity in // the entry itself. This prevents a future Unraid cache format change from // turning every container into an unexplained `unknown` again. foreach ($cache as $entry) { if (!is_array($entry)) continue; $entry_name = (string)($entry['container'] ?? $entry['container_name'] ?? $entry['name'] ?? ''); $entry_image = (string)($entry['image'] ?? $entry['repository'] ?? ''); $name_matches = $entry_name !== '' && strcasecmp($entry_name, $name) === 0; $image_matches = $entry_image !== '' && in_array( strtolower($entry_image), array_map('strtolower', docker_image_aliases($image)), true, ); if (!$name_matches && !$image_matches) continue; $state = docker_update_state_value($entry); if ($state !== null) return $state; } return null; } function docker_update_status_from(array $updates, array $rows): array { $items = []; foreach ($rows as $row) { [$name, $image] = array_pad(explode('|', $row, 2), 2, ''); $state = docker_update_state($updates, $name, $image); $items[] = [ 'container' => $name, 'image' => $image, 'status' => $state ?? 'unknown', ]; } $available = count(array_filter($items, fn($x) => $x['status'] === 'update-available')); $unknown = count(array_filter($items, fn($x) => $x['status'] === 'unknown')); return [ 'schema_version' => '1.1', 'container_count' => count($items), 'updates_available' => $available, 'unknown_count' => $unknown, 'status_complete' => $unknown === 0, 'updates_available_is_minimum' => $unknown > 0, 'containers' => $items, ]; } function docker_update_status(): array { $path = '/var/lib/docker/unraid-update-status.json'; $updates = is_readable($path) ? json_decode((string)file_get_contents($path), true) : []; if (!is_array($updates)) $updates = []; $rows = []; exec('/usr/bin/docker ps -a --format ' . escapeshellarg('{{.Names}}|{{.Image}}'), $rows); return docker_update_status_from($updates, $rows); } if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { $action = $argv[1] ?? ''; switch ($action) { case 'system-health': respond(system_health()); break; case 'storage-status': respond(storage_status()); break; case 'disk-health': respond(disk_health($argv[2] ?? null)); break; case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break; case 'shares-list': respond(shares_list(null)); break; case 'share-inspect': respond(shares_list($argv[2] ?? '')); break; case 'files-inventory': respond(files_inventory( $argv[2] ?? '', $argv[3] ?? '', $argv[4] ?? '', (int)($argv[5] ?? 10), (int)($argv[6] ?? 2000), ($argv[7] ?? '1') !== '0', ($argv[8] ?? '1') !== '0' )); break; case 'docker-update-status': respond(docker_update_status()); break; default: fail_status('Unknown read-only status action'); } }