diff --git a/README.md b/README.md index 3154d25..37a3ee2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid. - **Health-Check:** `http://:3002/health` (offen, ohne Auth) - **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token) - **Auth:** API-Key (Bearer-Token) für `/mcp` -- **Tools:** 22 (Docker: 14, Netzwerk: 6, System: 2) +- **Tools:** 23 (Docker: 14, Netzwerk: 6, System: 3) --- @@ -43,6 +43,10 @@ Die WebGUI schützt schreibende Formulare außerdem mit einem CSRF-Token. - Neuinstallationen starten im Profil **Nur Lesen**. Container-Steuerung, aktive Netzwerktests, Container-Umbauten und die Root-Shell sind aus. +- Für Diagnosen steht `unraid_system_shell_readonly` bereit. Es startet nur + fest freigegebene Leseprogramme als direkte Argumentliste, niemals über + `/bin/sh`; Verkettungen, Pipes, Umleitungen und schreibende Optionen werden + dadurch verhindert. Die freie Root-Shell bleibt separat und kritisch. - `none` bedeutet tatsächlich **keine Tools aktiv**; `all` ist ein expliziter Vollzugriff und wird in der GUI deutlich gewarnt. - Container-Umgebungswerte werden nie ausgegeben, nur ihre Variablennamen. @@ -68,10 +72,10 @@ Oder manuell: ```bash # .txz von Gitea laden -curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r007-x86_64-1.txz +curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r008-x86_64-1.txz # Installieren -upgradepkg --install-new mua-2026.08.21.r007-x86_64-1.txz +upgradepkg --install-new mua-2026.08.21.r008-x86_64-1.txz ``` ### 2. Service starten @@ -86,7 +90,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit). ```bash curl http://192.168.1.2:3002/health -# → {"status":"ok","version":"2026.08.21.r007","auth":"required"} +# → {"status":"ok","version":"2026.08.21.r008","auth":"required"} ``` --- @@ -206,7 +210,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden: |--------|-------| | **Docker (14)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_rebuild` | | **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` | -| **System (2)** | `unraid_system_connection_test`, `unraid_system_shell` | +| **System (3)** | `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in `tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`). diff --git a/dist/mua-2026.08.21.r008-x86_64-1.txz b/dist/mua-2026.08.21.r008-x86_64-1.txz new file mode 100644 index 0000000..46ef568 Binary files /dev/null and b/dist/mua-2026.08.21.r008-x86_64-1.txz differ diff --git a/package.json b/package.json index ed75406..200ca16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.21.r007", + "version": "2026.08.21.r008", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "type": "module", "main": "src/index.ts", diff --git a/plugin/mua.plg b/plugin/mua.plg index 5fd510f..3158ac9 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.21.r008 +- Neue getrennte Nur-Lese-Shell: unraid_system_shell_readonly mit Programm-Allowlist und direkter argv-Ausführung ohne /bin/sh. +- Schreibende Unterbefehle und Optionen für ip, find, ss, dmesg, date, mount und hostname werden serverseitig abgelehnt. +- Die uneingeschränkte Root-Shell bleibt als separates kritisches Werkzeug standardmäßig deaktiviert. +- GUI-Fix: MUA-eigene CSS-Klassennamen verhindern die Kollision mit Unraids globaler notice-Klasse; die blaue Karte wird wieder korrekt dargestellt. ### 2026.08.21.r007 - Navigation: MUA liegt unter Settings → User Utilities; der Klick im Plugin-Manager öffnet zuverlässig die MUA-Seite (korrekte Groß-/Kleinschreibung). - Sicherheitsprofile: Nur Lesen (Standard), Betrieb + Diagnose, Alles sperren und expliziter Vollzugriff. @@ -91,7 +96,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/scripts/mua.page b/scripts/mua.page index d5f4719..79be802 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -70,6 +70,7 @@ $readonly = [ 'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats', 'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list', 'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test', + 'unraid_system_shell_readonly', ]; $operator = array_merge($readonly, [ 'unraid_network_audit_tcp', 'unraid_network_lan_probe', @@ -130,18 +131,18 @@ foreach ($all_tools as $tool) { $risk_groups[$risk][] = $tool; } $risk_labels = [ - 'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'safe'], - 'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'notice'], - 'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'warn'], - 'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'danger'], + 'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'mua-risk-safe'], + 'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'mua-risk-notice'], + 'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'mua-risk-warn'], + 'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'mua-risk-danger'], ]; $active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools); ?>
-

MUA · Mikes Unraid Agent

Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen
+

MUA · Mikes Unraid Agent

Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen
Neuer API-Key – einmalige Anzeige
diff --git a/scripts/package.sh b/scripts/package.sh index 6841465..dbe8529 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -100,7 +100,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF | |${PKG_NAME} - Mikes Unraid Agent |MCP over HTTP (Streamable HTTP) Server für Unraid. -|22 Tools: Docker (14), Netzwerk (6), System (2). +|23 Tools: Docker (14), Netzwerk (6), System (3). |Port: 3002, Endpunkt: /mcp | |Runtime: TypeScript (Bun Runtime, kompiliertes Binary) diff --git a/src/auth.ts b/src/auth.ts index f7bcaba..3c47813 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -49,6 +49,7 @@ export const SAFE_DEFAULT_TOOLS = [ "unraid_network_inspect", "unraid_network_host_state", "unraid_system_connection_test", + "unraid_system_shell_readonly", ]; // ── Config laden ──────────────────────────────────────────────────────── diff --git a/src/helpers.ts b/src/helpers.ts index 5f9e83c..811c777 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.21.r007"; +export const MUA_VERSION = "2026.08.21.r008"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; @@ -81,6 +81,119 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise { } } +const READ_ONLY_PROGRAMS = new Set([ + "cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep", + "head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount", + "ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname", + "uptime", "wc", "whoami", +]); + +async function readStreamLimited( + stream: ReadableStream, + maxBytes: number, +): Promise<{ text: string; truncated: boolean }> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let kept = 0; + let truncated = false; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const available = Math.max(0, maxBytes - kept); + if (kept < maxBytes) { + const slice = value.subarray(0, available); + if (slice.length > 0) chunks.push(slice); + kept += slice.length; + } + if (value.length > available) truncated = true; + } + const combined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + return { text: new TextDecoder().decode(combined), truncated }; +} + +/** + * Führt ausschließlich freigegebene Leseprogramme direkt als argv aus. + * Kein /bin/sh, keine Pipes, Umleitungen, Substitutionen oder Verkettungen. + */ +export async function runReadOnlyCommand( + program: string, + args: string[], + timeoutSec = 30, +): Promise { + if (!READ_ONLY_PROGRAMS.has(program)) { + throw new Error(`Program is not allowed in read-only mode: ${program}`); + } + if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) { + throw new Error("Invalid or excessive arguments"); + } + + const lowered = args.map((arg) => arg.toLowerCase()); + const reject = (message: string) => { throw new Error(message); }; + if (["hostname", "whoami", "uptime"].includes(program) && args.length > 0) { + reject(`${program} does not accept arguments in read-only mode`); + } + if (program === "date") { + const safeDateFlags = new Set(["-u", "--utc", "-r", "--reference", "--rfc-email", "-d", "--date"]); + for (let i = 0; i < args.length; i++) { + const arg = lowered[i]; + if (i > 0 && ["-d", "--date", "-r", "--reference"].includes(lowered[i - 1])) continue; + if (arg.startsWith("+") || safeDateFlags.has(arg) || arg.startsWith("--date=") || + arg.startsWith("--iso-8601") || arg.startsWith("--rfc-3339")) continue; + reject("Only date display and parsing options are allowed"); + } + } + if (program === "dmesg" && args.some((a) => + ["-C", "-D", "-E", "-n"].includes(a) || + ["--clear", "--read-clear", "--console-off", "--console-on", "--console-level"].includes(a.toLowerCase()) + )) { + reject("Changing or clearing the kernel log is not allowed"); + } + if (program === "ss" && lowered.some((a) => a === "-k" || a === "--kill")) { + reject("Killing sockets is not allowed"); + } + if (program === "mount" && args.length > 0) { + reject("mount is display-only and accepts no arguments in read-only mode"); + } + if (program === "ip") { + const safeObjects = new Set(["address", "addr", "route", "link", "neigh", "neighbor"]); + const mutating = new Set(["add", "append", "change", "delete", "del", "flush", "replace", "set"]); + if (args.length === 0 || !safeObjects.has(lowered[0]) || lowered.some((a) => mutating.has(a))) { + reject("Only read-only ip objects and show/list operations are allowed"); + } + } + if (program === "find") { + const mutatingFind = ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"]; + if (lowered.some((a) => mutatingFind.some((blocked) => a === blocked || a.startsWith(blocked)))) { + reject("Mutating find actions are not allowed"); + } + } + + try { + const proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" }); + const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); + const [stdout, stderr, code] = await Promise.all([ + readStreamLimited(proc.stdout, 100_000), + readStreamLimited(proc.stderr, 20_000), + proc.exited, + ]); + clearTimeout(timeout); + return JSON.stringify({ + exit_code: code, + stdout: sanitizeLogOutput(stdout.text.trim(), 100_000), + stderr: sanitizeLogOutput(stderr.text.trim(), 20_000), + truncated: stdout.truncated || stderr.truncated, + mode: "read-only", + }); + } catch (e) { + throw new Error(`read-only command failed: ${String(e)}`); + } +} + /** * Docker-Befehl ausführen (kompakt). */ diff --git a/src/security.test.ts b/src/security.test.ts index 0102ac7..582a033 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth"; -import { sanitizeLogOutput } from "./helpers"; +import { runReadOnlyCommand, sanitizeLogOutput } from "./helpers"; import { getToolRisk } from "./tools"; describe("secure tool configuration", () => { @@ -43,5 +43,21 @@ describe("risk classification", () => { expect(getToolRisk("unraid_docker_restart")).toBe("write"); expect(getToolRisk("unraid_network_lan_probe")).toBe("active"); expect(getToolRisk("unraid_docker_list")).toBe("read"); + expect(getToolRisk("unraid_system_shell_readonly")).toBe("read"); + }); +}); + +describe("read-only shell", () => { + test("executes an allowlisted program without a shell", async () => { + const result = JSON.parse(await runReadOnlyCommand("ls", ["-ld", "/"], 5)); + expect(result.exit_code).toBe(0); + expect(result.mode).toBe("read-only"); + }); + + test("rejects arbitrary programs and mutating subcommands", async () => { + await expect(runReadOnlyCommand("sh", ["-c", "id"], 5)).rejects.toThrow(); + await expect(runReadOnlyCommand("ip", ["link", "set", "lo", "down"], 5)).rejects.toThrow(); + await expect(runReadOnlyCommand("find", ["/tmp", "-delete"], 5)).rejects.toThrow(); + await expect(runReadOnlyCommand("ss", ["-K", "dst", "127.0.0.1"], 5)).rejects.toThrow(); }); }); diff --git a/src/tools.ts b/src/tools.ts index 0bd14ce..827a79a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,6 +1,6 @@ /** * MUA — Mikes Unraid Agent - * tools.ts — MCP Tool-Definitionen (22 Tools) + * tools.ts — MCP Tool-Definitionen (23 Tools) * * Portiert von mcp/tools.php. Schema: unraid__ * Kategorien: docker (14), network (6), system (2). @@ -20,6 +20,7 @@ import { connectionTest, validateName, runShell, + runReadOnlyCommand, } from "./helpers"; export interface ToolDef { @@ -321,7 +322,7 @@ export const TOOLS: ToolDef[] = [ }, }, - // ── System (2) ──────────────────────────────────────────────────────── + // ── System (3) ──────────────────────────────────────────────────────── { name: "unraid_system_connection_test", description: @@ -329,6 +330,47 @@ export const TOOLS: ToolDef[] = [ inputSchema: empty, handler: () => connectionTest(), }, + { + name: "unraid_system_shell_readonly", + description: + "Run a strictly allowlisted read-only command without a shell interpreter. Supports diagnostics such as ls, tail, head, cat, grep, stat, find, ps, df, du, ss and read-only ip show/list operations. Pipes, redirects, command chaining and mutating options are impossible or rejected.", + inputSchema: { + type: "object", + properties: { + program: { + type: "string", + enum: [ + "cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep", + "head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount", + "ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname", + "uptime", "wc", "whoami", + ], + description: "Allowlisted read-only program", + }, + args: { + type: "array", + items: { type: "string", maxLength: 4096 }, + maxItems: 64, + description: "Argument vector; passed directly without /bin/sh", + }, + timeout_seconds: int("Timeout in seconds (1-120, default 30)"), + }, + required: ["program"], + additionalProperties: false, + }, + handler: (a) => { + const program = (a["program"] as string) ?? ""; + const rawArgs = a["args"] ?? []; + if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== "string")) { + throw new Error("args must be an array of strings"); + } + const timeout = Number(a["timeout_seconds"] ?? 30); + if (timeout < 1 || timeout > 120) { + throw new Error("timeout_seconds must be between 1 and 120"); + } + return runReadOnlyCommand(program, rawArgs as string[], timeout); + }, + }, { name: "unraid_system_shell", description: