461 lines
20 KiB
PHP
461 lines
20 KiB
PHP
<?php
|
|
/**
|
|
* MUA read-only Unraid status helper.
|
|
*
|
|
* It deliberately returns compact, selected fields instead of raw Unraid
|
|
* configuration files, SMART dumps or notification archives.
|
|
*/
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', '0');
|
|
|
|
function respond(array $value): void {
|
|
echo json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
|
}
|
|
|
|
function fail_status(string $message): void {
|
|
respond(['ok' => 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;
|
|
}
|
|
}
|
|
if ($is_dir) $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');
|
|
}
|
|
}
|