diff --git a/README.md b/README.md index fa11c17..b946b4d 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,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.24.r021-x86_64-1.txz +curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz # Installieren -upgradepkg --install-new mua-2026.08.24.r021-x86_64-1.txz +upgradepkg --install-new mua-2026.08.24.r022-x86_64-1.txz ``` ### 2. Service starten @@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit). ```bash curl http://192.168.1.2:3002/health -# → {"status":"ok","version":"2026.08.24.r021","auth":"required"} +# → {"status":"ok","version":"2026.08.24.r022","auth":"required"} ``` --- @@ -215,7 +215,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden: | **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` | | **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` | | **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` | -| **System (10)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | +| **System (13)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell`, `unraid_system_job_start`, `unraid_system_job_status`, `unraid_system_job_cleanup` | 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.24.r022-x86_64-1.txz b/dist/mua-2026.08.24.r022-x86_64-1.txz new file mode 100644 index 0000000..dabd7eb Binary files /dev/null and b/dist/mua-2026.08.24.r022-x86_64-1.txz differ diff --git a/package.json b/package.json index 72c96fe..3e5f71f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.24.r021", + "version": "2026.08.24.r022", "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 6e6a556..e1c3de7 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.24.r022 +- Allgemeine asynchrone Root-Jobs mit getrennten Werkzeugen für Start, kompakten Status und Aufräumen verhindern HTTP-Timeouts bei langen autorisierten Arbeiten. +- Bestehende Freigaben der kritischen Root-Shell erhalten dieselbe Berechtigungsklasse automatisch; Nur-Lese-Profile bleiben unverändert. +- Jobstatus enthält nur Zustand, Exit-Code und begrenzte, redigierte Log-Enden. Kommandoargumente und mögliche Secrets werden nicht zurückgegeben. + ### 2026.08.24.r021 - Die gefilterte Sammlungssuche beendet den Abstieg an einem passenden Verzeichnis. Episodenordner fluten dadurch nicht mehr das begrenzte Erkennungsergebnis. - Der zweite Aufruf auf den gefundenen Sammlungspfad bleibt unverändert und liefert die vollständige gebündelte Bestandsaufnahme. @@ -140,7 +145,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/scripts/package.sh b/scripts/package.sh index a8c2cdc..94913cd 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF | |${PKG_NAME} - Mikes Unraid Agent |MCP over HTTP (Streamable HTTP) Server für Unraid. -|35 Tools: Docker, Community Applications, Netzwerk und Unraid-System. +|Werkzeuge für Docker, Community Applications, Netzwerk und Unraid-System. |Port: 3002, Endpunkt: /mcp | |Runtime: TypeScript (Bun Runtime, kompiliertes Binary) diff --git a/src/auth.ts b/src/auth.ts index 311120a..35559fd 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -116,6 +116,18 @@ export function parseConfig(content: string): MUAConfig { ) { cfg.enabledTools.push("unraid_files_inventory"); } + // Async jobs are the bounded long-running equivalent of the already + // unrestricted root shell. Existing administrators who explicitly enabled + // that critical capability receive no new authority class here. + if (!cfg.allToolsEnabled && cfg.enabledTools.includes("unraid_system_shell")) { + for (const name of [ + "unraid_system_job_start", + "unraid_system_job_status", + "unraid_system_job_cleanup", + ]) { + if (!cfg.enabledTools.includes(name)) cfg.enabledTools.push(name); + } + } return cfg; } diff --git a/src/helpers.ts b/src/helpers.ts index 7c180dd..a133fdd 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -11,10 +11,11 @@ import { spawn } from "bun"; import { createConnection, type Socket } from "net"; import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.24.r021"; +export const MUA_VERSION = "2026.08.24.r022"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php"; @@ -83,6 +84,87 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise { } } +const ASYNC_JOB_ROOT = "/tmp/mua-jobs"; +const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function jobPath(jobId: string): string { + if (!JOB_ID_PATTERN.test(jobId)) throw new Error("invalid job_id"); + return `${ASYNC_JOB_ROOT}/${jobId}`; +} + +async function tailJobFile(path: string, maxBytes = 12_000): Promise { + if (!existsSync(path)) return ""; + const size = statSync(path).size; + const start = Math.max(0, size - maxBytes); + return sanitizeLogOutput(await Bun.file(path).slice(start, size).text(), maxBytes); +} + +/** Start arbitrary, explicitly authorized host work without tying up MCP HTTP. */ +export async function startShellJob(cmd: string): Promise { + if (cmd.trim() === "") throw new Error("command is required"); + mkdirSync(ASYNC_JOB_ROOT, { recursive: true, mode: 0o700 }); + const jobId = randomUUID(); + const dir = jobPath(jobId); + mkdirSync(dir, { mode: 0o700 }); + const script = `${dir}/run.sh`; + // The unrestricted command is already a critical/admin-only capability. + // Keep it out of status output and audit responses because it may contain + // credentials supplied by the operator. + writeFileSync( + script, + `#!/bin/sh\n${cmd}\ncode=$?\nprintf '%s\\n' "$code" > '${dir}/exit_code'\nexit "$code"\n`, + { mode: 0o700 }, + ); + const proc = spawn(["/bin/sh", script], { + cwd: "/", + stdin: "ignore", + stdout: Bun.file(`${dir}/stdout.log`), + stderr: Bun.file(`${dir}/stderr.log`), + }); + proc.unref(); + writeFileSync( + `${dir}/meta.json`, + JSON.stringify({ job_id: jobId, pid: proc.pid, started_at: new Date().toISOString() }), + { mode: 0o600 }, + ); + return JSON.stringify({ job_id: jobId, state: "running", pid: proc.pid }); +} + +/** Poll a job with compact tail output; works across independent MCP calls. */ +export async function shellJobStatus(jobId: string): Promise { + const dir = jobPath(jobId); + if (!existsSync(`${dir}/meta.json`)) throw new Error("job not found"); + const meta = JSON.parse(readFileSync(`${dir}/meta.json`, "utf-8")); + let running = false; + if (!existsSync(`${dir}/exit_code`)) { + try { + process.kill(Number(meta.pid), 0); + running = true; + } catch { + running = false; + } + } + const exitCode = existsSync(`${dir}/exit_code`) + ? Number(readFileSync(`${dir}/exit_code`, "utf-8").trim()) + : null; + return JSON.stringify({ + job_id: jobId, + state: running ? "running" : exitCode === null ? "unknown" : "finished", + exit_code: exitCode, + started_at: meta.started_at, + stdout_tail: await tailJobFile(`${dir}/stdout.log`), + stderr_tail: await tailJobFile(`${dir}/stderr.log`), + }); +} + +/** Remove only one completed MUA job directory. */ +export async function cleanupShellJob(jobId: string): Promise { + const status = JSON.parse(await shellJobStatus(jobId)); + if (status.state === "running") throw new Error("cannot clean up a running job"); + rmSync(jobPath(jobId), { recursive: true, force: false }); + return JSON.stringify({ job_id: jobId, cleaned: true, previous_state: status.state }); +} + async function runArgv( label: string, argv: string[], diff --git a/src/security.test.ts b/src/security.test.ts index d093087..3f81e2c 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -38,6 +38,15 @@ describe("secure tool configuration", () => { expect(cfg.enabledTools).toContain("unraid_files_inventory"); expect(cfg.enabledTools).not.toContain("unraid_system_shell"); }); + + test("an enabled critical shell also exposes bounded asynchronous jobs", () => { + const cfg = parseConfig( + "MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_system_shell\n", + ); + expect(cfg.enabledTools).toContain("unraid_system_job_start"); + expect(cfg.enabledTools).toContain("unraid_system_job_status"); + expect(cfg.enabledTools).toContain("unraid_system_job_cleanup"); + }); }); describe("secret handling", () => { @@ -55,6 +64,9 @@ describe("secret handling", () => { describe("risk classification", () => { test("classifies root shell and container changes as critical", () => { expect(getToolRisk("unraid_system_shell")).toBe("critical"); + expect(getToolRisk("unraid_system_job_start")).toBe("critical"); + expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical"); + expect(getToolRisk("unraid_system_job_status")).toBe("read"); expect(getToolRisk("unraid_docker_modify")).toBe("critical"); expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical"); expect(getToolRisk("unraid_docker_restart")).toBe("write"); diff --git a/src/tools.ts b/src/tools.ts index 48d0841..169d11d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -20,6 +20,9 @@ import { connectionTest, validateName, runShell, + startShellJob, + shellJobStatus, + cleanupShellJob, runReadOnlyCommand, runStatusHelper, searchCommunityApps, @@ -43,6 +46,8 @@ const CRITICAL_TOOLS = new Set([ "unraid_docker_update_verified_batch", "unraid_docker_rebuild", "unraid_system_shell", + "unraid_system_job_start", + "unraid_system_job_cleanup", "unraid_ca_install", ]); const WRITE_TOOLS = new Set([ @@ -634,6 +639,44 @@ export const TOOLS: ToolDef[] = [ return runShell(command, timeout); }, }, + { + name: "unraid_system_job_start", + description: + "CRITICAL: Start one explicitly authorized long-running unrestricted shell command on Unraid as an asynchronous job. Use this instead of unraid_system_shell when work may exceed an HTTP/tool timeout. Returns a job_id immediately; poll only with unraid_system_job_status and reserve calls for verification and cleanup.", + inputSchema: { + type: "object", + properties: { + command: str("Long-running shell command to execute on Unraid via /bin/sh"), + }, + required: ["command"], + additionalProperties: false, + }, + handler: (a) => startShellJob(String(a["command"] ?? "")), + }, + { + name: "unraid_system_job_status", + description: + "Poll one asynchronous Unraid shell job by job_id. Returns running/finished state, exit code and compact stdout/stderr tails. Do not start a duplicate job while this reports running.", + inputSchema: { + type: "object", + properties: { job_id: str("Job identifier returned by unraid_system_job_start") }, + required: ["job_id"], + additionalProperties: false, + }, + handler: (a) => shellJobStatus(String(a["job_id"] ?? "")), + }, + { + name: "unraid_system_job_cleanup", + description: + "CRITICAL: Remove the private MUA metadata/log directory for one completed asynchronous job. This never removes the command's own output files. Refuses cleanup while the job is running.", + inputSchema: { + type: "object", + properties: { job_id: str("Completed job identifier") }, + required: ["job_id"], + additionalProperties: false, + }, + handler: (a) => cleanupShellJob(String(a["job_id"] ?? "")), + }, ]; export function toolByName(name: string): ToolDef | undefined {