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
This commit is contained in:
+471
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* Helper-Funktionen für den MCP-Server
|
||||
*
|
||||
* Alle Funktionen laufen LOKAL auf dem Unraid-Host (kein SSH).
|
||||
* Portiert aus /opt/mike-ai/unraid-agent/unraid_mcp.py (Python, SSH-basiert).
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML
|
||||
|
||||
const MUA_VERSION = '1.0.0';
|
||||
const MUA_SERVER_NAME = 'mua';
|
||||
|
||||
/**
|
||||
* Führe einen lokalen Shell-Befehl aus und gib stdout+stderr zurück.
|
||||
* Ersatz für run_ssh() im Python-Code.
|
||||
*
|
||||
* @param string $label Nur fürs Audit-Logging (Tool-Name)
|
||||
* @param string $cmd Shell-Befehl
|
||||
* @param int $timeout Timeout in Sekunden
|
||||
* @return string
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
function run_local(string $label, string $cmd, int $timeout = 60): string {
|
||||
$descriptors = [
|
||||
0 => ['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);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* Selftest-Script
|
||||
*
|
||||
* Testet den MCP-Server lokal (ohne HTTP).
|
||||
* Aufruf: php /usr/local/plugins/mua/mcp/selftest.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/tools.php';
|
||||
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
$results = [];
|
||||
|
||||
function check(string $name, bool $condition, string $detail = ''): void {
|
||||
global $passed, $failed, $results;
|
||||
if ($condition) {
|
||||
$passed++;
|
||||
$results[] = ["PASS" => $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);
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* MCP Server (Streamable HTTP Transport)
|
||||
*
|
||||
* Endpunkt: POST /mcp (JSON-RPC 2.0)
|
||||
* GET /mcp (SSE-Stream, optional)
|
||||
* DELETE /mcp (Session-End)
|
||||
*
|
||||
* Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
|
||||
*
|
||||
* Läuft lokal auf dem Unraid-Host (kein SSH).
|
||||
* Port: 3002 (3000 ist belegt von theippenguin/unraid-mcp)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/tools.php';
|
||||
|
||||
// ── Konfiguration ────────────────────────────────────────────────────────
|
||||
const MUA_PORT = 3002;
|
||||
const MUA_BIND = '0.0.0.0';
|
||||
const MUA_SESSION_TTL = 3600; // Sekunden
|
||||
const MUA_SERVER_NAME = 'mua';
|
||||
const MUA_VERSION = '1.0.0';
|
||||
|
||||
// ── Session-Management (in-memory) ───────────────────────────────────────
|
||||
$sessions = []; // session_id => ['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']);
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* Tool-Definitionen (21 Tools)
|
||||
*
|
||||
* Schema: unraid_<kategorie>_<aktion>
|
||||
* 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],
|
||||
],
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user