release r010 add Unraid health and Community Apps tools

This commit is contained in:
Mikei386
2026-08-21 10:14:16 +02:00
parent e586b22b13
commit 771b8c43a1
14 changed files with 831 additions and 33 deletions
+4 -1
View File
@@ -71,10 +71,13 @@ $readonly = [
'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats',
'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list',
'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test',
'unraid_system_shell_readonly',
'unraid_system_shell_readonly', 'unraid_docker_update_status',
'unraid_system_health', 'unraid_storage_status', 'unraid_disk_health',
'unraid_notifications_list', 'unraid_shares_list', 'unraid_share_inspect',
];
$operator = array_merge($readonly, [
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
'unraid_ca_search', 'unraid_ca_install_preview',
'unraid_docker_start', 'unraid_docker_stop', 'unraid_docker_restart',
]);
+5 -1
View File
@@ -65,6 +65,10 @@ chmod 644 "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page"
cp "${ROOT}/scripts/unraid-docker-mcp-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
# Kompakter Read-only-Helper für Unraid-Systemzustand
cp "${ROOT}/scripts/unraid-mcp-status-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-mcp-status-helper.php"
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-mcp-status-helper.php"
# 3. doinst.sh (läuft nach Installation)
cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF'
#!/bin/bash
@@ -100,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|${PKG_NAME} - Mikes Unraid Agent
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|23 Tools: Docker (14), Netzwerk (6), System (3).
|33 Tools: Docker, Community Applications, Netzwerk und Unraid-System.
|Port: 3002, Endpunkt: /mcp
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
+81 -1
View File
@@ -9,6 +9,7 @@
* modify <container> <field> <value>
* update <container>
* rebuild <container>
* ca-install <template_url> <container_name> <overrides_json> <start:true|false>
*
* modify fields:
* port value: newExternalPort (z.B. "8466")
@@ -242,6 +243,80 @@ function modify_template(string $name, string $field, string $value): void {
rebuild_container($name, false, true);
}
function ca_install(string $template_url, string $name, string $overrides_json, bool $start): void {
if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $name)) {
fail('Invalid container name');
}
$url = parse_url($template_url);
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host'])) {
fail('Community Applications template must use HTTPS');
}
$host = strtolower((string)$url['host']);
if ($host === 'localhost' || str_ends_with($host, '.local')) {
fail('Private template hosts are rejected');
}
if (filter_var($host, FILTER_VALIDATE_IP) &&
!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
fail('Private or reserved template IPs are rejected');
}
$template_path = "/boot/config/plugins/dockerMan/templates-user/my-$name.xml";
if (file_exists($template_path)) fail("Template already exists: $name");
$existing = trim(docker_exec('inspect --type container --format {{.Name}} ' . escapeshellarg($name)));
if ($existing !== '' && stripos($existing, 'Error:') !== 0) fail("Container already exists: $name");
$context = stream_context_create(['http' => [
'timeout' => 30,
'follow_location' => 0,
'user_agent' => 'MUA Community Applications Installer',
]]);
$xml = @file_get_contents($template_url, false, $context, 0, 1024 * 1024 + 1);
if ($xml === false || strlen($xml) === 0) fail('Unable to download Community Applications template');
if (strlen($xml) > 1024 * 1024) fail('Community Applications template is too large');
$dom = new DOMDocument();
$old = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOBLANKS);
libxml_clear_errors();
libxml_use_internal_errors($old);
if (!$loaded || !$dom->documentElement || $dom->documentElement->nodeName !== 'Container') {
fail('Invalid Community Applications container template');
}
$repositories = $dom->getElementsByTagName('Repository');
if ($repositories->length !== 1 || trim($repositories->item(0)->textContent) === '') {
fail('Template has no valid Docker repository');
}
$names = $dom->getElementsByTagName('Name');
if ($names->length === 0) {
$name_node = $dom->createElement('Name', $name);
$dom->documentElement->insertBefore($name_node, $dom->documentElement->firstChild);
} else {
$names->item(0)->nodeValue = $name;
}
$overrides = json_decode($overrides_json, true);
if (!is_array($overrides) || count($overrides) > 32) fail('Invalid overrides object');
foreach ($overrides as $target => $value) {
if (!is_string($target) || !is_string($value) || strlen($value) > 4096) fail('Invalid override');
$found = false;
foreach ($dom->getElementsByTagName('Config') as $config) {
if ($config->getAttribute('Target') === $target) {
set_config_value($config, $value);
$found = true;
break;
}
}
if (!$found) fail("Override target not present in template: $target");
}
$dom->formatOutput = true;
if ($dom->save($template_path) === false) fail('Unable to write Unraid user template');
chmod($template_path, 0600);
out("Community Applications template saved: $name");
rebuild_container($name, true, $start);
out('Container is managed by the Unraid user template');
}
// ── Main ─────────────────────────────────────────────────────────────────
$argv = $_SERVER['argv'];
$action = $argv[1] ?? '';
@@ -273,6 +348,11 @@ switch ($action) {
rebuild_container($arg1, false);
break;
case 'ca-install':
if (!$arg1 || !$arg2) fail('Usage: ca-install <template_url> <container_name> <overrides_json> <start:true|false>');
ca_install($arg1, $arg2, $arg3 ?: '{}', ($argv[5] ?? 'false') === 'true');
break;
default:
fail("Unknown action: $action. Usage: create|modify|update|rebuild");
fail("Unknown action: $action. Usage: create|modify|update|rebuild|ca-install");
}
+269
View File
@@ -0,0 +1,269 @@
<?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 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);
$items = [];
foreach ($rows as $row) {
[$name, $image] = array_pad(explode('|', $row, 2), 2, '');
$key = $image;
if (!isset($updates[$key]) && !str_contains($key, '/')) $key = 'library/' . $key;
$state = $updates[$key]['status'] ?? null;
$items[] = [
'container' => $name,
'image' => $image,
'status' => $state === 'true' ? 'up-to-date' : ($state === 'false' ? 'update-available' : 'unknown'),
];
}
$available = count(array_filter($items, fn($x) => $x['status'] === 'update-available'));
return ['schema_version' => '1.0', 'container_count' => count($items), 'updates_available' => $available, 'containers' => $items];
}
$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 'docker-update-status': respond(docker_update_status()); break;
default: fail_status('Unknown read-only status action');
}