r002: API-Key-Auth + Einstellungs-Maske (API-Key generieren, Tool-Checkboxen)
- src/auth.ts: Config-Management (API-Key + Tool-Filter), timing-safe Auth - src/index.ts: Auth-Check auf /mcp, Tool-Filter in tools/list + tools/call, Config-Server auf 127.0.0.1:3003 (nur localhost) - scripts/mua.page: Einstellungs-UI (API-Key generieren + Tool-Checkboxen) - scripts/rc.mua: Config-Dir sicherstellen - Version: 2026.08.18.r002
This commit is contained in:
+198
@@ -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 | <comma-separated tool names>
|
||||
*
|
||||
* 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 <token>"
|
||||
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();
|
||||
}
|
||||
+1
-1
@@ -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";
|
||||
|
||||
|
||||
+96
-3
@@ -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<string, unknown> = {
|
||||
@@ -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<string, unknown>;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user