commit f4e66ea5a171027a35ce2dd69bd9f9bed37346ac Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon Aug 17 22:00:48 2026 +0200 MUA v1.0.0 — Mikes Unraid Agent Natives Unraid-Plugin: 21 Docker-, Netzwerk- und System-Tools als MCP-Server (Streamable HTTP) auf Port 3002. - mcp/server.php: MCP-Server (JSON-RPC 2.0, Streamable HTTP) - mcp/helpers.php: Lokale Tool-Implementierungen (kein SSH) - mcp/tools.php: 21 Tool-Definitionen - mcp/selftest.php: Selftest (12 Checks) - scripts/unraid-docker-mcp-helper.php: Write-Operationen - scripts/mua.service: systemd-Service - scripts/install.sh, update.sh, remove.sh: Plugin-Scripts - MUA.plg: Plugin-Manifest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1c6b20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# PHP +*.php.swp +*.php.swo +.phpunit.result.cache + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.iml + +# Logs +*.log + +# Temp +/tmp/ +*.tmp diff --git a/MUA.plg b/MUA.plg new file mode 100644 index 0000000..abeebd1 --- /dev/null +++ b/MUA.plg @@ -0,0 +1,71 @@ + + + mua + 1.0.0 + 1 + x86_64 + https://git-192.168.1.2:33/michael/MUA-Mikes-Unraid-Agent + MUA - Mikes Unraid Agent (MCP Server) + MUA (Mikes Unraid Agent) is a native Unraid plugin that exposes 21 Docker, network, and system tools as an MCP (Model Context Protocol) server over HTTP (Streamable HTTP transport). Runs locally on the Unraid host - no SSH required. Endpoint: http://<unraid-ip>:3002/mcp + Michael + MIT + + Tools + + + + mcp/server.php + /usr/local/plugins/mua/mcp/ + 0644 + + + mcp/helpers.php + /usr/local/plugins/mua/mcp/ + 0644 + + + mcp/tools.php + /usr/local/plugins/mua/mcp/ + 0644 + + + scripts/unraid-docker-mcp-helper.php + /usr/local/plugins/mua/scripts/ + 0755 + + + scripts/mua.service + /usr/local/plugins/mua/scripts/ + 0644 + + + scripts/install.sh + /usr/local/plugins/mua/scripts/ + 0755 + + + scripts/update.sh + /usr/local/plugins/mua/scripts/ + 0755 + + + scripts/remove.sh + /usr/local/plugins/mua/scripts/ + 0755 + + + + + install + + + + update + + + + remove + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d58026 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# MUA — Mikes Unraid Agent + +Natives Unraid-Plugin, das 21 Docker-, Netzwerk- und System-Tools als **MCP-Server** (Model Context Protocol) über **HTTP (Streamable HTTP)** exponiert. + +Läuft **lokal auf dem Unraid-Host** — kein SSH, kein Proxy, kein Key-Management. + +## Endpunkt + +``` +http://:3002/mcp +``` + +- **POST** `/mcp` — JSON-RPC 2.0 (MCP-Requests) +- **GET** `/health` — Health-Check +- **DELETE** `/mcp` — Session-End + +## Tools (21) + +### Docker (14) +| Tool | Beschreibung | +|------|-------------| +| `unraid_docker_list` | Alle Container mit Runtime-Stats | +| `unraid_docker_inspect` | Container-Details | +| `unraid_docker_logs` | Container-Logs | +| `unraid_docker_analyze_logs` | Log-Analyse (server-seitig) | +| `unraid_docker_processes` | Prozesse im Container | +| `unraid_docker_stats` | Live-Stats (CPU/RAM/Net) | +| `unraid_docker_info` | Docker-Daemon-Info | +| `unraid_docker_start` | Container starten | +| `unraid_docker_stop` | Container stoppen | +| `unraid_docker_restart` | Container neu starten | +| `unraid_docker_create` | Container aus Template | +| `unraid_docker_modify` | Template ändern | +| `unraid_docker_update` | Container aktualisieren | +| `unraid_docker_rebuild` | Container neu bauen | + +### Netzwerk (6) +| Tool | Beschreibung | +|------|-------------| +| `unraid_network_inventory` | Netzwerk-Inventur | +| `unraid_network_list` | Alle Netzwerke | +| `unraid_network_inspect` | Netzwerk-Details | +| `unraid_network_host_state` | Host-Netzwerk-Zustand | +| `unraid_network_audit_tcp` | TCP-Endpunkte auditieren | +| `unraid_network_lan_probe` | Dualstack-Probe | + +### System (1) +| Tool | Beschreibung | +|------|-------------| +| `unraid_system_connection_test` | Verbindungstest | + +## Installation + +### Via Community Apps (Link) +1. Community Apps öffnen +2. "Add Plugin" → Link: `https://git-192.168.1.2:33/michael/MUA-Mikes-Unraid-Agent` +3. Installieren + +### Manuell +```bash +# Dateien kopieren +mkdir -p /usr/local/plugins/mua +cp -r mcp scripts /usr/local/plugins/mua/ + +# Helper installieren +cp scripts/unraid-docker-mcp-helper.php /usr/local/bin/ +chmod 755 /usr/local/bin/unraid-docker-mcp-helper.php + +# Service installieren +cp scripts/mua.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now mua.service + +# Health-Check +curl http://127.0.0.1:3002/health +``` + +## Anbindung an MCP-Client + +### Beispiel: Hermes Agent (config.yaml) +```yaml +mcp_servers: + mua: + url: http://192.168.1.2:3002/mcp + transport: http +``` + +### Beispiel: curl +```bash +# Initialize +curl -X POST http://192.168.1.2:3002/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + +# Tools auflisten +curl -X POST http://192.168.1.2:3002/mcp \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: " \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +# Tool aufrufen +curl -X POST http://192.168.1.2:3002/mcp \ + -H "Content-Type: application/json" \ + -H "Mcp-Session-Id: " \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"unraid_docker_list","arguments":{}}}' +``` + +## Selftest + +```bash +php /usr/local/plugins/mua/mcp/selftest.php +``` + +## Architektur + +``` +MCP-Client (z.B. llama.cpp auf 196) + │ + │ HTTP (Streamable HTTP) + ▼ +http://192.168.1.2:3002/mcp (MUA Plugin auf Unraid) + │ + │ lokal (kein SSH) + ▼ +/usr/bin/docker, /usr/bin/ip, /usr/bin/ss, /usr/bin/php +``` + +## Port + +- **3002** (3000 ist belegt von `theippenguin/unraid-mcp`) + +## Git + +- Repo: `michael/MUA-Mikes-Unraid-Agent.git` +- Zugang: `git@192.168.1.2:33` (SSH-Key `ha_agent`) +- Updates: Neuer Commit + Push + +## Lizenz + +MIT diff --git a/mcp/helpers.php b/mcp/helpers.php new file mode 100644 index 0000000..ea2782c --- /dev/null +++ b/mcp/helpers.php @@ -0,0 +1,471 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes); + if (!is_resource($proc)) { + throw new RuntimeException("proc_open failed for: $label"); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $output = ''; + $error = ''; + $start = microtime(true); + while (true) { + $out = fread($pipes[1], 65536); + $err = fread($pipes[2], 65536); + if ($out !== false && $out !== '') $output .= $out; + if ($err !== false && $err !== '') $error .= $err; + if (feof($pipes[1]) && feof($pipes[2])) break; + if (microtime(true) - $start > $timeout) { + proc_terminate($proc, 9); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + throw new RuntimeException("Timeout after {$timeout}s: $label"); + } + usleep(5000); + } + fclose($pipes[1]); + fclose($pipes[2]); + $rc = proc_close($proc); + + $result = trim($output); + if ($result === '' && $error !== '') { + $result = trim($error); + } + return $result; +} + +/** + * Docker-Befehl ausführen (kompakt). + */ +function docker_exec(string $cmd, int $timeout = 60): string { + return run_local('docker', "/usr/bin/docker $cmd 2>&1", $timeout); +} + +/** + * Validiere einen Namen (Container, Network, Host, Template). + * Verhindert Shell-Injection. + * + * @param mixed $value + * @param string $field + * @return string + * @throws InvalidArgumentException + */ +function validate_name($value, string $field): string { + if (!is_string($value) || $value === '') { + throw new InvalidArgumentException("$field is required"); + } + // Erlaubt: Buchstaben, Ziffern, -, _, . + if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $value)) { + throw new InvalidArgumentException("Invalid $field: $value"); + } + return $value; +} + +/** + * JSON-Lines parsen (eine JSON-Objekt pro Zeile). + */ +function json_lines(string $text): array { + $result = []; + foreach (explode("\n", $text) as $line) { + $line = trim($line); + if ($line === '') continue; + $decoded = json_decode($line, true); + if (json_last_error() === JSON_ERROR_NONE) { + $result[] = $decoded; + } + } + return $result; +} + +/** + * Extrahiere Host-Adressen aus `ip -j address show` + `ss`-Output. + * + * @return array{ipv4:string, ipv6:string, public_ipv6:string} + */ +function host_addresses(string $raw): array { + // Robust: JSON parsen statt Regex + $data = json_decode($raw, true); + if (!is_array($data)) { + return ['ipv4' => '', 'ipv6' => '', 'public_ipv6' => '']; + } + + $ipv4 = ''; + $ipv6 = ''; + $public_ipv6 = ''; + + foreach ($data as $iface) { + $ifname = $iface['ifname'] ?? ''; + if ($ifname === 'lo') continue; // Loopback überspringen + + foreach ($iface['addr_info'] ?? [] as $addr) { + $local = $addr['local'] ?? ''; + $family = $addr['family'] ?? ''; + + if ($family === 'inet') { + // IPv4: erste private Adresse + if ($local !== '127.0.0.1' && $local !== '0.0.0.0' && $ipv4 === '') { + $ipv4 = $local; + } + } elseif ($family === 'inet6') { + // IPv6: Link-Local (fe80::) + if (strpos($local, 'fe80') === 0 && $ipv6 === '') { + $ipv6 = $local; + } + // Public IPv6 (global, nicht fe80/fd/fc/::1) + if (strpos($local, 'fe80') !== 0 + && strpos($local, 'fd') !== 0 + && strpos($local, 'fc') !== 0 + && $local !== '::1' + && $public_ipv6 === '') { + $public_ipv6 = $local; + } + } + } + } + + return ['ipv4' => $ipv4, 'ipv6' => $ipv6, 'public_ipv6' => $public_ipv6]; +} + +/** + * TCP-Port probe (IPv4 oder IPv6). + */ +function tcp_probe(string $host, int $port, int $family, float $timeout): array { + if ($host === '') { + return ['reachable' => false, 'error' => 'no host address']; + } + $addr = $family === AF_INET6 ? "[$host]" : $host; + $context = stream_context_create([ + 'tcp' => ['timeout' => $timeout, 'binary_package' => true], + ]); + $start = microtime(true); + $fp = @fsockopen($addr, $port, $errno, $errstr, $timeout, $family === AF_INET6 ? STREAM_CLIENT_IPPROTO_V6 : 0); + $elapsed = (microtime(true) - $start) * 1000; + if ($fp) { + fclose($fp); + return ['reachable' => true, 'latency_ms' => round($elapsed, 1)]; + } + return ['reachable' => false, 'error' => $errstr ?: "errno $errno"]; +} + +/** + * Sanitize Log-Output (entferne Control-Chars, begrenze Länge). + */ +function sanitize_log_output(string $text, int $max_chars = 50000): string { + // Entferne ANSI-Escape-Sequenzen + $text = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]/', '', $text); + // Entferne andere Control-Chars (außer \n, \r, \t) + $text = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $text); + // Begrenze Länge + if (strlen($text) > $max_chars) { + $text = '... [truncated] ...' . substr($text, -$max_chars); + } + return $text; +} + +/** + * Kompakte Container-Inspect-Ausgabe. + */ +function compact_container_inspect(string $raw): string { + $data = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + return $raw; + } + // Docker inspect gibt ein Array mit einem Element zurück + if (isset($data[0])) { + $data = $data[0]; + } + $compact = [ + 'Id' => substr($data['Id'] ?? '', 0, 12), + 'Name' => $data['Name'] ?? '', + 'State' => [ + 'Status' => $data['State']['Status'] ?? '', + 'Running' => $data['State']['Running'] ?? false, + 'Pid' => $data['State']['Pid'] ?? 0, + 'ExitCode' => $data['State']['ExitCode'] ?? 0, + ], + 'Image' => $data['Config']['Image'] ?? '', + 'NetworkMode' => $data['HostConfig']['NetworkMode'] ?? '', + 'Ports' => $data['NetworkSettings']['Ports'] ?? [], + 'Env' => $data['Config']['Env'] ?? [], + 'Mounts' => array_map(function ($m) { + return [ + 'Type' => $m['Type'] ?? '', + 'Source' => $m['Source'] ?? '', + 'Destination' => $m['Destination'] ?? '', + ]; + }, $data['Mounts'] ?? []), + 'RestartCount' => $data['RestartCount'] ?? 0, + 'Created' => $data['Created'] ?? '', + ]; + return json_encode($compact, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +/** + * Container-Runtime-Zusammenfassung (docker ps + docker stats). + */ +function container_runtime_summary(): string { + $ps = docker_exec("ps -a --format '{{json .}}'"); + $containers = json_lines($ps); + + // docker stats für laufende Container + $running_ids = array_filter(array_map(function ($c) { + return $c['ID'] ?? ''; + }, $containers)); + + $stats = []; + if (!empty($running_ids)) { + $stats_raw = docker_exec("stats --no-stream --format '{{json .}}'"); + foreach (json_lines($stats_raw) as $s) { + $stats[$s['ID'] ?? ''] = $s; + } + } + + $result = []; + foreach ($containers as $c) { + $id = $c['ID'] ?? ''; + $entry = [ + 'id' => substr($id, 0, 12), + 'name' => $c['Names'] ?? '', + 'image' => $c['Image'] ?? '', + 'status' => $c['Status'] ?? '', + 'state' => $c['State'] ?? '', + 'ports' => $c['Ports'] ?? '', + ]; + if (isset($stats[$id])) { + $entry['cpu_percent'] = $stats[$id]['CPUPerc'] ?? ''; + $entry['mem_usage'] = $stats[$id]['MemUsage'] ?? ''; + $entry['mem_percent'] = $stats[$id]['MemPerc'] ?? ''; + $entry['net_io'] = $stats[$id]['NetIO'] ?? ''; + $entry['block_io'] = $stats[$id]['BlockIO'] ?? ''; + } + $result[] = $entry; + } + + return json_encode([ + 'schema_version' => '1.0', + 'container_count' => count($result), + 'containers' => $result, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +/** + * Kompakte Netzwerk-Inventur. + */ +function compact_network_inventory(array $args): string { + $networks_raw = docker_exec("network ls --no-trunc --format '{{json .}}'"); + $networks = json_lines($networks_raw); + + $result = []; + foreach ($networks as $n) { + $entry = [ + 'name' => $n['Name'] ?? '', + 'id' => substr($n['ID'] ?? '', 0, 12), + 'driver' => $n['Driver'] ?? '', + 'scope' => $n['Scope'] ?? '', + ]; + // Container-Count via inspect + $inspect = docker_exec("network inspect --format '{{len .Containers}}' {$n['Name']}"); + $entry['container_count'] = (int)trim($inspect); + $result[] = $entry; + } + + return json_encode([ + 'schema_version' => '1.0', + 'network_count' => count($result), + 'networks' => $result, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +/** + * Container-Logs analysieren (server-seitig). + */ +function analyze_container_logs(?string $severity, ?string $container, string $since, int $scan_tail, int $max_results): string { + $severity_levels = [ + 'error' => ['error', 'fatal', 'panic', 'exception', 'traceback', 'critical'], + 'warn' => ['warn', 'warning', 'deprecated'], + 'info' => ['info', 'started', 'listening', 'ready'], + ]; + + $patterns = $severity_levels[$severity] ?? $severity_levels['error']; + $regex = '/(' . implode('|', array_map('preg_quote', $patterns)) . ')/i'; + + // Hole Logs + $log_cmd = "logs --timestamps --since $since --tail $scan_tail"; + if ($container) { + $log_cmd .= " $container"; + } + $raw = docker_exec($log_cmd); + $lines = explode("\n", $raw); + + $matches = []; + $counts = []; + foreach ($lines as $line) { + if (preg_match($regex, $line, $m)) { + $key = strtolower($m[1]); + $counts[$key] = ($counts[$key] ?? 0) + 1; + if (count($matches) < $max_results) { + $matches[] = [ + 'pattern' => $m[1], + 'line' => mb_substr(trim($line), 0, 300), + ]; + } + } + } + + return json_encode([ + 'schema_version' => '1.0', + 'severity' => $severity, + 'container' => $container, + 'since' => $since, + 'scan_tail' => $scan_tail, + 'total_matches' => array_sum($counts), + 'pattern_counts' => $counts, + 'sample_matches' => $matches, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +/** + * Dualstack-LAN-Probe. + */ +function probe_dualstack(string $host, int $port, float $timeout): string { + $ipv4 = tcp_probe($host, $port, AF_INET, $timeout); + $ipv6 = tcp_probe($host, $port, AF_INET6, $timeout); + return json_encode([ + 'host' => $host, + 'port' => $port, + 'ipv4' => $ipv4, + 'ipv6' => $ipv6, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +/** + * Alle TCP-Endpunkte auditieren (komplexes Tool). + */ +function audit_all_tcp_endpoints(float $timeout, bool $include_all_endpoints = false): string { + // Container-Inventur + $inventory_cmd = "ids=\$(docker ps -aq); [ -z \"\$ids\" ] || docker inspect --type container --format '{\"ID\":{{json .Id}},\"Name\":{{json .Name}},\"Image\":{{json .Config.Image}},\"Status\":{{json .State.Status}},\"Running\":{{json .State.Running}},\"Health\":{{json (index .State \"Health\")}},\"NetworkMode\":{{json .HostConfig.NetworkMode}},\"ExposedPorts\":{{json (index .Config \"ExposedPorts\")}},\"Ports\":{{json .NetworkSettings.Ports}}}' \$ids"; + $containers = json_lines(run_local('audit', $inventory_cmd)); + + // Host-Netzwerk + $host_cmd = "printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup"; + $host_raw = run_local('audit', $host_cmd); + $addrs = host_addresses($host_raw); + + $by_id = []; + foreach ($containers as $item) { + $by_id[$item['ID'] ?? ''] = $item; + } + + $endpoints = []; + $inactive = []; + $no_tcp = []; + $udp = []; + $endpoint_keys = []; + + foreach ($containers as $item) { + $name = ltrim($item['Name'] ?? '', '/'); + if (!($item['Running'] ?? false)) { + $inactive[] = ['container' => $name, 'status' => $item['Status'] ?? '']; + continue; + } + $mode = $item['NetworkMode'] ?? 'unknown'; + $found_tcp = false; + foreach (($item['Ports'] ?? []) as $container_port => $bindings) { + $protocol = substr($container_port, strrpos($container_port, '/') + 1); + if ($protocol === 'udp' && $bindings) { + $udp[] = ['container' => $name, 'container_port' => $container_port]; + continue; + } + if ($protocol !== 'tcp' || !$bindings) continue; + foreach ($bindings as $binding) { + if (!empty($binding['HostPort'])) { + $key = "$name:{$binding['HostPort']}:$container_port"; + if (!isset($endpoint_keys[$key])) { + $endpoint_keys[$key] = true; + $endpoints[] = [ + 'container' => $name, + 'mode' => $mode, + 'container_port' => $container_port, + 'host_port' => (int)$binding['HostPort'], + ]; + } + $found_tcp = true; + } + } + } + if ($mode !== 'host' && !$found_tcp) { + $no_tcp[] = ['container' => $name, 'mode' => $mode]; + } + } + + // Probes + $classifications = ['dualstack' => 0, 'ipv4-only' => 0, 'ipv6-only' => 0, 'unreachable' => 0]; + foreach ($endpoints as &$ep) { + $v4 = tcp_probe($addrs['ipv4'], $ep['host_port'], AF_INET, $timeout); + $v6 = tcp_probe($addrs['ipv6'], $ep['host_port'], AF_INET6, $timeout); + $ep['ipv4_reachable'] = $v4['reachable']; + $ep['ipv6_reachable'] = $v6['reachable']; + $ep['classification'] = ($v4['reachable'] && $v6['reachable']) ? 'dualstack' + : ($v4['reachable'] ? 'ipv4-only' : ($v6['reachable'] ? 'ipv6-only' : 'unreachable')); + $classifications[$ep['classification']]++; + } + unset($ep); + + $issue_endpoints = array_filter($endpoints, function ($e) { + return $e['classification'] !== 'dualstack'; + }); + + $result = [ + 'schema_version' => '2.0', + 'targets' => ['ipv4' => $addrs['ipv4'], 'lan_ipv6' => $addrs['ipv6']], + 'counts' => [ + 'containers_total' => count($containers), + 'tcp_endpoints_total' => count($endpoints), + 'tcp_dualstack' => $classifications['dualstack'], + 'tcp_ipv4_only' => $classifications['ipv4-only'], + 'tcp_ipv6_only' => $classifications['ipv6-only'], + 'tcp_unreachable' => $classifications['unreachable'], + 'tcp_problem_endpoints' => count($issue_endpoints), + ], + 'problem_endpoints_only' => array_values($issue_endpoints), + 'inactive_containers' => $inactive, + 'running_without_published_tcp' => $no_tcp, + 'task_complete' => true, + ]; + if ($include_all_endpoints) { + $result['all_tcp_endpoints'] = $endpoints; + } + return json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} diff --git a/mcp/selftest.php b/mcp/selftest.php new file mode 100644 index 0000000..b398d00 --- /dev/null +++ b/mcp/selftest.php @@ -0,0 +1,119 @@ + $name, "detail" => $detail]; + echo " ✓ $name" . ($detail ? " ($detail)" : '') . "\n"; + } else { + $failed++; + $results[] = ["FAIL" => $name, "detail" => $detail]; + echo " ✗ $name" . ($detail ? " ($detail)" : '') . "\n"; + } +} + +echo "=== MUA Selftest ===\n\n"; + +// ── 1. Tools-Definition ────────────────────────────────────────────────── +echo "[1] Tools-Definition\n"; +$tools = mua_tools(); +check('21 Tools definiert', count($tools) === 21, count($tools) . ' Tools'); + +$names = array_column($tools, 'name'); +$expected = [ + 'unraid_docker_list', 'unraid_docker_inspect', 'unraid_docker_logs', + 'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats', + 'unraid_docker_info', 'unraid_docker_start', 'unraid_docker_stop', + 'unraid_docker_restart', 'unraid_docker_create', 'unraid_docker_modify', + 'unraid_docker_update', 'unraid_docker_rebuild', + 'unraid_network_inventory', 'unraid_network_list', 'unraid_network_inspect', + 'unraid_network_host_state', 'unraid_network_audit_tcp', 'unraid_network_lan_probe', + 'unraid_system_connection_test', +]; +$missing = array_diff($expected, $names); +check('Alle erwarteten Tools vorhanden', empty($missing), empty($missing) ? '' : 'Fehlt: ' . implode(', ', $missing)); + +// ── 2. Docker-Verbindung ───────────────────────────────────────────────── +echo "\n[2] Docker-Verbindung\n"; +try { + $info = docker_exec("info --format '{{.ServerVersion}}'"); + check('Docker erreichbar', $info !== '', 'Version: ' . trim($info)); +} catch (Exception $e) { + check('Docker erreichbar', false, $e->getMessage()); +} + +// ── 3. Container-Liste ─────────────────────────────────────────────────── +echo "\n[3] Container-Liste\n"; +try { + $summary = container_runtime_summary(); + $decoded = json_decode($summary, true); + check('container_runtime_summary() funktioniert', is_array($decoded) && isset($decoded['container_count'])); + check('Container-Count > 0', ($decoded['container_count'] ?? 0) > 0, $decoded['container_count'] . ' Container'); +} catch (Exception $e) { + check('container_runtime_summary() funktioniert', false, $e->getMessage()); +} + +// ── 4. Netzwerk-Inventur ───────────────────────────────────────────────── +echo "\n[4] Netzwerk-Inventur\n"; +try { + $inventory = compact_network_inventory([]); + $decoded = json_decode($inventory, true); + check('compact_network_inventory() funktioniert', is_array($decoded) && isset($decoded['network_count'])); + check('Netzwerk-Count > 0', ($decoded['network_count'] ?? 0) > 0, $decoded['network_count'] . ' Netzwerke'); +} catch (Exception $e) { + check('compact_network_inventory() funktioniert', false, $e->getMessage()); +} + +// ── 5. Host-Adressen ───────────────────────────────────────────────────── +echo "\n[5] Host-Adressen\n"; +try { + $raw = run_local('test', "ip -j address show"); + $addrs = host_addresses($raw); + check('IPv4-Adresse gefunden', $addrs['ipv4'] !== '', $addrs['ipv4']); + check('IPv6-Adresse gefunden', $addrs['ipv6'] !== '', $addrs['ipv6']); +} catch (Exception $e) { + check('Host-Adressen', false, $e->getMessage()); +} + +// ── 6. Validate-Name ───────────────────────────────────────────────────── +echo "\n[6] Validate-Name\n"; +try { + validate_name('test-container', 'container'); + check('Gültiger Name akzeptiert', true); +} catch (Exception $e) { + check('Gültiger Name akzeptiert', false, $e->getMessage()); +} +try { + validate_name('bad;name', 'container'); + check('Ungültiger Name abgelehnt', false, 'Sollte Exception werfen'); +} catch (Exception $e) { + check('Ungültiger Name abgelehnt', true); +} + +// ── 7. PHP-Helper vorhanden ────────────────────────────────────────────── +echo "\n[7] PHP-Helper\n"; +$helper = '/usr/local/bin/unraid-docker-mcp-helper.php'; +check('PHP-Helper vorhanden', file_exists($helper), $helper); + +// ── Zusammenfassung ────────────────────────────────────────────────────── +echo "\n=== Zusammenfassung ===\n"; +echo " Bestanden: $passed\n"; +echo " Fehlgeschlagen: $failed\n"; +echo " Gesamt: " . ($passed + $failed) . "\n"; + +exit($failed > 0 ? 1 : 0); diff --git a/mcp/server.php b/mcp/server.php new file mode 100644 index 0000000..1518f09 --- /dev/null +++ b/mcp/server.php @@ -0,0 +1,378 @@ + ['created' => time, 'initialized' => bool] + +function session_create(): string { + global $sessions; + $id = bin2hex(random_bytes(16)); + $sessions[$id] = ['created' => time(), 'initialized' => false]; + return $id; +} + +function session_get(?string $id): ?array { + global $sessions; + if ($id === null || !isset($sessions[$id])) return null; + // TTL-Check + if (time() - $sessions[$id]['created'] > MUA_SESSION_TTL) { + unset($sessions[$id]); + return null; + } + return $sessions[$id]; +} + +function session_delete(?string $id): void { + global $sessions; + if ($id !== null) unset($sessions[$id]); +} + +// ── HTTP-Response-Helfer ───────────────────────────────────────────────── +function http_response(int $code, string $body, array $headers = []): void { + http_response_code($code); + foreach ($headers as $k => $v) { + header("$k: $v"); + } + echo $body; + exit; +} + +function json_response(int $code, array $data, array $extra_headers = []): void { + $headers = ['Content-Type' => 'application/json']; + $headers = array_merge($headers, $extra_headers); + http_response($code, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $headers); +} + +function sse_response(array $data, array $extra_headers = []): void { + $headers = [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + ]; + $headers = array_merge($headers, $extra_headers); + http_response(200, "data: " . json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n", $headers); +} + +// ── JSON-RPC-Response ──────────────────────────────────────────────────── +function rpc_result($id, $result): array { + return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; +} + +function rpc_error($id, int $code, string $message, $data = null): array { + $err = ['code' => $code, 'message' => $message]; + if ($data !== null) $err['data'] = $data; + return ['jsonrpc' => '2.0', 'id' => $id, 'error' => $err]; +} + +// ── Tool-Dispatch ──────────────────────────────────────────────────────── +function call_tool(string $name, array $args): string { + switch ($name) { + // ── Docker ────────────────────────────────────────────────────── + case 'unraid_docker_list': + return container_runtime_summary(); + + case 'unraid_docker_inspect': + $container = validate_name($args['container'] ?? null, 'container'); + $raw = docker_exec("inspect --type container -- $container"); + return compact_container_inspect($raw); + + case 'unraid_docker_logs': + $container = validate_name($args['container'] ?? null, 'container'); + $tail = (int)($args['tail'] ?? 200); + if ($tail < 1 || $tail > 2000) throw new InvalidArgumentException('tail must be between 1 and 2000'); + $raw = docker_exec("logs --timestamps --tail $tail $container"); + return sanitize_log_output($raw); + + case 'unraid_docker_analyze_logs': + $severity = $args['severity'] ?? 'error'; + $container = $args['container'] ?? null; + if ($container !== null && !is_string($container)) throw new InvalidArgumentException('container must be a string'); + $since = $args['since'] ?? '24h'; + $scan_tail = (int)($args['scan_tail'] ?? 1000); + $max_results = (int)($args['max_results'] ?? 50); + return analyze_container_logs($severity, $container, $since, $scan_tail, $max_results); + + case 'unraid_docker_processes': + $container = validate_name($args['container'] ?? null, 'container'); + return docker_exec("top $container -eo pid,ppid,user,stat,lstart,etime,args"); + + case 'unraid_docker_stats': + return docker_exec("stats --no-stream --format '{{json .}}'"); + + case 'unraid_docker_info': + return docker_exec("info --format '{{json .}}'"); + + case 'unraid_docker_start': + $container = validate_name($args['container'] ?? null, 'container'); + return docker_exec("start $container"); + + case 'unraid_docker_stop': + $container = validate_name($args['container'] ?? null, 'container'); + return docker_exec("stop $container"); + + case 'unraid_docker_restart': + $container = validate_name($args['container'] ?? null, 'container'); + return docker_exec("restart $container"); + + case 'unraid_docker_create': + $template = validate_name($args['template_name'] ?? null, 'template_name'); + return run_php_helper('create', $template); + + case 'unraid_docker_modify': + $container = validate_name($args['container'] ?? null, 'container'); + $field = $args['field'] ?? ''; + $value = $args['value'] ?? ''; + if (!in_array($field, ['port', 'env', 'volume', 'network', 'privileged'])) { + throw new InvalidArgumentException('field must be one of: port, env, volume, network, privileged'); + } + return run_php_helper('modify', $container, $field, $value); + + case 'unraid_docker_update': + $container = validate_name($args['container'] ?? null, 'container'); + return run_php_helper('update', $container); + + case 'unraid_docker_rebuild': + $container = validate_name($args['container'] ?? null, 'container'); + return run_php_helper('rebuild', $container); + + // ── Netzwerk ──────────────────────────────────────────────────── + case 'unraid_network_inventory': + return compact_network_inventory($args); + + case 'unraid_network_list': + return docker_exec("network ls --no-trunc --format '{{json .}}'"); + + case 'unraid_network_inspect': + $network = validate_name($args['network'] ?? null, 'network'); + return docker_exec("network inspect -- $network"); + + case 'unraid_network_host_state': + return run_local('host_state', "printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- IPv4 routes ---'; ip -j -4 route show; printf '%s\\n' '--- IPv6 routes ---'; ip -j -6 route show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup"); + + case 'unraid_network_audit_tcp': + $timeout = (float)($args['timeout_seconds'] ?? 2); + if ($timeout < 0.2 || $timeout > 10) throw new InvalidArgumentException('timeout_seconds must be between 0.2 and 10'); + $include_all = (bool)($args['include_all_endpoints'] ?? false); + return audit_all_tcp_endpoints($timeout, $include_all); + + case 'unraid_network_lan_probe': + $host = validate_name($args['host'] ?? null, 'host'); + $port = (int)($args['port'] ?? 0); + $timeout = (float)($args['timeout_seconds'] ?? 3); + if ($port < 1 || $port > 65535 || $timeout < 0.2 || $timeout > 10) { + throw new InvalidArgumentException('Invalid port or timeout'); + } + return probe_dualstack($host, $port, $timeout); + + // ── System ────────────────────────────────────────────────────── + case 'unraid_system_connection_test': + return run_local('connection_test', "id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version"); + + default: + throw new InvalidArgumentException("Unknown tool: $name"); + } +} + +/** + * PHP-Helper für Write-Operationen aufrufen. + * Der Helper liegt in /usr/local/bin/unraid-docker-mcp-helper.php + * (wird vom Plugin installiert). + */ +function run_php_helper(string $action, ...$args): string { + $helper = '/usr/local/bin/unraid-docker-mcp-helper.php'; + if (!file_exists($helper)) { + throw new RuntimeException("PHP helper not found: $helper"); + } + $cmd = escapeshellarg('/usr/bin/php') . ' ' . escapeshellarg($helper) . ' ' . escapeshellarg($action); + foreach ($args as $arg) { + $cmd .= ' ' . escapeshellarg($arg); + } + return run_local("php_helper:$action", $cmd, 300); +} + +// ── MCP-Methoden-Handler ───────────────────────────────────────────────── +function handle_mcp_request(array $message, ?string $session_id): array { + global $_SERVER; + $method = $message['method'] ?? ''; + $id = $message['id'] ?? null; + $params = $message['params'] ?? []; + + // ── initialize ────────────────────────────────────────────────────── + if ($method === 'initialize') { + $protocol_version = $params['protocolVersion'] ?? '2024-11-05'; + // Session erstellen (oder bestehende übernehmen) + if ($session_id === null) { + $session_id = session_create(); + } else { + // Bestehende Session als initialisiert markieren + global $sessions; + if (isset($sessions[$session_id])) { + $sessions[$session_id]['initialized'] = true; + } else { + $session_id = session_create(); + } + } + // Session-ID muss im Response-Header zurückgegeben werden + $_SERVER['MUA_SESSION_ID'] = $session_id; + return rpc_result($id, [ + 'protocolVersion' => $protocol_version, + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => MUA_SERVER_NAME, 'version' => MUA_VERSION], + ]); + } + + // ── notifications/initialized (kein Response nötig) ───────────────── + if ($method === 'notifications/initialized') { + return null; // Notification, kein Response + } + + // ── Session-Check für alle anderen Methoden ───────────────────────── + if ($session_id === null) { + return rpc_error($id, -32000, 'Missing Mcp-Session-Id header'); + } + $session = session_get($session_id); + if ($session === null) { + return rpc_error($id, -32000, 'Invalid or expired session'); + } + if (!$session['initialized'] && $method !== 'initialize') { + return rpc_error($id, -32000, 'Not initialized: send initialize first'); + } + + // ── tools/list ────────────────────────────────────────────────────── + if ($method === 'tools/list') { + return rpc_result($id, ['tools' => mua_tools()]); + } + + // ── tools/call ────────────────────────────────────────────────────── + if ($method === 'tools/call') { + $tool_name = $params['name'] ?? ''; + $tool_args = $params['arguments'] ?? []; + try { + $text = call_tool($tool_name, $tool_args); + $result = [ + 'content' => [['type' => 'text', 'text' => $text]], + 'isError' => false, + ]; + // structuredContent für JSON-Tools + if (in_array($tool_name, [ + 'unraid_docker_list', 'unraid_network_inventory', + 'unraid_docker_analyze_logs', 'unraid_network_audit_tcp', + ])) { + $decoded = json_decode($text, true); + if (json_last_error() === JSON_ERROR_NONE) { + $result['structuredContent'] = $decoded; + } + } + return rpc_result($id, $result); + } catch (Exception $e) { + return rpc_result($id, [ + 'content' => [['type' => 'text', 'text' => 'ERROR: ' . $e->getMessage()]], + 'isError' => true, + ]); + } + } + + // ── ping ──────────────────────────────────────────────────────────── + if ($method === 'ping') { + return rpc_result($id, new stdClass()); + } + + // ── Unknown method ────────────────────────────────────────────────── + if ($id !== null) { + return rpc_error($id, -32601, "Method not found: $method"); + } + return null; +} + +// ── HTTP-Router ────────────────────────────────────────────────────────── +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$session_header = $_SERVER['HTTP_MCP_SESSION_ID'] ?? null; + +// CORS für lokale Nutzung +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id'); + +if ($method === 'OPTIONS') { + http_response(204, ''); +} + +// ── Health-Check ───────────────────────────────────────────────────────── +if ($path === '/health') { + json_response(200, [ + 'status' => 'ok', + 'server' => MUA_SERVER_NAME, + 'version' => MUA_VERSION, + 'port' => MUA_PORT, + 'time' => date('c'), + ]); +} + +// ── MCP-Endpunkt ───────────────────────────────────────────────────────── +if ($path === '/mcp' || $path === '/') { + + // ── POST: JSON-RPC Request ────────────────────────────────────────── + if ($method === 'POST') { + $body = file_get_contents('php://input'); + $message = json_decode($body, true); + if (!is_array($message)) { + json_response(400, rpc_error(null, -32700, 'Parse error')); + } + + $response = handle_mcp_request($message, $session_header); + + // Notification (kein Response nötig) + if ($response === null) { + http_response(202, '', [ + 'Mcp-Session-Id' => $_SERVER['MUA_SESSION_ID'] ?? ($session_header ?? ''), + ]); + } + + $headers = []; + if (!empty($_SERVER['MUA_SESSION_ID'])) { + $headers['Mcp-Session-Id'] = $_SERVER['MUA_SESSION_ID']; + } + json_response(200, $response, $headers); + } + + // ── GET: SSE-Stream (nicht implementiert) ─────────────────────────── + // Laut MCP-Streamable-HTTP-Spec ist die SSE-GET-Endpunkt optional. + // Wir nutzen POST-only (request/response). 405 = spec-konform. + if ($method === 'GET') { + http_response(405, json_encode(['error' => 'SSE not supported. Use POST /mcp for JSON-RPC requests.']), [ + 'Content-Type' => 'application/json', + ]); + } + + // ── DELETE: Session-End ───────────────────────────────────────────── + if ($method === 'DELETE') { + session_delete($session_header); + http_response(200, ''); + } +} + +// ── 404 ────────────────────────────────────────────────────────────────── +json_response(404, ['error' => 'Not found. Use /mcp or /health']); diff --git a/mcp/tools.php b/mcp/tools.php new file mode 100644 index 0000000..f3979f2 --- /dev/null +++ b/mcp/tools.php @@ -0,0 +1,218 @@ +_ + * Kategorien: docker (14), network (6), system (1) + * + * Identisch zu /opt/mike-ai/unraid-agent/unraid_mcp.py (Python). + */ + +function mua_tools(): array { + return [ + // ── Docker (14) ────────────────────────────────────────────────── + [ + 'name' => 'unraid_docker_list', + 'description' => 'List all Docker containers with runtime stats (CPU, memory, network I/O). Returns a compact JSON summary.', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_docker_inspect', + 'description' => 'Inspect a single Docker container in detail (state, image, ports, env, mounts).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_logs', + 'description' => 'Get recent logs from a Docker container (with timestamps).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + 'tail' => ['type' => 'integer', 'description' => 'Number of lines (1-2000, default 200)'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_analyze_logs', + 'description' => 'Analyze container logs server-side for errors/warnings. Returns pattern counts and sample matches.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'severity' => ['type' => 'string', 'enum' => ['error', 'warn', 'info'], 'description' => 'Log severity to scan for'], + 'container' => ['type' => 'string', 'description' => 'Container name (optional, scans all if omitted)'], + 'since' => ['type' => 'string', 'description' => 'Time filter (default 24h)'], + 'scan_tail' => ['type' => 'integer', 'description' => 'Max lines to scan (default 1000)'], + 'max_results' => ['type' => 'integer', 'description' => 'Max sample matches (default 50)'], + ], + 'required' => ['severity'], + ], + ], + [ + 'name' => 'unraid_docker_processes', + 'description' => 'List processes running inside a Docker container (docker top).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_stats', + 'description' => 'Get live CPU/memory/network/block I/O stats for all running containers.', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_docker_info', + 'description' => 'Get Docker daemon information (version, storage driver, container counts, etc.).', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_docker_start', + 'description' => 'Start a Docker container.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_stop', + 'description' => 'Stop a Docker container.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_restart', + 'description' => 'Restart a Docker container.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container name or ID'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_create', + 'description' => 'Create a Docker container from a template (pulls image, creates container).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'template_name' => ['type' => 'string', 'description' => 'Template name (e.g. "linuxserver/sonarr")'], + ], + 'required' => ['template_name'], + ], + ], + [ + 'name' => 'unraid_docker_modify', + 'description' => 'Modify a container template (port, env, volume, network, privileged) and rebuild.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container/template name'], + 'field' => ['type' => 'string', 'enum' => ['port', 'env', 'volume', 'network', 'privileged']], + 'value' => ['type' => 'string', 'description' => 'New value (format depends on field)'], + ], + 'required' => ['container', 'field', 'value'], + ], + ], + [ + 'name' => 'unraid_docker_update', + 'description' => 'Update a container (pull latest image, rebuild).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container/template name'], + ], + 'required' => ['container'], + ], + ], + [ + 'name' => 'unraid_docker_rebuild', + 'description' => 'Rebuild a container from its template (without pulling new image).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'container' => ['type' => 'string', 'description' => 'Container/template name'], + ], + 'required' => ['container'], + ], + ], + + // ── Netzwerk (6) ───────────────────────────────────────────────── + [ + 'name' => 'unraid_network_inventory', + 'description' => 'Compact Docker network inventory (all networks with container counts).', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_network_list', + 'description' => 'List all Docker networks.', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_network_inspect', + 'description' => 'Inspect a Docker network in detail.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'network' => ['type' => 'string', 'description' => 'Network name or ID'], + ], + 'required' => ['network'], + ], + ], + [ + 'name' => 'unraid_network_host_state', + 'description' => 'Get host network state (IPv4/IPv6 addresses, routes, listening sockets).', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + [ + 'name' => 'unraid_network_audit_tcp', + 'description' => 'Audit all TCP endpoints: probe IPv4/IPv6 reachability for every published port. Returns classification (dualstack/ipv4-only/ipv6-only/unreachable).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'timeout_seconds' => ['type' => 'number', 'description' => 'Probe timeout (0.2-10, default 2)'], + 'include_all_endpoints' => ['type' => 'boolean', 'description' => 'Include all endpoints (default false, only problems)'], + ], + ], + ], + [ + 'name' => 'unraid_network_lan_probe', + 'description' => 'Probe a specific host:port for IPv4 and IPv6 reachability (dualstack test).', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'host' => ['type' => 'string', 'description' => 'Hostname or IP'], + 'port' => ['type' => 'integer', 'description' => 'Port (1-65535)'], + 'timeout_seconds' => ['type' => 'number', 'description' => 'Timeout (0.2-10, default 3)'], + ], + 'required' => ['host', 'port'], + ], + ], + + // ── System (1) ─────────────────────────────────────────────────── + [ + 'name' => 'unraid_system_connection_test', + 'description' => 'Test connection to the Unraid host (hostname, kernel, Unraid version).', + 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false], + ], + ]; +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..b0dbc41 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# MUA - Mikes Unraid Agent +# Install-Script +# Wird vom Unraid-Plugin-Installer aufgerufen. + +set -e + +PLUGIN_DIR="/usr/local/plugins/mua" +SERVICE_FILE="$PLUGIN_DIR/scripts/mua.service" +HELPER_SRC="$PLUGIN_DIR/scripts/unraid-docker-mcp-helper.php" +HELPER_DST="/usr/local/bin/unraid-docker-mcp-helper.php" + +echo "=== MUA Install ===" + +# 1. Verzeichnisse sicherstellen +mkdir -p "$PLUGIN_DIR/mcp" +mkdir -p "$PLUGIN_DIR/scripts" + +# 2. PHP-Helper installieren (Write-Operationen) +if [ -f "$HELPER_SRC" ]; then + cp "$HELPER_SRC" "$HELPER_DST" + chmod 755 "$HELPER_DST" + echo " Helper installiert: $HELPER_DST" +fi + +# 3. systemd-Service installieren +if [ -f "$SERVICE_FILE" ]; then + cp "$SERVICE_FILE" /etc/systemd/system/mua.service + chmod 644 /etc/systemd/system/mua.service + systemctl daemon-reload + echo " Service installiert: mua.service" +fi + +# 4. Service starten + enable +systemctl enable mua.service +systemctl restart mua.service + +# 5. Health-Check +sleep 2 +if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then + echo " Health-Check: OK" + echo "=== MUA installiert und läuft auf Port 3002 ===" + echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp" +else + echo " WARNUNG: Health-Check fehlgeschlagen" + echo " Service-Status:" + systemctl status mua.service --no-pager || true + exit 1 +fi diff --git a/scripts/mua.service b/scripts/mua.service new file mode 100644 index 0000000..ea91023 --- /dev/null +++ b/scripts/mua.service @@ -0,0 +1,20 @@ +[Unit] +Description=MUA - Mikes Unraid Agent (MCP Server) +After=network.target docker.service +Wants=docker.service + +[Service] +Type=simple +# PHP Built-in Server auf Port 3002 +ExecStart=/usr/bin/php -S 0.0.0.0:3002 /usr/local/plugins/mua/mcp/server.php +Restart=on-failure +RestartSec=5 +# Arbeitsverzeichnis +WorkingDirectory=/usr/local/plugins/mua +# Sicherheits-Härtung +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/scripts/remove.sh b/scripts/remove.sh new file mode 100644 index 0000000..9ac80fd --- /dev/null +++ b/scripts/remove.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# MUA - Mikes Unraid Agent +# Remove-Script +# Wird vom Unraid-Plugin-Installer bei Deinstallation aufgerufen. + +set -e + +echo "=== MUA Remove ===" + +# 1. Service stoppen + disable +if systemctl list-unit-files | grep -q "^mua.service"; then + systemctl stop mua.service 2>/dev/null || true + systemctl disable mua.service 2>/dev/null || true + rm -f /etc/systemd/system/mua.service + systemctl daemon-reload + echo " Service entfernt" +fi + +# 2. PHP-Helper entfernen +if [ -f "/usr/local/bin/unraid-docker-mcp-helper.php" ]; then + rm -f /usr/local/bin/unraid-docker-mcp-helper.php + echo " Helper entfernt" +fi + +# 3. Plugin-Verzeichnis entfernen +if [ -d "/usr/local/plugins/mua" ]; then + rm -rf /usr/local/plugins/mua + echo " Plugin-Verzeichnis entfernt" +fi + +echo "=== MUA entfernt ===" diff --git a/scripts/unraid-docker-mcp-helper.php b/scripts/unraid-docker-mcp-helper.php new file mode 100644 index 0000000..ab12902 --- /dev/null +++ b/scripts/unraid-docker-mcp-helper.php @@ -0,0 +1,278 @@ + + * + * Actions: + * create + * modify + * update + * rebuild + * + * modify fields: + * port value: newExternalPort (z.B. "8466") + * oder newExternalPort:internalPort (z.B. "8466:8465") + * env value: VAR_NAME=VAR_VALUE (z.B. "TZ=Europe/Berlin") + * volume value: hostPath:containerPath (z.B. "/mnt/user/data:/data") + * network value: bridge|host|none + * privileged value: true|false + */ + +error_reporting(E_ALL); +ini_set('display_errors', 1); + +// ── Unraid Setup (aus update_container) ────────────────────────────────── +$docroot = '/usr/local/emhttp'; +require_once "$docroot/webGui/include/Wrappers.php"; +extract(parse_plugin_cfg('dynamix', true)); + +$_SERVER['REQUEST_URI'] = ''; +$login_locale = _var($display, 'locale'); +require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php"; + +$var = parse_ini_file('/var/local/emhttp/var.ini'); +$DockerClient = new DockerClient(); +$DockerUpdate = new DockerUpdate(); +$DockerTemplates = new DockerTemplates(); + +$custom = DockerUtil::custom(); +$subnet = DockerUtil::network($custom); +$cpus = DockerUtil::cpus(); + +// ── Helper-Funktionen ──────────────────────────────────────────────────── +function out(string $msg): void { + echo $msg . "\n"; +} + +function fail(string $msg): never { + out("ERROR: " . $msg); + exit(1); +} + +function docker_exec(string $cmd): string { + $proc = popen("/usr/bin/docker $cmd 2>&1", 'r'); + $output = stream_get_contents($proc); + pclose($proc); + return trim($output); +} + +function get_template_path(string $name): string { + $dir = '/boot/config/plugins/dockerMan/templates-user'; + $file = "$dir/my-$name.xml"; + if (!file_exists($file)) { + fail("Template not found: $file"); + } + return $file; +} + +function rebuild_container(string $name, bool $pull_image = false, bool $force_start = false): void { + global $DockerClient; + $tmpl = get_template_path($name); + $xml = file_get_contents($tmpl); + [$cmd, $Name, $Repository] = xmlToCommand($tmpl); + + // Pull image if requested + if ($pull_image) { + out("Pulling image: $Repository"); + $pull_out = docker_exec("pull $Repository"); + if (strpos($pull_out, 'Error') !== false || strpos($pull_out, 'error') !== false) { + fail("Image pull failed: $pull_out"); + } + out("Image pulled: $Repository"); + } + + // Check if container is running + $oldContainerInfo = $DockerClient->getContainerDetails($Name); + $startContainer = $force_start; + if (!empty($oldContainerInfo) && !empty($oldContainerInfo['State']) && !empty($oldContainerInfo['State']['Running'])) { + $startContainer = true; + out("Stopping container: $Name"); + $DockerClient->stopContainer($Name); + } + + // Convert create to run if we need to start + if ($startContainer) { + $cmd = str_replace('/docker create ', '/docker run -d ', $cmd); + } + + // Remove old container + out("Removing old container: $Name"); + $DockerClient->removeContainer($Name); + + // Execute the docker command + out("Creating container: $Name"); + $proc = popen("$cmd 2>&1", 'r'); + $output = stream_get_contents($proc); + $rc = pclose($proc); + if ($rc !== 0) { + fail("Container creation failed (exit $rc): $output"); + } + out("Container created: $Name"); + + // Flush caches + $DockerClient->flushCaches(); + out("Done: $Name"); +} + +/** + * Set the text content of a DOM element. + * This is the actual value Unraid uses (not the Default attribute). + */ +function set_config_value(DOMElement $config, string $value): void { + // Remove existing text children + while ($config->firstChild) { + $config->removeChild($config->firstChild); + } + $config->appendChild(new DOMText($value)); + // Also update Default attribute for consistency + $config->setAttribute('Default', $value); +} + +function modify_template(string $name, string $field, string $value): void { + $tmpl = get_template_path($name); + $xml = file_get_contents($tmpl); + $dom = new DOMDocument(); + $dom->loadXML($xml); + + switch ($field) { + case 'port': + // value: newExternalPort or newExternalPort:internalPort + $parts = explode(':', $value, 2); + $newExternal = $parts[0]; + $newInternal = $parts[1] ?? null; + + $found = false; + foreach ($dom->getElementsByTagName('Config') as $config) { + if (strcasecmp($config->getAttribute('Type'), 'Port') === 0) { + if ($newInternal !== null) { + // Match by internal port (Target attribute) + if ($config->getAttribute('Target') === $newInternal) { + $config->setAttribute('Target', $newInternal); + set_config_value($config, $newExternal); + $found = true; + break; + } + } else { + // No internal port specified — change first Port config + set_config_value($config, $newExternal); + $found = true; + break; + } + } + } + if (!$found) { + fail("Port config not found in template" . ($newInternal !== null ? " (Target=$newInternal)" : "")); + } + break; + + case 'env': + // value: VAR_NAME=VAR_VALUE + $eqPos = strpos($value, '='); + if ($eqPos === false) { + fail("Env value must be VAR_NAME=VAR_VALUE, got: $value"); + } + $varName = substr($value, 0, $eqPos); + $varValue = substr($value, $eqPos + 1); + + $found = false; + foreach ($dom->getElementsByTagName('Config') as $config) { + if (strcasecmp($config->getAttribute('Type'), 'Variable') === 0) { + if ($config->getAttribute('Target') === $varName) { + set_config_value($config, $varValue); + $found = true; + break; + } + } + } + if (!$found) { + fail("Env var $varName not found in template"); + } + break; + + case 'volume': + // value: hostPath:containerPath + $colonPos = strpos($value, ':'); + if ($colonPos === false) { + fail("Volume value must be hostPath:containerPath, got: $value"); + } + $hostPath = substr($value, 0, $colonPos); + $containerPath = substr($value, $colonPos + 1); + + $found = false; + foreach ($dom->getElementsByTagName('Config') as $config) { + if (strcasecmp($config->getAttribute('Type'), 'Path') === 0) { + if ($config->getAttribute('Target') === $containerPath) { + set_config_value($config, $hostPath); + $found = true; + break; + } + } + } + if (!$found) { + fail("Volume target $containerPath not found in template"); + } + break; + + case 'network': + $networks = $dom->getElementsByTagName('Network'); + if ($networks->length === 0) { + fail("No Network element in template"); + } + $networks->item(0)->nodeValue = $value; + break; + + case 'privileged': + $privs = $dom->getElementsByTagName('Privileged'); + if ($privs->length === 0) { + fail("No Privileged element in template"); + } + $privs->item(0)->nodeValue = $value; + break; + + default: + fail("Unknown field: $field (use: port|env|volume|network|privileged)"); + } + + // Write modified template + $dom->save($tmpl); + out("Template modified: $name ($field = $value)"); + + // Rebuild container (force start so it's running after modify) + rebuild_container($name, false, true); +} + +// ── Main ───────────────────────────────────────────────────────────────── +$argv = $_SERVER['argv']; +$action = $argv[1] ?? ''; +$arg1 = $argv[2] ?? ''; +$arg2 = $argv[3] ?? ''; +$arg3 = $argv[4] ?? ''; + +switch ($action) { + case 'create': + if (!$arg1) fail("Missing template name"); + out("Creating container from template: $arg1"); + rebuild_container($arg1, true); + break; + + case 'modify': + if (!$arg1 || !$arg2 || !$arg3) fail("Usage: modify "); + modify_template($arg1, $arg2, $arg3); + break; + + case 'update': + if (!$arg1) fail("Missing container name"); + out("Updating container: $arg1"); + rebuild_container($arg1, true); + break; + + case 'rebuild': + if (!$arg1) fail("Missing container name"); + out("Rebuilding container: $arg1"); + rebuild_container($arg1, false); + break; + + default: + fail("Unknown action: $action. Usage: create|modify|update|rebuild"); +} diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..912f491 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# MUA - Mikes Unraid Agent +# Update-Script +# Wird vom Unraid-Plugin-Installer bei Updates aufgerufen. + +set -e + +PLUGIN_DIR="/usr/local/plugins/mua" +SERVICE_FILE="$PLUGIN_DIR/scripts/mua.service" +HELPER_SRC="$PLUGIN_DIR/scripts/unraid-docker-mcp-helper.php" +HELPER_DST="/usr/local/bin/unraid-docker-mcp-helper.php" + +echo "=== MUA Update ===" + +# 1. PHP-Helper aktualisieren +if [ -f "$HELPER_SRC" ]; then + cp "$HELPER_SRC" "$HELPER_DST" + chmod 755 "$HELPER_DST" + echo " Helper aktualisiert" +fi + +# 2. systemd-Service aktualisieren +if [ -f "$SERVICE_FILE" ]; then + cp "$SERVICE_FILE" /etc/systemd/system/mua.service + chmod 644 /etc/systemd/system/mua.service + systemctl daemon-reload + echo " Service aktualisiert" +fi + +# 3. Service neu starten +systemctl restart mua.service + +# 4. Health-Check +sleep 2 +if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then + echo " Health-Check: OK" + echo "=== MUA aktualisiert ===" +else + echo " WARNUNG: Health-Check fehlgeschlagen" + systemctl status mua.service --no-pager || true + exit 1 +fi