511 lines
18 KiB
PHP
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.24.r017';
|
|
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);
|
|
}
|