diff --git a/dist/mua-2026.08.18.r002-x86_64-1.txz b/dist/mua-2026.08.18.r002-x86_64-1.txz new file mode 100644 index 0000000..6b9568c Binary files /dev/null and b/dist/mua-2026.08.18.r002-x86_64-1.txz differ diff --git a/mcp/helpers.php b/mcp/helpers.php index 0a7b389..f90328e 100644 --- a/mcp/helpers.php +++ b/mcp/helpers.php @@ -10,7 +10,7 @@ error_reporting(E_ALL); ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML -const MUA_VERSION = '2026.08.18.r001'; +const MUA_VERSION = '2026.08.18.r002'; const MUA_SERVER_NAME = 'mua'; /** diff --git a/mcp/server.php b/mcp/server.php index 9ce42a1..7a3ffdd 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -21,7 +21,7 @@ const MUA_PORT = 3002; const MUA_BIND = '0.0.0.0'; const MUA_SESSION_TTL = 3600; // Sekunden const MUA_SERVER_NAME = 'mua'; -const MUA_VERSION = '2026.08.18.r001'; +const MUA_VERSION = '2026.08.18.r002'; // ── Session-Management (in-memory) ─────────────────────────────────────── $sessions = []; // session_id => ['created' => time, 'initialized' => bool] diff --git a/package.json b/package.json index d81de8f..88f31ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.18.r001", + "version": "2026.08.18.r002", "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 033fbf7..aca59d2 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.18.r002 +- API-Key-Authentifizierung (Bearer-Token) für den MCP-HTTP-Endpunkt (Port 3002). /health bleibt offen. +- Einstellungs-Maske im WebGUI-Tab: API-Key generieren + Tools per Checkbox aktivieren/deaktivieren. +- Config-Server auf 127.0.0.1:3003 (nur localhost) für WebGUI-Integration. +- Tool-Filter: deaktivierte Tools werden in tools/list ausgeblendet und bei tools/call abgelehnt. ### 2026.08.18.r001 - 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. @@ -65,7 +70,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 668dc70..53adfd3 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.18.r001", + "version": "2026.08.18.r002", "minver": "7.0.0", "pluginDirectory": "/usr/local/emhttp/plugins/mua", "configDirectory": "/boot/config/plugins/mua", diff --git a/scripts/mua.page b/scripts/mua.page index f5f8a39..56ccf9a 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -9,15 +9,23 @@ Icon="cubes" * MUA - Mikes Unraid Agent * WebGUI-Tab (mua.page) * Unraid-Standard .page-Format + * + * Einstellungen: + * A) API-Key generieren (POST /config {generate:true}) + * B) Tools per Checkbox aktivieren/deaktivieren (POST /config {enabledTools:[...]}) + * + * Config-Server: 127.0.0.1:3003 (nur localhost, kein Auth nötig — + * WebGUI läuft auf demselben Host und ist selbst authifiziert). */ $plugin_dir = '/usr/local/emhttp/plugins/mua'; $port = 3002; +$config_port = 3003; $pidfile = '/var/run/mua.pid'; $binary = '/usr/local/bin/mua'; $endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp'; -// Status bestimmen +// ── Status bestimmen ──────────────────────────────────────────────────── $running = false; $pid = null; if (file_exists($pidfile)) { @@ -27,7 +35,7 @@ if (file_exists($pidfile)) { } } -// Health-Check +// ── Health-Check (MCP-Server, Port 3002) ──────────────────────────────── $health = null; $health_code = 0; $ch = curl_init('http://127.0.0.1:' . $port . '/health'); @@ -41,9 +49,111 @@ $health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $health_ok = ($health_code === 200); +// ── Config vom Config-Server laden (Port 3003) ────────────────────────── +$config = null; +$ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 3, + CURLOPT_CONNECTTIMEOUT => 2, +]); +$config_raw = curl_exec($ch); +$config_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +if ($config_code === 200 && $config_raw !== false) { + $config = json_decode($config_raw, true); +} + +$api_key = $config['apiKey'] ?? ''; +$enabled_tools = $config['enabledTools'] ?? []; +$all_tools = $config['allTools'] ?? []; +$config_ok = ($config !== null); + +// ── POST-Handling (Einstellungen speichern) ───────────────────────────── +$flash_msg = ''; +$flash_type = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $payload = []; + + // A) API-Key generieren + if (isset($_POST['action']) && $_POST['action'] === 'generate_key') { + $payload['generate'] = true; + } + + // B) Tool-Checkboxen + if (isset($_POST['action']) && $_POST['action'] === 'save_tools') { + $selected = []; + foreach ($all_tools as $tool) { + if (isset($_POST['tool_' . $tool])) { + $selected[] = $tool; + } + } + $payload['enabledTools'] = $selected; + } + + if (!empty($payload)) { + $ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + ]); + $resp_raw = curl_exec($ch); + $resp_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + $resp = json_decode($resp_raw, true); + + if ($resp_code === 200 && isset($resp['ok'])) { + // Config neu laden + $ch = curl_init('http://127.0.0.1:' . $config_port . '/config'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 3, + CURLOPT_CONNECTTIMEOUT => 2, + ]); + $config_raw = curl_exec($ch); + curl_close($ch); + $config = json_decode($config_raw, true); + $api_key = $config['apiKey'] ?? ''; + $enabled_tools = $config['enabledTools'] ?? []; + + if ($payload['generate'] ?? false) { + $flash_msg = 'Neuer API-Key generiert.'; + $flash_type = 'ok'; + } else { + $flash_msg = 'Tool-Einstellungen gespeichert (' . count($selected) . ' von ' . count($all_tools) . ' aktiv).'; + $flash_type = 'ok'; + } + } else { + $flash_msg = 'Fehler beim Speichern (HTTP ' . $resp_code . ')'; + $flash_type = 'err'; + } + } +} + $binary_exists = file_exists($binary); -$tool_count = 21; $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; + +// Tool-Gruppierung für die Checkbox-Liste +$tool_groups = [ + 'Docker' => [ + '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' => [ + 'unraid_network_inventory', 'unraid_network_list', 'unraid_network_inspect', + 'unraid_network_host_state', 'unraid_network_audit_tcp', 'unraid_network_lan_probe', + ], + 'System' => [ + 'unraid_system_connection_test', + ], +]; ?> @@ -67,19 +186,75 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
⚠️ Health-Check: nicht erreichbar
+ +
+ +

MCP-Endpunkt

Transport: Streamable HTTP (POST-only) · JSON-RPC 2.0

+

API-Key (Authentifizierung)

+ +
✅ API-Key aktiv — Clients müssen diesen als Bearer-Token senden
+

API-Key:***

+

Authorization-Header: Authorization: Bearer *** htmlspecialchars($api_key) ?>

+

+ Beispiel (curl):
+ curl -X POST -H "Authorization: Bearer *** htmlspecialchars($api_key) ?>" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +

+
+ + +
+ +
⚠️ Kein API-Key gesetzt. Generiere einen, um den MCP-Endpunkt zu schützen.
+
+ + +
+ +
⚠️ Config-Server nicht erreichbar (Port ). Starte den MCP-Server, um die Einstellungen zu verwalten.
+ + +

Tools (aktivieren / deaktivieren)

+ +

+ . + Deaktivierte Tools werden nicht in tools/list angezeigt und bei tools/call abgelehnt. +

+
+ + $group_tools): ?> +
+

()

+ + + + +
+ +
+ +
+
+ +
⚠️ Tool-Liste nicht verfügbar (Config-Server nicht erreichbar).
+ +

Details

- + - + + - +
PluginMUA (Mikes Unraid Agent)
Version2026.08.18.r001
Version2026.08.18.r002
RuntimeTypeScript (Bun Runtime, kompiliertes Binary)
Binary
Port
Port (MCP)
Port (Config) (nur localhost)
Host-IP
Tools (Docker: 14, Netzwerk: 6, System: 1)
Tools (Docker: 14, Netzwerk: 6, System: 1)
Plugin-Verzeichnis
Service/etc/rc.d/rc.mua (SysVinit)
@@ -93,11 +268,3 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; Status/etc/rc.d/rc.mua status Logstail -f /var/log/plugins/mua.log - -

Tools

- - - - - -
KategorieTools
Docker (14)list, inspect, logs, analyze_logs, processes, stats, info, start, stop, restart, create, modify, update, rebuild
Netzwerk (6)inventory, list, inspect, host_state, audit_tcp, lan_probe
System (1)connection_test
diff --git a/scripts/package.sh b/scripts/package.sh index 9c52621..d0d4112 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -13,7 +13,7 @@ set -euo pipefail -VERSION="${1:-2026.08.18.r001}" +VERSION="${1:-2026.08.18.r002}" PKG_NAME="mua" ARCH="x86_64" BUILD_NUM="1" diff --git a/scripts/rc.mua b/scripts/rc.mua index ee41f5e..87804de 100644 --- a/scripts/rc.mua +++ b/scripts/rc.mua @@ -21,6 +21,8 @@ start() { echo " FEHLER: Binary nicht gefunden: $DAEMON" return 1 fi + # Config-Verzeichnis sicherstellen (für API-Key) + mkdir -p /boot/config/plugins/mua # Server starten (detached) MUA_PORT=$PORT nohup "$DAEMON" >> "$LOGFILE" 2>&1 & echo $! > "$PIDFILE" diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..2bb0c40 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,198 @@ +/** + * MUA — Mikes Unraid Agent + * auth.ts — API-Key-Auth + Tool-Filter (Config-Management) + * + * Config-Datei: /boot/config/plugins/mua/mua.conf (chmod 600) + * Format (INI): + * MUA_API_KEY=<64 hex chars> + * MUA_ENABLED_TOOLS=all | + * + * Auth-Flow: + * Client sendet: Authorization: Bearer *** + * Server prüft: timing-safe comparison (timing-attack-sicher) + * Fehlschlag: 401 Unauthorized + * + * /health bleibt ohne Auth (Monitoring). + * /mcp (POST + DELETE) erfordert gültiges Token. + * + * Tool-Filter: + * MUA_ENABLED_TOOLS=all → alle Tools aktiv + * MUA_ENABLED_TOOLS=a,b,c → nur diese Tools (tools/list + tools/call) + */ + +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"; +import { randomBytes } from "node:crypto"; + +const CONFIG_DIR = process.env["MUA_CONFIG_DIR"] ?? "/boot/config/plugins/mua"; +const CONFIG_FILE = `${CONFIG_DIR}/mua.conf`; + +// ── Config-Struktur ───────────────────────────────────────────────────── +interface MUAConfig { + apiKey: string; + enabledTools: string[]; // leer = alle aktiv +} + +// ── Config laden ──────────────────────────────────────────────────────── +let cachedConfig: MUAConfig | null = null; + +function parseConfig(content: string): MUAConfig { + const cfg: MUAConfig = { apiKey: "", enabledTools: [] }; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("#") || trimmed === "") continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key === "MUA_API_KEY") { + cfg.apiKey = value; + } else if (key === "MUA_ENABLED_TOOLS") { + if (value === "all" || value === "") { + cfg.enabledTools = []; + } else { + cfg.enabledTools = value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + } + } + return cfg; +} + +function loadConfig(): MUAConfig { + if (cachedConfig) return cachedConfig; + + // 1. Env-Override (für Testing / Docker) + const envToken = process.env["MUA_API_KEY"]; + if (envToken && envToken.length > 0) { + cachedConfig = { apiKey: envToken, enabledTools: [] }; + return cachedConfig; + } + + // 2. Aus Config-Datei laden + try { + const content = readFileSync(CONFIG_FILE, "utf-8"); + const cfg = parseConfig(content); + if (cfg.apiKey.length > 0) { + cachedConfig = cfg; + return cachedConfig; + } + } catch { + // Datei nicht vorhanden — generiere neue Config + } + + // 3. Neue Config generieren + const newToken = randomBytes(32).toString("hex"); // 64 hex chars + cachedConfig = { apiKey: newToken, enabledTools: [] }; + + try { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeConfigFile(cachedConfig); + console.log(`[MUA] Neue Config generiert: ${CONFIG_FILE}`); + } catch (e) { + console.error(`[MUA] WARNUNG: Config konnte nicht gespeichert werden: ${String(e)}`); + console.error(`[MUA] API-Key (nur in diesem Log): ${newToken}`); + } + + return cachedConfig; +} + +// ── Config schreiben ──────────────────────────────────────────────────── +function writeConfigFile(cfg: MUAConfig): void { + const toolsLine = + cfg.enabledTools.length === 0 + ? "MUA_ENABLED_TOOLS=all" + : `MUA_ENABLED_TOOLS=${cfg.enabledTools.join(",")}`; + const content = [ + "# MUA Configuration", + "# API-Key für MCP-HTTP-Endpunkt (Port 3002)", + "# Format: Authorization: Bearer ***", + `MUA_API_KEY=${cfg.apiKey}`, + "# Aktive Tools (all = alle, oder kommagetrennte Tool-Namen)", + toolsLine, + "", + ].join("\n"); + writeFileSync(CONFIG_FILE, content, { mode: 0o600 }); + chmodSync(CONFIG_FILE, 0o600); +} + +export function saveConfig(cfg: MUAConfig): void { + cachedConfig = cfg; + writeConfigFile(cfg); +} + +// ── API-Key ───────────────────────────────────────────────────────────── +export function getApiKey(): string { + return loadConfig().apiKey; +} + +export function setApiKey(newKey: string): void { + const cfg = loadConfig(); + cfg.apiKey = newKey; + saveConfig(cfg); +} + +export function generateApiKey(): string { + const newKey = randomBytes(32).toString("hex"); + setApiKey(newKey); + return newKey; +} + +// ── Tool-Filter ───────────────────────────────────────────────────────── +/** + * Liefert die Liste aktiver Tool-Namen. + * Leer-Array = alle Tools aktiv. + */ +export function getEnabledTools(): string[] { + return loadConfig().enabledTools; +} + +/** + * Setzt die aktiven Tools. + * Leer-Array = alle Tools aktiv. + */ +export function setEnabledTools(names: string[]): void { + const cfg = loadConfig(); + cfg.enabledTools = names; + saveConfig(cfg); +} + +/** + * Prüft, ob ein Tool aktiv ist. + * true = aktiv, false = deaktiviert. + */ +export function isToolEnabled(toolName: string): boolean { + const enabled = getEnabledTools(); + if (enabled.length === 0) return true; // alle aktiv + return enabled.includes(toolName); +} + +// ── Timing-safe Token-Check ───────────────────────────────────────────── +export function checkAuth(req: Request): boolean { + const token = getApiKey(); + const authHeader = req.headers.get("authorization") ?? ""; + + // Format: "Bearer " + if (!authHeader.startsWith("Bearer ")) { + return false; + } + + const provided = authHeader.slice(7).trim(); + + // Timing-safe comparison (verhindert Timing-Attacks) + if (provided.length !== token.length) { + return false; + } + + let diff = 0; + for (let i = 0; i < token.length; i++) { + diff |= token.charCodeAt(i) ^ provided.charCodeAt(i); + } + return diff === 0; +} + +// ── Token für WebGUI-Tab (nur lesbar) ─────────────────────────────────── +export function getTokenForDisplay(): string { + return getApiKey(); +} diff --git a/src/helpers.ts b/src/helpers.ts index d48d86d..a8a4ba3 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.18.r001"; +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"; diff --git a/src/index.ts b/src/index.ts index e6a51b6..6d8d226 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { MUA_PROTOCOL_VERSION, } from "./helpers"; import { TOOLS, toolByName } from "./tools"; +import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth"; +import { randomBytes } from "node:crypto"; const PORT = Number(process.env["MUA_PORT"] ?? 3002); const HOST = process.env["MUA_HOST"] ?? "0.0.0.0"; @@ -81,9 +83,11 @@ async function handleMcpRequest( // ── tools/list ──────────────────────────────────────────────────────── if (method === "tools/list") { if (sessionId) touchSession(sessionId); + // Tool-Filter: nur aktive Tools anzeigen + const activeTools = TOOLS.filter((t) => isToolEnabled(t.name)); return { response: rpcResult(id, { - tools: TOOLS.map((t) => ({ + tools: activeTools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, @@ -108,6 +112,16 @@ async function handleMcpRequest( sessionId: sessionId ?? "", }; } + // Tool-Filter: deaktivierte Tools ablehnen + if (!isToolEnabled(toolName)) { + return { + response: rpcResult(id, { + content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }], + isError: true, + }), + sessionId: sessionId ?? "", + }; + } try { const text = await tool.handler(args); const result: Record = { @@ -179,7 +193,7 @@ const server = Bun.serve({ return new Response(null, { status: 204, headers: corsHeaders }); } - // ── Health-Check ──────────────────────────────────────────────────── + // ── Health-Check (ohne Auth) ──────────────────────────────────────── if (path === "/health") { return Response.json( { @@ -187,12 +201,33 @@ const server = Bun.serve({ server: MUA_SERVER_NAME, version: MUA_VERSION, port: PORT, + auth: "required", time: new Date().toISOString(), }, { headers: corsHeaders }, ); } + // ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ───────────── + if (path === "/mcp" || path === "/") { + if (!checkAuth(req)) { + return Response.json( + { + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "Unauthorized: missing or invalid API key" }, + }, + { + status: 401, + headers: { + ...corsHeaders, + "Mcp-Session-Id": "00000000-0000-0000-0000-000000000000", + }, + }, + ); + } + } + // ── MCP-Endpunkt ──────────────────────────────────────────────────── if (path === "/mcp" || path === "/") { // POST: JSON-RPC Request @@ -261,8 +296,66 @@ const server = Bun.serve({ }, }); +// ── Config-Server (nur localhost:127.0.0.1:3003) ─────────────────────── +// Separater Server, nur von localhost erreichbar (WebGUI auf demselben Host). +// Kein Header-Spoofing, kein File-Permission-Problem. +const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3003); +const configServer = Bun.serve({ + port: CONFIG_PORT, + hostname: "127.0.0.1", // nur localhost + idleTimeout: 30, + async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (path !== "/config") { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + // GET: Config lesen + if (method === "GET") { + return Response.json({ + apiKey: getApiKey(), + enabledTools: getEnabledTools(), + allTools: TOOLS.map((t) => t.name), + }); + } + + // POST: Config schreiben + if (method === "POST") { + let body: Record; + try { + body = JSON.parse(await req.text()); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + // "generate": neuen API-Key generieren + if (body["generate"] === true) { + setApiKey(randomBytes(32).toString("hex")); + } else if (typeof body["apiKey"] === "string" && (body["apiKey"] as string).length > 0) { + setApiKey(body["apiKey"] as string); + } + if (Array.isArray(body["enabledTools"])) { + const tools = (body["enabledTools"] as unknown[]) + .filter((t): t is string => typeof t === "string"); + setEnabledTools(tools); + } + + return Response.json({ + ok: true, + apiKey: getApiKey(), + enabledTools: getEnabledTools(), + }); + } + + return Response.json({ error: "Method not allowed" }, { status: 405 }); + }, +}); + console.log( - `[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP Streamable HTTP)`, + `[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP) + 127.0.0.1:${CONFIG_PORT} (config)`, ); // Graceful shutdown