/** * MUA — Mikes Unraid Agent * helpers.ts — Command execution, JSON-RPC, read operations * * Portiert von mcp/helpers.php. Alle Funktionen laufen LOKAL auf dem * Unraid-Host. Read-Operationen via `docker` CLI. Write-Operationen * werden an den PHP-Helper delegiert (Bun.spawn), da diese Unraids * PHP-Klassen (DockerClient) benötigen. */ import { spawn } from "bun"; import { createConnection, type Socket } from "net"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; export const MUA_VERSION = "2026.08.18.r002"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; // ── Command Execution ─────────────────────────────────────────────────── export interface CmdResult { stdout: string; stderr: string; code: number; } /** * Führe einen lokalen Shell-Befehl aus (via /bin/sh -c). * Timeout in Sekunden. */ export async function runLocal( label: string, cmd: string, timeoutSec = 60, ): Promise { try { const proc = spawn(["/bin/sh", "-c", cmd], { stdout: "pipe", stderr: "pipe", }); const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); clearTimeout(timeout); let result = stdout.trim(); if (result === "" && stderr.trim() !== "") result = stderr.trim(); return result; } catch (e) { throw new Error(`run_local failed for ${label}: ${String(e)}`); } } /** * Docker-Befehl ausführen (kompakt). */ export async function dockerExec(cmd: string, timeoutSec = 60): Promise { return runLocal("docker", `/usr/bin/docker ${cmd} 2>&1`, timeoutSec); } /** * Delegiert eine Write-Operation an den PHP-Helper. */ export async function runPhpHelper( action: string, ...args: string[] ): Promise { const helper = PHP_HELPER; if (!Bun.file(helper).exists()) { throw new Error(`PHP helper not found: ${helper}`); } const escaped = [action, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); return runLocal(`php_helper:${action}`, `/usr/bin/php ${helper} ${escaped}`, 300); } // ── Validierung ───────────────────────────────────────────────────────── export function validateName(value: unknown, field: string): string { if (typeof value !== "string" || value === "") { throw new Error(`${field} is required`); } if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) { throw new Error(`Invalid ${field}: ${value}`); } return value; } // ── JSON-Lines ────────────────────────────────────────────────────────── export function jsonLines(text: string): Record[] { const result: Record[] = []; for (const line of text.split("\n")) { const trimmed = line.trim(); if (trimmed === "") continue; try { result.push(JSON.parse(trimmed)); } catch { // skip malformed lines } } return result; } // ── Host-Adressen ─────────────────────────────────────────────────────── export interface HostAddresses { ipv4: string; ipv6: string; public_ipv6: string; } export function hostAddresses(raw: string): HostAddresses { const result: HostAddresses = { ipv4: "", ipv6: "", public_ipv6: "" }; let data: unknown; try { data = JSON.parse(raw); } catch { return result; } if (!Array.isArray(data)) return result; for (const iface of data as Record[]) { const ifname = (iface["ifname"] as string) ?? ""; if (ifname === "lo") continue; const addrInfo = (iface["addr_info"] as Record[]) ?? []; for (const addr of addrInfo) { const local = (addr["local"] as string) ?? ""; const family = (addr["family"] as string) ?? ""; if (family === "inet") { if (local !== "127.0.0.1" && local !== "0.0.0.0" && result.ipv4 === "") { result.ipv4 = local; } } else if (family === "inet6") { if (local.startsWith("fe80") && result.ipv6 === "") { result.ipv6 = local; } if ( !local.startsWith("fe80") && !local.startsWith("fd") && !local.startsWith("fc") && local !== "::1" && result.public_ipv6 === "" ) { result.public_ipv6 = local; } } } } return result; } // ── TCP Probe ─────────────────────────────────────────────────────────── export interface ProbeResult { reachable: boolean; latency_ms?: number; error?: string; } export function tcpProbe( host: string, port: number, family: 4 | 6, timeoutSec: number, ): Promise { return new Promise((resolve) => { if (host === "") { resolve({ reachable: false, error: "no host address" }); return; } const start = Date.now(); const socket: Socket = createConnection({ host, port, family, timeout: timeoutSec * 1000, }); const done = (result: ProbeResult) => { socket.destroy(); resolve(result); }; socket.on("connect", () => { const elapsed = Date.now() - start; done({ reachable: true, latency_ms: Math.round(elapsed * 10) / 10 }); }); socket.on("timeout", () => { done({ reachable: false, error: "timeout" }); }); socket.on("error", (err) => { done({ reachable: false, error: err.message }); }); }); } // ── Log Sanitize ──────────────────────────────────────────────────────── export function sanitizeLogOutput(text: string, maxChars = 50000): string { // Entferne ANSI-Escape-Sequenzen let result = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // Entferne andere Control-Chars (außer \n, \r, \t) result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); // Begrenze Länge if (result.length > maxChars) { result = "... [truncated] ..." + result.slice(-maxChars); } return result; } // ── Compact Container Inspect ─────────────────────────────────────────── export async function compactContainerInspect(container: string): Promise { const raw = await dockerExec(`inspect --type container -- ${container}`); let data: unknown; try { data = JSON.parse(raw); } catch { return raw; } if (Array.isArray(data)) data = data[0]; const d = data as Record; const compact = { Id: (d.Id ?? "").slice(0, 12), Name: d.Name ?? "", State: { Status: d.State?.Status ?? "", Running: d.State?.Running ?? false, Pid: d.State?.Pid ?? 0, ExitCode: d.State?.ExitCode ?? 0, }, Image: d.Config?.Image ?? "", NetworkMode: d.HostConfig?.NetworkMode ?? "", Ports: d.NetworkSettings?.Ports ?? [], Env: d.Config?.Env ?? [], Mounts: (d.Mounts ?? []).map((m: any) => ({ Type: m.Type ?? "", Source: m.Source ?? "", Destination: m.Destination ?? "", })), RestartCount: d.RestartCount ?? 0, Created: d.Created ?? "", }; return JSON.stringify(compact); } // ── Container Runtime Summary ─────────────────────────────────────────── export async function containerRuntimeSummary(): Promise { const ps = await dockerExec(`ps -a --format '{{json .}}'`); const containers = jsonLines(ps); const runningIds = containers .map((c) => (c["ID"] as string) ?? "") .filter((id) => id !== ""); const stats: Record> = {}; if (runningIds.length > 0) { const statsRaw = await dockerExec(`stats --no-stream --format '{{json .}}'`); for (const s of jsonLines(statsRaw)) { stats[(s["ID"] as string) ?? ""] = s; } } const result = containers.map((c) => { const id = (c["ID"] as string) ?? ""; const entry: Record = { id: id.slice(0, 12), name: c["Names"] ?? "", image: c["Image"] ?? "", status: c["Status"] ?? "", state: c["State"] ?? "", ports: c["Ports"] ?? "", }; if (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"] ?? ""; } return entry; }); return JSON.stringify({ schema_version: "1.0", container_count: result.length, containers: result, }); } // ── Compact Network Inventory ─────────────────────────────────────────── export async function compactNetworkInventory(): Promise { const networksRaw = await dockerExec(`network ls --no-trunc --format '{{json .}}'`); const networks = jsonLines(networksRaw); const result = []; for (const n of networks) { const entry: Record = { name: n["Name"] ?? "", id: ((n["ID"] as string) ?? "").slice(0, 12), driver: n["Driver"] ?? "", scope: n["Scope"] ?? "", }; const inspect = await dockerExec( `network inspect --format '{{len .Containers}}' ${n["Name"]}`, ); entry.container_count = parseInt(inspect.trim(), 10) || 0; result.push(entry); } return JSON.stringify({ schema_version: "1.0", network_count: result.length, networks: result, }); } // ── Analyze Container Logs ────────────────────────────────────────────── export async function analyzeContainerLogs( severity: string, container: string | null, since: string, scanTail: number, maxResults: number, ): Promise { const severityLevels: Record = { error: ["error", "fatal", "panic", "exception", "traceback", "critical"], warn: ["warn", "warning", "deprecated"], info: ["info", "started", "listening", "ready"], }; const patterns = severityLevels[severity] ?? severityLevels["error"]; const regex = new RegExp(`(${patterns.join("|")})`, "i"); let logCmd = `logs --timestamps --since ${since} --tail ${scanTail}`; if (container) logCmd += ` ${container}`; const raw = await dockerExec(logCmd, 120); const lines = raw.split("\n"); const matches: { pattern: string; line: string }[] = []; const counts: Record = {}; for (const line of lines) { const m = line.match(regex); if (m) { const key = m[1].toLowerCase(); counts[key] = (counts[key] ?? 0) + 1; if (matches.length < maxResults) { matches.push({ pattern: m[1], line: line.trim().slice(0, 300) }); } } } return JSON.stringify({ schema_version: "1.0", severity, container, since, scan_tail: scanTail, total_matches: Object.values(counts).reduce((a, b) => a + b, 0), pattern_counts: counts, sample_matches: matches, }); } // ── Dualstack LAN Probe ───────────────────────────────────────────────── export async function probeDualstack( host: string, port: number, timeoutSec: number, ): Promise { const [ipv4, ipv6] = await Promise.all([ tcpProbe(host, port, 4, timeoutSec), tcpProbe(host, port, 6, timeoutSec), ]); return JSON.stringify({ host, port, ipv4, ipv6 }); } // ── Audit All TCP Endpoints ───────────────────────────────────────────── export async function auditAllTcpEndpoints( timeoutSec: number, includeAllEndpoints = false, ): Promise { const inventoryCmd = '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'; const containersRaw = await runLocal("audit", inventoryCmd, 60); const containers = jsonLines(containersRaw); const hostCmd = "printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup"; const hostRaw = await runLocal("audit", hostCmd, 30); const addrs = hostAddresses(hostRaw); const inactive: { container: string; status: string }[] = []; const noTcp: { container: string; mode: string }[] = []; const udp: { container: string; container_port: string }[] = []; const endpoints: { container: string; mode: string; container_port: string; host_port: number; ipv4_reachable?: boolean; ipv6_reachable?: boolean; classification?: string; }[] = []; const endpointKeys = new Set(); for (const item of containers) { const name = ((item["Name"] as string) ?? "").replace(/^\//, ""); if (!(item["Running"] as boolean)) { inactive.push({ container: name, status: (item["Status"] as string) ?? "" }); continue; } const mode = (item["NetworkMode"] as string) ?? "unknown"; let foundTcp = false; const ports = (item["Ports"] as Record) ?? {}; for (const [containerPort, bindings] of Object.entries(ports)) { const protocol = containerPort.split("/").pop() ?? ""; if (protocol === "udp" && bindings.length > 0) { udp.push({ container: name, container_port: containerPort }); continue; } if (protocol !== "tcp" || bindings.length === 0) continue; for (const binding of bindings) { if (binding.HostPort) { const key = `${name}:${binding.HostPort}:${containerPort}`; if (!endpointKeys.has(key)) { endpointKeys.add(key); endpoints.push({ container: name, mode, container_port: containerPort, host_port: parseInt(binding.HostPort, 10), }); } foundTcp = true; } } } if (mode !== "host" && !foundTcp) { noTcp.push({ container: name, mode }); } } const classifications = { dualstack: 0, "ipv4-only": 0, "ipv6-only": 0, unreachable: 0 }; for (const ep of endpoints) { const [v4, v6] = await Promise.all([ tcpProbe(addrs.ipv4, ep.host_port, 4, timeoutSec), tcpProbe(addrs.ipv6, ep.host_port, 6, timeoutSec), ]); 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 as keyof typeof classifications]++; } const issueEndpoints = endpoints.filter((e) => e.classification !== "dualstack"); const result: Record = { schema_version: "2.0", targets: { ipv4: addrs.ipv4, lan_ipv6: addrs.ipv6 }, counts: { containers_total: containers.length, tcp_endpoints_total: endpoints.length, tcp_dualstack: classifications.dualstack, tcp_ipv4_only: classifications["ipv4-only"], tcp_ipv6_only: classifications["ipv6-only"], tcp_unreachable: classifications.unreachable, tcp_problem_endpoints: issueEndpoints.length, }, problem_endpoints_only: issueEndpoints, inactive_containers: inactive, running_without_published_tcp: noTcp, task_complete: true, }; if (includeAllEndpoints) { result.all_tcp_endpoints = endpoints; } return JSON.stringify(result); } // ── Host State ────────────────────────────────────────────────────────── export async function hostState(): Promise { return runLocal( "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", 30, ); } // ── Connection Test ───────────────────────────────────────────────────── export async function connectionTest(): Promise { return runLocal( "connection_test", "id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version", 15, ); }