diff --git a/README.md b/README.md index ebaaeb0..231f8b0 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.r018-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.r019-x86_64-1.txz # Installieren -upgradepkg --install-new mua-2026.08.24.r018-x86_64-1.txz +upgradepkg --install-new mua-2026.08.24.r019-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.r018","auth":"required"} +# → {"status":"ok","version":"2026.08.24.r019","auth":"required"} ``` --- @@ -212,7 +212,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden: | Gruppe | Tools | |--------|-------| -| **Docker (15)** | `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_rebuild` | +| **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 (9)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | diff --git a/dist/mua-2026.08.24.r019-x86_64-1.txz b/dist/mua-2026.08.24.r019-x86_64-1.txz new file mode 100644 index 0000000..345b3a6 Binary files /dev/null and b/dist/mua-2026.08.24.r019-x86_64-1.txz differ diff --git a/package.json b/package.json index a419878..2056664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.24.r018", + "version": "2026.08.24.r019", "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 ac3f47d..5a14bbf 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.24.r019 +- Ein gebündelter Docker-Updateablauf prüft echte Image-IDs, überspringt bereits aktuelle Images trotz veraltetem Unraid-Cache und erhält den ursprünglichen Laufzustand. +- Bis zu 25 ausdrücklich benannte Container werden in einem Werkzeugaufruf aktualisiert und anschließend kompakt auf Container-ID, Zustand, RestartCount und Health geprüft. +- Eine bestehende Freigabe des bisherigen Einzel-Update-Werkzeugs schaltet automatisch dessen sicherere Batch-Variante frei. + ### 2026.08.24.r018 - Added focused, batched container-log analysis via `focus_terms` to avoid repeated shell/grep loops. - Repaired the repository self-test command. @@ -126,7 +131,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/plugin/plugin.json b/plugin/plugin.json index a4ebc5e..c426f57 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.24.r018", + "version": "2026.08.24.r019", "minver": "7.0.0", "pluginDirectory": "/usr/local/emhttp/plugins/mua", "configDirectory": "/boot/config/plugins/mua", diff --git a/scripts/package.sh b/scripts/package.sh index 6d2a64e..fc30b05 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. -|33 Tools: Docker, Community Applications, Netzwerk und Unraid-System. +|34 Tools: Docker, Community Applications, Netzwerk und Unraid-System. |Port: 3002, Endpunkt: /mcp | |Runtime: TypeScript (Bun Runtime, kompiliertes Binary) diff --git a/scripts/unraid-docker-mcp-helper.php b/scripts/unraid-docker-mcp-helper.php index 98aea1d..4aa3f0a 100644 --- a/scripts/unraid-docker-mcp-helper.php +++ b/scripts/unraid-docker-mcp-helper.php @@ -8,6 +8,7 @@ * create * modify * update + * update-verified-batch * rebuild * ca-install * @@ -116,6 +117,109 @@ function rebuild_container(string $name, bool $pull_image = false, bool $force_s out("Done: $Name"); } +function docker_inspect_value(string $format, string $name): string { + return docker_exec('inspect --format ' . escapeshellarg($format) . ' ' . escapeshellarg($name)); +} + +/** + * Pull and update several exact containers without trusting Unraid's cached + * update flag. The immutable image ID is the authority. A stale cache can + * therefore never cause a needless container recreation. + */ +function update_verified_batch(string $raw_names): void { + $names = array_values(array_unique(array_filter(array_map('trim', explode('|', $raw_names))))); + if (count($names) < 1 || count($names) > 25) { + fail('Expected 1-25 container names separated by |'); + } + + $results = []; + foreach ($names as $name) { + if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $name)) { + $results[] = ['container' => $name, 'result' => 'error', 'error' => 'invalid container name']; + continue; + } + + try { + $tmpl = get_template_path($name); + [, $template_name, $repository] = xmlToCommand($tmpl); + if ($template_name !== $name) { + throw new RuntimeException("Template name mismatch: $template_name"); + } + + $before_id = docker_inspect_value('{{.Id}}', $name); + $before_image_id = docker_inspect_value('{{.Image}}', $name); + $before_state = docker_inspect_value('{{.State.Status}}', $name); + $was_running = docker_inspect_value('{{.State.Running}}', $name) === 'true'; + + $pull_output = docker_exec('pull ' . escapeshellarg($repository)); + if (preg_match('/(?:^|\n)(?:Error response|error:|failed)/i', $pull_output)) { + throw new RuntimeException('Image pull failed: ' . substr($pull_output, 0, 500)); + } + $pulled_image_id = docker_exec('image inspect --format ' . escapeshellarg('{{.Id}}') . ' ' . escapeshellarg($repository)); + if ($pulled_image_id === '') { + throw new RuntimeException('Pulled image ID could not be resolved'); + } + + if ($before_image_id === $pulled_image_id) { + $results[] = [ + 'container' => $name, + 'result' => 'already-current', + 'recreated' => false, + 'original_state' => $before_state, + 'final_state' => $before_state, + 'container_id_changed' => false, + 'image_id_changed' => false, + ]; + continue; + } + + ob_start(); + rebuild_container($name, false, false); + ob_end_clean(); + + $after_id = docker_inspect_value('{{.Id}}', $name); + $after_image_id = docker_inspect_value('{{.Image}}', $name); + $after_state = docker_inspect_value('{{.State.Status}}', $name); + $restart_count = (int)docker_inspect_value('{{.RestartCount}}', $name); + $health = docker_inspect_value('{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', $name); + $state_preserved = $was_running ? $after_state === 'running' : $after_state !== 'running'; + + $results[] = [ + 'container' => $name, + 'result' => ($after_image_id === $pulled_image_id && $state_preserved) ? 'updated' : 'verification-failed', + 'recreated' => true, + 'original_state' => $before_state, + 'final_state' => $after_state, + 'state_preserved' => $state_preserved, + 'container_id_changed' => $before_id !== $after_id, + 'image_id_changed' => $before_image_id !== $after_image_id, + 'restart_count' => $restart_count, + 'health' => $health, + ]; + } catch (Throwable $error) { + if (ob_get_level() > 0) ob_end_clean(); + $results[] = [ + 'container' => $name, + 'result' => 'error', + 'error' => substr($error->getMessage(), 0, 600), + ]; + } + } + + $counts = []; + foreach ($results as $result) { + $key = (string)$result['result']; + $counts[$key] = ($counts[$key] ?? 0) + 1; + } + echo json_encode([ + 'schema_version' => '1.0', + 'requested_count' => count($names), + 'result_counts' => $counts, + 'all_verified' => !isset($counts['error']) && !isset($counts['verification-failed']), + 'containers' => $results, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; +} + /** * Set the text content of a DOM element. * This is the actual value Unraid uses (not the Default attribute). @@ -354,6 +458,11 @@ switch ($action) { rebuild_container($arg1, true); break; + case 'update-verified-batch': + if (!$arg1) fail('Missing container names'); + update_verified_batch($arg1); + break; + case 'rebuild': if (!$arg1) fail("Missing container name"); out("Rebuilding container: $arg1"); @@ -366,5 +475,5 @@ switch ($action) { break; default: - fail("Unknown action: $action. Usage: create|modify|update|rebuild|ca-install"); + fail("Unknown action: $action. Usage: create|modify|update|update-verified-batch|rebuild|ca-install"); } diff --git a/src/auth.ts b/src/auth.ts index 4e1a9f9..457bca4 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -93,6 +93,17 @@ export function parseConfig(content: string): MUAConfig { } } } + // r019 replaces repeated single-container update calls with an idempotent + // batch variant. If an administrator already enabled the older update + // capability, expose the safer equivalent automatically; this does not + // grant a new class of operation. + if ( + !cfg.allToolsEnabled + && cfg.enabledTools.includes("unraid_docker_update") + && !cfg.enabledTools.includes("unraid_docker_update_verified_batch") + ) { + cfg.enabledTools.push("unraid_docker_update_verified_batch"); + } return cfg; } diff --git a/src/helpers.ts b/src/helpers.ts index 75076aa..96e4df0 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -14,7 +14,7 @@ import { createHash, randomUUID } from "node:crypto"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.24.r018"; +export const MUA_VERSION = "2026.08.24.r019"; 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"; diff --git a/src/security.test.ts b/src/security.test.ts index b70f48b..66b8dea 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -22,6 +22,14 @@ describe("secure tool configuration", () => { expect(cfg.allToolsEnabled).toBe(true); expect(cfg.enabledTools).toEqual([]); }); + + test("an enabled legacy update also exposes the safer batch update", () => { + const cfg = parseConfig( + "MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_docker_update\n", + ); + expect(cfg.enabledTools).toContain("unraid_docker_update"); + expect(cfg.enabledTools).toContain("unraid_docker_update_verified_batch"); + }); }); describe("secret handling", () => { @@ -40,6 +48,7 @@ describe("risk classification", () => { test("classifies root shell and container changes as critical", () => { expect(getToolRisk("unraid_system_shell")).toBe("critical"); expect(getToolRisk("unraid_docker_modify")).toBe("critical"); + expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical"); expect(getToolRisk("unraid_docker_restart")).toBe("write"); expect(getToolRisk("unraid_network_lan_probe")).toBe("active"); expect(getToolRisk("unraid_docker_list")).toBe("read"); diff --git a/src/tools.ts b/src/tools.ts index 5432b7a..e709acb 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -40,6 +40,7 @@ const CRITICAL_TOOLS = new Set([ "unraid_docker_create", "unraid_docker_modify", "unraid_docker_update", + "unraid_docker_update_verified_batch", "unraid_docker_rebuild", "unraid_system_shell", "unraid_ca_install", @@ -281,7 +282,8 @@ export const TOOLS: ToolDef[] = [ }, { name: "unraid_docker_update", - description: "Update a container (pull latest image, rebuild).", + description: + "Update one explicitly named container (pull latest image and rebuild). For two or more containers, or when update status may be stale, use unraid_docker_update_verified_batch instead.", inputSchema: { type: "object", properties: { container: str("Container/template name") }, @@ -289,6 +291,30 @@ export const TOOLS: ToolDef[] = [ }, handler: (a) => runPhpHelper("update", validateName(a["container"], "container")), }, + { + name: "unraid_docker_update_verified_batch", + description: + "Authoritative one-call Docker image update workflow for 1-25 explicitly named Unraid containers. Pulls each configured image, compares immutable image IDs, skips already-current containers even if Unraid's cached status is stale, rebuilds only when the image actually changed, preserves every original running/stopped state, and returns compact post-verification (container ID change, state, restart count and health). Use this after read-only update discovery when the user explicitly requested the updates.", + inputSchema: { + type: "object", + properties: { + containers: str( + "One to 25 exact container names separated by |, as returned by Unraid inventory/update status", + ), + }, + required: ["containers"], + additionalProperties: false, + }, + handler: (a) => { + const raw = String(a["containers"] ?? ""); + const containers = raw.split("|").map((name) => name.trim()).filter(Boolean); + if (containers.length < 1 || containers.length > 25) { + throw new Error("containers must contain 1-25 names separated by |"); + } + const unique = [...new Set(containers.map((name) => validateName(name, "container")))]; + return runPhpHelper("update-verified-batch", unique.join("|")); + }, + }, { name: "unraid_docker_rebuild", description: