Files
MUA-Mikes-Unraid-Agent/mcp/helpers.php
T

511 lines
18 KiB
PHP

<?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 = '2026.08.29.r026';
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 $detail = 'summary'): 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'] ?? [],
'RestartCount' => $data['RestartCount'] ?? 0,
'Created' => $data['Created'] ?? '',
];
if ($detail === 'full') {
$compact['Env'] = array_map(function ($entry) {
if (!is_string($entry) || !str_contains($entry, '=')) return $entry;
[$name, $value] = explode('=', $entry, 2);
if (preg_match('/(KEY|TOKEN|SECRET|PASS|AUTH|COOKIE|ARL)/i', $name)) {
return $name . '=<redacted>';
}
return $name . '=' . $value;
}, $data['Config']['Env'] ?? []);
$compact['Mounts'] = array_map(function ($m) {
return [
'Type' => $m['Type'] ?? '',
'Source' => $m['Source'] ?? '',
'Destination' => $m['Destination'] ?? '',
];
}, $data['Mounts'] ?? []);
}
return json_encode($compact, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Container-Runtime-Zusammenfassung (docker ps + docker stats).
*/
function container_runtime_summary(array $args = []): string {
$state_filter = $args['state'] ?? 'all';
$include_stats = ($args['include_stats'] ?? false) === true;
$detail = $args['detail'] ?? 'compact';
$allowed_states = ['all', 'running', 'stopped', 'created', 'exited'];
if (!in_array($state_filter, $allowed_states, true)) {
throw new InvalidArgumentException('state must be one of: ' . implode(', ', $allowed_states));
}
if (!in_array($detail, ['compact', 'full'], true)) {
throw new InvalidArgumentException('detail must be compact or full');
}
$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['State'] ?? '') === 'running') ? ($c['ID'] ?? '') : '';
}, $containers));
$stats = [];
if ($include_stats && !empty($running_ids)) {
$stats_raw = docker_exec("stats --no-stream --format '{{json .}}'");
foreach (json_lines($stats_raw) as $s) {
$stats[$s['ID'] ?? ''] = $s;
}
}
$state_counts = [];
foreach ($containers as $c) {
$state = (string)($c['State'] ?? 'unknown');
$state_counts[$state] = ($state_counts[$state] ?? 0) + 1;
}
$result = [];
foreach ($containers as $c) {
$state = (string)($c['State'] ?? '');
$matches = $state_filter === 'all'
|| ($state_filter === 'stopped' && $state !== 'running')
|| $state === $state_filter;
if (!$matches) continue;
$id = $c['ID'] ?? '';
$entry = [
'name' => $c['Names'] ?? '',
'status' => $c['Status'] ?? '',
'state' => $c['State'] ?? '',
];
if ($detail === 'full') {
$entry['id'] = substr($id, 0, 12);
$entry['image'] = $c['Image'] ?? '';
$entry['ports'] = $c['Ports'] ?? '';
}
if ($include_stats && 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.1',
'container_count' => count($result),
'returned_count' => count($result),
'total_count' => count($containers),
'running_count' => $state_counts['running'] ?? 0,
'stopped_count' => count($containers) - ($state_counts['running'] ?? 0),
'state_counts' => $state_counts,
'filter' => ['state' => $state_filter, 'include_stats' => $include_stats, 'detail' => $detail],
'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);
}