diff --git a/.gitignore b/.gitignore index b1c6b20..23f5b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,14 @@ Thumbs.db # Temp /tmp/ *.tmp + +# Build artifacts +node_modules/ +build/ + +# Raw compiled binary (90MB) — NOT committed, only the .txz package +dist/mua +dist/mua-mac + +# Allow the .txz package (35MB, committed for plugin install) +!dist/*.txz diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..54a99ab --- /dev/null +++ b/bun.lock @@ -0,0 +1,21 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "mua", + "devDependencies": { + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/dist/mua-1.0.0-x86_64-1.txz b/dist/mua-1.0.0-x86_64-1.txz new file mode 100644 index 0000000..e1950a9 Binary files /dev/null and b/dist/mua-1.0.0-x86_64-1.txz differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..fe4789e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "mua", + "version": "1.0.0", + "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", + "type": "module", + "main": "src/index.ts", + "scripts": { + "build": "bun build ./src/index.ts --compile --target=bun-linux-x64 --outfile dist/mua", + "build:mac": "bun build ./src/index.ts --compile --target=bun-darwin-arm64 --outfile dist/mua-mac", + "start": "bun run src/index.ts", + "selftest": "bun run src/selftest.ts" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/plugin/mua.plg b/plugin/mua.plg index 4386650..2d2ce65 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -7,6 +7,8 @@ + + ]> ### 1.0.0 - Initial release: 21 Docker, network, and system tools as MCP server over HTTP (Streamable HTTP, port 3002). +- Runtime: TypeScript (Bun Runtime, compiled binary) — no PHP built-in server. - - -set -e - -GIT_REPO="http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent.git" -TMP_DIR="/tmp/mua-install-$$" - -echo " Kloniere Repo..." -git clone --depth 1 "$GIT_REPO" "$TMP_DIR" 2>&1 | sed 's/^/ /' - -# MCP-Server (PHP) -echo " Installiere MCP-Server..." -cp "$TMP_DIR/mcp/server.php" &emhttpLOC;/mcp/ -cp "$TMP_DIR/mcp/helpers.php" &emhttpLOC;/mcp/ -cp "$TMP_DIR/mcp/tools.php" &emhttpLOC;/mcp/ -cp "$TMP_DIR/mcp/selftest.php" &emhttpLOC;/mcp/ -chmod 644 &emhttpLOC;/mcp/*.php - -# PHP-Helper (Write-Operationen) -echo " Installiere PHP-Helper..." -cp "$TMP_DIR/scripts/unraid-docker-mcp-helper.php" /usr/local/bin/ -chmod 755 /usr/local/bin/unraid-docker-mcp-helper.php - -# WebGUI-Tab (.page-Format) -echo " Installiere WebGUI-Tab..." -cp "$TMP_DIR/scripts/mua.page" &emhttpLOC;/mua.page -chmod 644 &emhttpLOC;/mua.page - -# SysVinit-Service -echo " Installiere Service..." -cp "$TMP_DIR/scripts/rc.mua" /etc/rc.d/rc.mua -chmod 755 /etc/rc.d/rc.mua - -# Cleanup -rm -rf "$TMP_DIR" - - + +&txzURL; +&txzSHA256; set +e -echo " Führe Selftest aus..." -php &emhttpLOC;/mcp/selftest.php 2>&1 | sed 's/^/ /' - -echo " Starte MCP-Server..." -/etc/rc.d/rc.mua start - -# Health-Check +echo "" +echo " Führe Health-Check aus..." sleep 2 -if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then +if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then echo "" echo " Health-Check: OK" echo "" echo "====================================================" echo " MUA installiert und läuft auf Port 3002" echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp" - echo " WebGUI: Settings > MUA" + echo " WebGUI: Settings > MUA" echo "====================================================" else echo "" @@ -140,10 +110,12 @@ UPDATE: Alte Dateien bereinigen --> -# Alte Dateien entfernen vor Reinstall +# Service stoppen vor Update +/etc/rc.d/rc.mua stop 2>/dev/null || true + +# Alte PHP-Dateien entfernen (falls vorhanden) rm -rf &emhttpLOC;/mcp rm -rf &emhttpLOC;/scripts -rm -f &emhttpLOC;/mua.page rm -f &emhttpLOC;/plugin.php @@ -169,16 +141,19 @@ echo "" # 2. Service-Datei entfernen rm -f /etc/rc.d/rc.mua -# 3. PHP-Helper entfernen +# 3. Binary entfernen +rm -f /usr/local/bin/mua + +# 4. PHP-Helper entfernen rm -f /usr/local/bin/unraid-docker-mcp-helper.php -# 4. Plugin-Verzeichnis entfernen +# 5. Plugin-Verzeichnis entfernen rm -rf &emhttpLOC; -# 5. Config entfernen +# 6. Config entfernen rm -rf &pluginLOC; -# 6. Logs entfernen +# 7. Logs entfernen rm -f /var/log/plugins/mua.log rm -f /var/run/mua.pid diff --git a/scripts/mua.page b/scripts/mua.page index 87acb17..8074322 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -14,6 +14,7 @@ Icon="cubes" $plugin_dir = '/usr/local/emhttp/plugins/mua'; $port = 3002; $pidfile = '/var/run/mua.pid'; +$binary = '/usr/local/bin/mua'; $endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp'; // Status bestimmen @@ -40,7 +41,7 @@ $health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $health_ok = ($health_code === 200); -$php_version = PHP_VERSION; +$binary_exists = file_exists($binary); $tool_count = 21; $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; ?> @@ -74,7 +75,8 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; - + + diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..54beee0 --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# MUA - Mikes Unraid Agent +# package.sh — Baut das Slackware .txz-Paket +# +# Struktur des .txz: +# install/doinst.sh +# install/slack-desc +# usr/local/bin/mua (kompiliertes Binary) +# etc/rc.d/rc.mua (SysVinit-Service) +# usr/local/emhttp/plugins/mua/mua.page (WebGUI-Tab) +# +# Cross-Platform: .txz ist ein gzip-tar, funktioniert auf macOS + Linux. + +set -euo pipefail + +VERSION="${1:-1.0.0}" +PKG_NAME="mua" +ARCH="x86_64" +BUILD_NUM="1" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="${ROOT}/build/pkg" +DIST_DIR="${ROOT}/dist" + +echo "====================================================" +echo " MUA - Paket-Builder" +echo " Version: ${VERSION}" +echo "====================================================" + +# 1. Binary bauen (falls nicht vorhanden) +if [ ! -f "${DIST_DIR}/mua" ]; then + echo " Kompiliere Binary..." + cd "${ROOT}" + bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua" +fi +chmod +x "${DIST_DIR}/mua" +echo " Binary: $(du -h "${DIST_DIR}/mua" | cut -f1)" + +# 2. Paket-Struktur aufbauen +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/install" +mkdir -p "${BUILD_DIR}/usr/local/bin" +mkdir -p "${BUILD_DIR}/etc/rc.d" +mkdir -p "${BUILD_DIR}/usr/local/emhttp/plugins/mua" + +# Binary +cp "${DIST_DIR}/mua" "${BUILD_DIR}/usr/local/bin/mua" +chmod 755 "${BUILD_DIR}/usr/local/bin/mua" + +# SysVinit-Service +cp "${ROOT}/scripts/rc.mua" "${BUILD_DIR}/etc/rc.d/rc.mua" +chmod 755 "${BUILD_DIR}/etc/rc.d/rc.mua" + +# WebGUI-Tab +cp "${ROOT}/scripts/mua.page" "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page" +chmod 644 "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page" + +# PHP-Helper (Write-Operationen: create/modify/update/rebuild) +cp "${ROOT}/scripts/unraid-docker-mcp-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php" +chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php" + +# 3. doinst.sh (läuft nach Installation) +cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF' +#!/bin/bash +# MUA doinst.sh — läuft nach Installation des .txz-Pakets + +# Verzeichnisse +mkdir -p /var/log/plugins +touch /var/log/plugins/mua.log +mkdir -p /boot/config/plugins/mua + +# Service starten +/etc/rc.d/rc.mua start 2>/dev/null || true + +# Health-Check +sleep 2 +if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then + echo "" + echo " Health-Check: OK" + echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp" +else + echo "" + echo " WARNUNG: Health-Check fehlgeschlagen" + echo " Logs: tail -f /var/log/plugins/mua.log" +fi +DOEOF +chmod 755 "${BUILD_DIR}/install/doinst.sh" + +# 4. slack-desc (Paket-Beschreibung) +cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF +|${PKG_NAME} (${PKG_NAME}) +| +|${PKG_NAME} - Mikes Unraid Agent +|MCP over HTTP (Streamable HTTP) Server für Unraid. +|21 Tools: Docker (14), Netzwerk (6), System (1). +|Port: 3002, Endpunkt: /mcp +| +|Runtime: TypeScript (Bun Runtime, kompiliertes Binary) +|Service: /etc/rc.d/rc.mua (SysVinit) +|WebGUI: Settings > MUA +| +|Version: ${VERSION} +|Arch: ${ARCH} +|Build: ${BUILD_NUM} +DESCEOF + +# 5. .txz erstellen (gzip-tar) +mkdir -p "${DIST_DIR}" +TXZ_NAME="${PKG_NAME}-${VERSION}-${ARCH}-${BUILD_NUM}.txz" +echo " Erstelle ${TXZ_NAME}..." +( cd "${BUILD_DIR}" && tar czf "${DIST_DIR}/${TXZ_NAME}" . ) + +echo "" +echo "====================================================" +echo " Paket erstellt: dist/${TXZ_NAME}" +echo " Größe: $(du -h "${DIST_DIR}/${TXZ_NAME}" | cut -f1)" +echo " SHA256: $(shasum -a 256 "${DIST_DIR}/${TXZ_NAME}" | cut -d' ' -f1)" +echo "====================================================" diff --git a/scripts/rc.mua b/scripts/rc.mua index f8701eb..ee41f5e 100644 --- a/scripts/rc.mua +++ b/scripts/rc.mua @@ -3,30 +3,26 @@ # SysVinit-Service-Script (rc.mua) # Unraid nutzt SysVinit, kein systemd. # Wird installiert nach /etc/rc.d/rc.mua +# +# Läuft das kompilierte TypeScript-Binary (Bun Runtime) — +# kein PHP-Built-in-Server mehr. -PLUGIN_DIR="/usr/local/emhttp/plugins/mua" -SERVER="$PLUGIN_DIR/mcp/server.php" +DAEMON="/usr/local/bin/mua" PIDFILE="/var/run/mua.pid" LOGFILE="/var/log/plugins/mua.log" PORT=3002 -PHP_BIN="/usr/bin/php" - -# PHP-Binary finden (Fallback) -if [ ! -x "$PHP_BIN" ]; then - PHP_BIN=$(command -v php 2>/dev/null) -fi start() { if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo " MUA läuft bereits (PID $(cat "$PIDFILE"))" return 0 fi - if [ ! -f "$SERVER" ]; then - echo " FEHLER: Server-Datei nicht gefunden: $SERVER" + if [ ! -x "$DAEMON" ]; then + echo " FEHLER: Binary nicht gefunden: $DAEMON" return 1 fi # Server starten (detached) - nohup "$PHP_BIN" -S 0.0.0.0:$PORT "$SERVER" >> "$LOGFILE" 2>&1 & + MUA_PORT=$PORT nohup "$DAEMON" >> "$LOGFILE" 2>&1 & echo $! > "$PIDFILE" sleep 1 if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..2a2b0b7 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,497 @@ +/** + * 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 = "1.0.0"; +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, + ); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e6a51b6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,278 @@ +/** + * MUA — Mikes Unraid Agent + * index.ts — MCP over HTTP (Streamable HTTP) Server + * + * Portiert von mcp/server.php. Nutzt Bun.serve() statt php -S — + * production-grade HTTP-Server, POST-Body wird korrekt gelesen. + * + * Transport: MCP Streamable HTTP (POST-only, JSON-RPC 2.0). + * Endpunkte: + * POST /mcp — JSON-RPC Request + * GET /mcp — 405 (SSE nicht implementiert, spec-konform) + * DELETE /mcp — Session-End + * GET /health — Health-Check + */ + +import { + MUA_SERVER_NAME, + MUA_VERSION, + MUA_PROTOCOL_VERSION, +} from "./helpers"; +import { TOOLS, toolByName } from "./tools"; + +const PORT = Number(process.env["MUA_PORT"] ?? 3002); +const HOST = process.env["MUA_HOST"] ?? "0.0.0.0"; + +// ── JSON-RPC Helpers ──────────────────────────────────────────────────── +function rpcResult(id: number | string | null, result: unknown) { + return { jsonrpc: "2.0", id, result }; +} +function rpcError(id: number | string | null, code: number, message: string) { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +// ── Session Management ────────────────────────────────────────────────── +const sessions = new Map(); + +function newSessionId(): string { + return crypto.randomUUID(); +} +function touchSession(id: string) { + const now = Date.now(); + if (sessions.has(id)) { + sessions.get(id)!.lastActivity = now; + } else { + sessions.set(id, { createdAt: now, lastActivity: now }); + } +} +function deleteSession(id: string | null) { + if (id) sessions.delete(id); +} + +// ── MCP Request Handler ───────────────────────────────────────────────── +async function handleMcpRequest( + message: Record, + sessionId: string | null, +): Promise<{ response: unknown; sessionId: string } | null> { + const method = (message["method"] as string) ?? ""; + const id = message["id"] as number | string | null; + const params = (message["params"] as Record) ?? {}; + + // ── initialize ──────────────────────────────────────────────────────── + if (method === "initialize") { + const sid = newSessionId(); + touchSession(sid); + return { + response: rpcResult(id, { + protocolVersion: MUA_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: MUA_SERVER_NAME, version: MUA_VERSION }, + }), + sessionId: sid, + }; + } + + // ── notifications/initialized (kein Response) ───────────────────────── + if (method === "notifications/initialized") { + if (sessionId) touchSession(sessionId); + return null; + } + + // ── tools/list ──────────────────────────────────────────────────────── + if (method === "tools/list") { + if (sessionId) touchSession(sessionId); + return { + response: rpcResult(id, { + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }), + sessionId: sessionId ?? "", + }; + } + + // ── tools/call ──────────────────────────────────────────────────────── + if (method === "tools/call") { + if (sessionId) touchSession(sessionId); + const toolName = (params["name"] as string) ?? ""; + const args = (params["arguments"] as Record) ?? {}; + const tool = toolByName(toolName); + if (!tool) { + return { + response: rpcResult(id, { + content: [{ type: "text", text: `ERROR: Unknown tool: ${toolName}` }], + isError: true, + }), + sessionId: sessionId ?? "", + }; + } + try { + const text = await tool.handler(args); + const result: Record = { + content: [{ type: "text", text }], + }; + // structuredContent für Tools mit JSON-Output + if ( + [ + "unraid_docker_list", + "unraid_network_inventory", + "unraid_docker_analyze_logs", + "unraid_network_audit_tcp", + ].includes(toolName) + ) { + try { + result.structuredContent = JSON.parse(text); + } catch { + // ignore + } + } + return { response: rpcResult(id, result), sessionId: sessionId ?? "" }; + } catch (e) { + return { + response: rpcResult(id, { + content: [{ type: "text", text: `ERROR: ${String(e)}` }], + isError: true, + }), + sessionId: sessionId ?? "", + }; + } + } + + // ── ping ────────────────────────────────────────────────────────────── + if (method === "ping") { + if (sessionId) touchSession(sessionId); + return { response: rpcResult(id, {}), sessionId: sessionId ?? "" }; + } + + // ── Unknown method ──────────────────────────────────────────────────── + if (id !== null && id !== undefined) { + return { + response: rpcError(id, -32601, `Method not found: ${method}`), + sessionId: sessionId ?? "", + }; + } + return null; +} + +// ── HTTP Server (Bun.serve — production-grade) ────────────────────────── +const server = Bun.serve({ + port: PORT, + hostname: HOST, + idleTimeout: 120, // Sekunden (für lange docker stats / audit) + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + const sessionHeader = req.headers.get("mcp-session-id"); + + // CORS für lokale Nutzung + const corsHeaders: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Mcp-Session-Id", + }; + + // ── OPTIONS (CORS Preflight) ──────────────────────────────────────── + if (method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + // ── Health-Check ──────────────────────────────────────────────────── + if (path === "/health") { + return Response.json( + { + status: "ok", + server: MUA_SERVER_NAME, + version: MUA_VERSION, + port: PORT, + time: new Date().toISOString(), + }, + { headers: corsHeaders }, + ); + } + + // ── MCP-Endpunkt ──────────────────────────────────────────────────── + if (path === "/mcp" || path === "/") { + // POST: JSON-RPC Request + if (method === "POST") { + let body: string; + try { + body = await req.text(); + } catch (e) { + return Response.json( + rpcError(null, -32700, "Parse error"), + { status: 400, headers: corsHeaders }, + ); + } + + let message: Record; + try { + message = JSON.parse(body); + } catch { + return Response.json( + rpcError(null, -32700, "Parse error"), + { status: 400, headers: corsHeaders }, + ); + } + + const result = await handleMcpRequest(message, sessionHeader); + + // Notification (kein Response nötig) + if (result === null) { + return new Response(null, { + status: 202, + headers: { + ...corsHeaders, + "Mcp-Session-Id": sessionHeader ?? "", + }, + }); + } + + return Response.json(result.response, { + headers: { + ...corsHeaders, + "Mcp-Session-Id": result.sessionId, + }, + }); + } + + // GET: SSE-Stream (nicht implementiert, spec-konform 405) + if (method === "GET") { + return Response.json( + { error: "SSE not supported. Use POST /mcp for JSON-RPC requests." }, + { status: 405, headers: corsHeaders }, + ); + } + + // DELETE: Session-End + if (method === "DELETE") { + deleteSession(sessionHeader); + return new Response(null, { status: 200, headers: corsHeaders }); + } + } + + // ── 404 ───────────────────────────────────────────────────────────── + return Response.json( + { error: "Not found. Use /mcp or /health" }, + { status: 404, headers: corsHeaders }, + ); + }, +}); + +console.log( + `[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP Streamable HTTP)`, +); + +// Graceful shutdown +process.on("SIGTERM", () => { + console.log("[MUA] SIGTERM received, shutting down"); + server.stop(true); + process.exit(0); +}); +process.on("SIGINT", () => { + console.log("[MUA] SIGINT received, shutting down"); + server.stop(true); + process.exit(0); +}); diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..b39b595 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,309 @@ +/** + * MUA — Mikes Unraid Agent + * tools.ts — MCP Tool-Definitionen (21 Tools) + * + * Portiert von mcp/tools.php. Schema: unraid__ + * Kategorien: docker (14), network (6), system (1). + */ + +import { + containerRuntimeSummary, + compactContainerInspect, + dockerExec, + sanitizeLogOutput, + analyzeContainerLogs, + runPhpHelper, + compactNetworkInventory, + hostState, + auditAllTcpEndpoints, + probeDualstack, + connectionTest, + validateName, +} from "./helpers"; + +export interface ToolDef { + name: string; + description: string; + inputSchema: object; + handler: (args: Record) => Promise; +} + +const str = (desc: string) => ({ type: "string", description: desc }); +const int = (desc: string) => ({ type: "integer", description: desc }); +const num = (desc: string) => ({ type: "number", description: desc }); +const bool = (desc: string) => ({ type: "boolean", description: desc }); +const empty = { type: "object", properties: {}, additionalProperties: false } as const; + +export const TOOLS: ToolDef[] = [ + // ── 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: empty, + handler: () => containerRuntimeSummary(), + }, + { + name: "unraid_docker_inspect", + description: + "Inspect a single Docker container in detail (state, image, ports, env, mounts).", + inputSchema: { + type: "object", + properties: { container: str("Container name or ID") }, + required: ["container"], + }, + handler: (a) => compactContainerInspect(validateName(a["container"], "container")), + }, + { + name: "unraid_docker_logs", + description: "Get recent logs from a Docker container (with timestamps).", + inputSchema: { + type: "object", + properties: { + container: str("Container name or ID"), + tail: int("Number of lines (1-2000, default 200)"), + }, + required: ["container"], + }, + handler: async (a) => { + const container = validateName(a["container"], "container"); + const tail = Number(a["tail"] ?? 200); + if (tail < 1 || tail > 2000) throw new Error("tail must be between 1 and 2000"); + const raw = await dockerExec(`logs --timestamps --tail ${tail} ${container}`, 60); + return sanitizeLogOutput(raw); + }, + }, + { + 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: str("Container name (optional, scans all if omitted)"), + since: str("Time filter (default 24h)"), + scan_tail: int("Max lines to scan (default 1000)"), + max_results: int("Max sample matches (default 50)"), + }, + required: ["severity"], + }, + handler: (a) => { + const severity = (a["severity"] as string) ?? "error"; + const container = a["container"] as string | null | undefined; + if (container !== undefined && container !== null && typeof container !== "string") { + throw new Error("container must be a string"); + } + const since = (a["since"] as string) ?? "24h"; + const scanTail = Number(a["scan_tail"] ?? 1000); + const maxResults = Number(a["max_results"] ?? 50); + return analyzeContainerLogs(severity, container ?? null, since, scanTail, maxResults); + }, + }, + { + name: "unraid_docker_processes", + description: "List processes running inside a Docker container (docker top).", + inputSchema: { + type: "object", + properties: { container: str("Container name or ID") }, + required: ["container"], + }, + handler: (a) => + dockerExec( + `top ${validateName(a["container"], "container")} -eo pid,ppid,user,stat,lstart,etime,args`, + ), + }, + { + name: "unraid_docker_stats", + description: "Get live CPU/memory/network/block I/O stats for all running containers.", + inputSchema: empty, + handler: () => dockerExec(`stats --no-stream --format '{{json .}}'`), + }, + { + name: "unraid_docker_info", + description: + "Get Docker daemon information (version, storage driver, container counts, etc.).", + inputSchema: empty, + handler: () => dockerExec(`info --format '{{json .}}'`), + }, + { + name: "unraid_docker_start", + description: "Start a Docker container.", + inputSchema: { + type: "object", + properties: { container: str("Container name or ID") }, + required: ["container"], + }, + handler: (a) => dockerExec(`start ${validateName(a["container"], "container")}`), + }, + { + name: "unraid_docker_stop", + description: "Stop a Docker container.", + inputSchema: { + type: "object", + properties: { container: str("Container name or ID") }, + required: ["container"], + }, + handler: (a) => dockerExec(`stop ${validateName(a["container"], "container")}`), + }, + { + name: "unraid_docker_restart", + description: "Restart a Docker container.", + inputSchema: { + type: "object", + properties: { container: str("Container name or ID") }, + required: ["container"], + }, + handler: (a) => dockerExec(`restart ${validateName(a["container"], "container")}`), + }, + { + name: "unraid_docker_create", + description: "Create a Docker container from a template (pulls image, creates container).", + inputSchema: { + type: "object", + properties: { + template_name: str('Template name (e.g. "linuxserver/sonarr")'), + }, + required: ["template_name"], + }, + handler: (a) => runPhpHelper("create", validateName(a["template_name"], "template_name")), + }, + { + name: "unraid_docker_modify", + description: + "Modify a container template (port, env, volume, network, privileged) and rebuild.", + inputSchema: { + type: "object", + properties: { + container: str("Container/template name"), + field: { + type: "string", + enum: ["port", "env", "volume", "network", "privileged"], + }, + value: str("New value (format depends on field)"), + }, + required: ["container", "field", "value"], + }, + handler: (a) => { + const container = validateName(a["container"], "container"); + const field = (a["field"] as string) ?? ""; + const value = (a["value"] as string) ?? ""; + if (!["port", "env", "volume", "network", "privileged"].includes(field)) { + throw new Error("field must be one of: port, env, volume, network, privileged"); + } + return runPhpHelper("modify", container, field, value); + }, + }, + { + name: "unraid_docker_update", + description: "Update a container (pull latest image, rebuild).", + inputSchema: { + type: "object", + properties: { container: str("Container/template name") }, + required: ["container"], + }, + handler: (a) => runPhpHelper("update", validateName(a["container"], "container")), + }, + { + name: "unraid_docker_rebuild", + description: + "Rebuild a container from its template (without pulling new image).", + inputSchema: { + type: "object", + properties: { container: str("Container/template name") }, + required: ["container"], + }, + handler: (a) => runPhpHelper("rebuild", validateName(a["container"], "container")), + }, + + // ── Netzwerk (6) ────────────────────────────────────────────────────── + { + name: "unraid_network_inventory", + description: "Compact Docker network inventory (all networks with container counts).", + inputSchema: empty, + handler: () => compactNetworkInventory(), + }, + { + name: "unraid_network_list", + description: "List all Docker networks.", + inputSchema: empty, + handler: () => dockerExec(`network ls --no-trunc --format '{{json .}}'`), + }, + { + name: "unraid_network_inspect", + description: "Inspect a Docker network in detail.", + inputSchema: { + type: "object", + properties: { network: str("Network name or ID") }, + required: ["network"], + }, + handler: (a) => dockerExec(`network inspect -- ${validateName(a["network"], "network")}`), + }, + { + name: "unraid_network_host_state", + description: + "Get host network state (IPv4/IPv6 addresses, routes, listening sockets).", + inputSchema: empty, + handler: () => hostState(), + }, + { + 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: num("Probe timeout (0.2-10, default 2)"), + include_all_endpoints: bool("Include all endpoints (default false, only problems)"), + }, + }, + handler: (a) => { + const timeout = Number(a["timeout_seconds"] ?? 2); + if (timeout < 0.2 || timeout > 10) { + throw new Error("timeout_seconds must be between 0.2 and 10"); + } + const includeAll = Boolean(a["include_all_endpoints"] ?? false); + return auditAllTcpEndpoints(timeout, includeAll); + }, + }, + { + name: "unraid_network_lan_probe", + description: + "Probe a specific host:port for IPv4 and IPv6 reachability (dualstack test).", + inputSchema: { + type: "object", + properties: { + host: str("Hostname or IP"), + port: int("Port (1-65535)"), + timeout_seconds: num("Timeout (0.2-10, default 3)"), + }, + required: ["host", "port"], + }, + handler: (a) => { + const host = validateName(a["host"], "host"); + const port = Number(a["port"] ?? 0); + const timeout = Number(a["timeout_seconds"] ?? 3); + if (port < 1 || port > 65535 || timeout < 0.2 || timeout > 10) { + throw new Error("Invalid port or timeout"); + } + return probeDualstack(host, port, timeout); + }, + }, + + // ── System (1) ──────────────────────────────────────────────────────── + { + name: "unraid_system_connection_test", + description: + "Test connection to the Unraid host (hostname, kernel, Unraid version).", + inputSchema: empty, + handler: () => connectionTest(), + }, +]; + +export function toolByName(name: string): ToolDef | undefined { + return TOOLS.find((t) => t.name === name); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..efffbcd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts"] +}
PluginMUA (Mikes Unraid Agent)
Version1.0.0
PHP
RuntimeTypeScript (Bun Runtime, kompiliertes Binary)
Binary
Port
Host-IP
Tools (Docker: 14, Netzwerk: 6, System: 1)