['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); }