/** * 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 | none | * * 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 ───────────────────────────────────────────────────── export interface MUAConfig { apiKey: string; enabledTools: string[]; allToolsEnabled: boolean; } // Sichere Grundeinstellung für Neuinstallationen. Schreibzugriffe, aktive // Netzwerkscans und die Root-Shell müssen ausdrücklich in der GUI aktiviert // werden. export const SAFE_DEFAULT_TOOLS = [ "unraid_docker_list", "unraid_docker_inspect", "unraid_docker_logs", "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", ]; // ── Config laden ──────────────────────────────────────────────────────── let cachedConfig: MUAConfig | null = null; export function parseConfig(content: string): MUAConfig { const cfg: MUAConfig = { apiKey: "", enabledTools: [...SAFE_DEFAULT_TOOLS], allToolsEnabled: false, }; 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") { cfg.enabledTools = []; cfg.allToolsEnabled = true; } else if (value === "none" || value === "") { cfg.enabledTools = []; cfg.allToolsEnabled = false; } else { cfg.enabledTools = value .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); cfg.allToolsEnabled = false; } } } 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) { const envTools = process.env["MUA_ENABLED_TOOLS"] ?? ""; cachedConfig = parseConfig( `MUA_API_KEY=${envToken}\nMUA_ENABLED_TOOLS=${envTools || "none"}\n`, ); 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: [...SAFE_DEFAULT_TOOLS], allToolsEnabled: false, }; 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] Aus Sicherheitsgründen wird der flüchtige API-Key nicht protokolliert."); } return cachedConfig; } // ── Config schreiben ──────────────────────────────────────────────────── function writeConfigFile(cfg: MUAConfig): void { const toolsValue = cfg.allToolsEnabled ? "all" : cfg.enabledTools.length === 0 ? "none" : 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, none = keine, oder kommagetrennte Tool-Namen)", `MUA_ENABLED_TOOLS=${toolsValue}`, "", ].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; } export function getAllToolsEnabled(): boolean { return loadConfig().allToolsEnabled; } /** * Setzt die aktiven Tools. * Ein leeres Array bedeutet ausdrücklich: keine Tools aktiv. */ export function setEnabledTools(names: string[], allToolsEnabled = false): void { const cfg = loadConfig(); cfg.enabledTools = [...new Set(names)]; cfg.allToolsEnabled = allToolsEnabled; saveConfig(cfg); } /** * Prüft, ob ein Tool aktiv ist. * true = aktiv, false = deaktiviert. */ export function isToolEnabled(toolName: string): boolean { const enabled = getEnabledTools(); if (getAllToolsEnabled()) return true; 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; } export function getMaskedApiKey(): string { const key = getApiKey(); return key.length >= 8 ? `••••••••${key.slice(-8)}` : "gesetzt"; }