release r007 security hardening and redesigned UI
This commit is contained in:
+63
-22
@@ -5,7 +5,7 @@
|
||||
* 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>
|
||||
* MUA_ENABLED_TOOLS=all | none | <comma-separated tool names>
|
||||
*
|
||||
* Auth-Flow:
|
||||
* Client sendet: Authorization: Bearer ***
|
||||
@@ -27,16 +27,39 @@ const CONFIG_DIR = process.env["MUA_CONFIG_DIR"] ?? "/boot/config/plugins/mua";
|
||||
const CONFIG_FILE = `${CONFIG_DIR}/mua.conf`;
|
||||
|
||||
// ── Config-Struktur ─────────────────────────────────────────────────────
|
||||
interface MUAConfig {
|
||||
export interface MUAConfig {
|
||||
apiKey: string;
|
||||
enabledTools: string[]; // leer = alle aktiv
|
||||
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;
|
||||
|
||||
function parseConfig(content: string): MUAConfig {
|
||||
const cfg: MUAConfig = { apiKey: "", enabledTools: [] };
|
||||
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;
|
||||
@@ -47,13 +70,18 @@ function parseConfig(content: string): MUAConfig {
|
||||
if (key === "MUA_API_KEY") {
|
||||
cfg.apiKey = value;
|
||||
} else if (key === "MUA_ENABLED_TOOLS") {
|
||||
if (value === "all" || value === "") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +94,10 @@ function loadConfig(): MUAConfig {
|
||||
// 1. Env-Override (für Testing / Docker)
|
||||
const envToken = process.env["MUA_API_KEY"];
|
||||
if (envToken && envToken.length > 0) {
|
||||
cachedConfig = { apiKey: envToken, enabledTools: [] };
|
||||
const envTools = process.env["MUA_ENABLED_TOOLS"] ?? "";
|
||||
cachedConfig = parseConfig(
|
||||
`MUA_API_KEY=${envToken}\nMUA_ENABLED_TOOLS=${envTools || "none"}\n`,
|
||||
);
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
@@ -84,7 +115,11 @@ function loadConfig(): MUAConfig {
|
||||
|
||||
// 3. Neue Config generieren
|
||||
const newToken = randomBytes(32).toString("hex"); // 64 hex chars
|
||||
cachedConfig = { apiKey: newToken, enabledTools: [] };
|
||||
cachedConfig = {
|
||||
apiKey: newToken,
|
||||
enabledTools: [...SAFE_DEFAULT_TOOLS],
|
||||
allToolsEnabled: false,
|
||||
};
|
||||
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
@@ -92,7 +127,7 @@ function loadConfig(): MUAConfig {
|
||||
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}`);
|
||||
console.error("[MUA] Aus Sicherheitsgründen wird der flüchtige API-Key nicht protokolliert.");
|
||||
}
|
||||
|
||||
return cachedConfig;
|
||||
@@ -100,17 +135,18 @@ function loadConfig(): MUAConfig {
|
||||
|
||||
// ── Config schreiben ────────────────────────────────────────────────────
|
||||
function writeConfigFile(cfg: MUAConfig): void {
|
||||
const toolsLine =
|
||||
cfg.enabledTools.length === 0
|
||||
? "MUA_ENABLED_TOOLS=all"
|
||||
: `MUA_ENABLED_TOOLS=${cfg.enabledTools.join(",")}`;
|
||||
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, oder kommagetrennte Tool-Namen)",
|
||||
toolsLine,
|
||||
"# Aktive Tools (all = alle, none = keine, oder kommagetrennte Tool-Namen)",
|
||||
`MUA_ENABLED_TOOLS=${toolsValue}`,
|
||||
"",
|
||||
].join("\n");
|
||||
writeFileSync(CONFIG_FILE, content, { mode: 0o600 });
|
||||
@@ -148,13 +184,18 @@ export function getEnabledTools(): string[] {
|
||||
return loadConfig().enabledTools;
|
||||
}
|
||||
|
||||
export function getAllToolsEnabled(): boolean {
|
||||
return loadConfig().allToolsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die aktiven Tools.
|
||||
* Leer-Array = alle Tools aktiv.
|
||||
* Ein leeres Array bedeutet ausdrücklich: keine Tools aktiv.
|
||||
*/
|
||||
export function setEnabledTools(names: string[]): void {
|
||||
export function setEnabledTools(names: string[], allToolsEnabled = false): void {
|
||||
const cfg = loadConfig();
|
||||
cfg.enabledTools = names;
|
||||
cfg.enabledTools = [...new Set(names)];
|
||||
cfg.allToolsEnabled = allToolsEnabled;
|
||||
saveConfig(cfg);
|
||||
}
|
||||
|
||||
@@ -164,7 +205,7 @@ export function setEnabledTools(names: string[]): void {
|
||||
*/
|
||||
export function isToolEnabled(toolName: string): boolean {
|
||||
const enabled = getEnabledTools();
|
||||
if (enabled.length === 0) return true; // alle aktiv
|
||||
if (getAllToolsEnabled()) return true;
|
||||
return enabled.includes(toolName);
|
||||
}
|
||||
|
||||
@@ -192,7 +233,7 @@ export function checkAuth(req: Request): boolean {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ── Token für WebGUI-Tab (nur lesbar) ───────────────────────────────────
|
||||
export function getTokenForDisplay(): string {
|
||||
return getApiKey();
|
||||
export function getMaskedApiKey(): string {
|
||||
const key = getApiKey();
|
||||
return key.length >= 8 ? `••••••••${key.slice(-8)}` : "gesetzt";
|
||||
}
|
||||
|
||||
+17
-2
@@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net";
|
||||
|
||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||
export const MUA_SERVER_NAME = "mua";
|
||||
export const MUA_VERSION = "2026.08.18.r006";
|
||||
export const MUA_VERSION = "2026.08.21.r007";
|
||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||
|
||||
@@ -224,6 +224,17 @@ export function sanitizeLogOutput(text: string, maxChars = 50000): string {
|
||||
let result = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
||||
// Entferne andere Control-Chars (außer \n, \r, \t)
|
||||
result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
||||
// Häufige Secret-Formate redigieren. Das ist bewusst nur eine zusätzliche
|
||||
// Schutzschicht; Container-Logs können weiterhin sensible Nutzdaten
|
||||
// enthalten und sollten nur gezielt sowie mit kleinem `tail` gelesen werden.
|
||||
result = result.replace(
|
||||
/((?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\s*[=:]\s*)([^\s,;]+)/gi,
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
result = result.replace(
|
||||
/(\"(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\"\s*:\s*\")[^\"]*(\")/gi,
|
||||
"$1[REDACTED]$2",
|
||||
);
|
||||
// Begrenze Länge
|
||||
if (result.length > maxChars) {
|
||||
result = "... [truncated] ..." + result.slice(-maxChars);
|
||||
@@ -254,7 +265,11 @@ export async function compactContainerInspect(container: string): Promise<string
|
||||
Image: d.Config?.Image ?? "",
|
||||
NetworkMode: d.HostConfig?.NetworkMode ?? "",
|
||||
Ports: d.NetworkSettings?.Ports ?? [],
|
||||
Env: d.Config?.Env ?? [],
|
||||
// Environment-Werte enthalten sehr häufig API-Keys, Passwörter und
|
||||
// interne URLs. Für Diagnosezwecke reichen die vorhandenen Variablennamen.
|
||||
EnvNames: (d.Config?.Env ?? []).map((entry: unknown) =>
|
||||
typeof entry === "string" ? entry.split("=", 1)[0] : "",
|
||||
).filter((name: string) => name !== ""),
|
||||
Mounts: (d.Mounts ?? []).map((m: any) => ({
|
||||
Type: m.Type ?? "",
|
||||
Source: m.Source ?? "",
|
||||
|
||||
+128
-18
@@ -18,14 +18,27 @@ import {
|
||||
MUA_VERSION,
|
||||
MUA_PROTOCOL_VERSION,
|
||||
} from "./helpers";
|
||||
import { TOOLS, toolByName } from "./tools";
|
||||
import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth";
|
||||
import { TOOLS, toolByName, getToolRisk } from "./tools";
|
||||
import {
|
||||
checkAuth,
|
||||
isToolEnabled,
|
||||
getEnabledTools,
|
||||
getAllToolsEnabled,
|
||||
getMaskedApiKey,
|
||||
generateApiKey,
|
||||
setEnabledTools,
|
||||
} from "./auth";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { statSync } from "node:fs";
|
||||
import { appendFile, chmodSync, statSync, writeFileSync } from "node:fs";
|
||||
|
||||
const PORT = Number(process.env["MUA_PORT"] ?? 3002);
|
||||
const HOST = process.env["MUA_HOST"] ?? "0.0.0.0";
|
||||
const MAX_REQUEST_BYTES = Number(process.env["MUA_MAX_REQUEST_BYTES"] ?? 1_048_576);
|
||||
const RATE_LIMIT_PER_MINUTE = Number(process.env["MUA_RATE_LIMIT_PER_MINUTE"] ?? 120);
|
||||
const MAX_CONCURRENT_TOOL_CALLS = Number(process.env["MUA_MAX_CONCURRENT_TOOL_CALLS"] ?? 4);
|
||||
const CORS_ORIGIN = process.env["MUA_CORS_ORIGIN"] ?? "";
|
||||
const AUDIT_LOG = process.env["MUA_AUDIT_LOG"] ?? "/var/log/plugins/mua-audit.log";
|
||||
|
||||
// ── JSON-RPC Helpers ────────────────────────────────────────────────────
|
||||
function rpcResult(id: number | string | null, result: unknown) {
|
||||
@@ -37,6 +50,31 @@ function rpcError(id: number | string | null, code: number, message: string) {
|
||||
|
||||
// ── Session Management ──────────────────────────────────────────────────
|
||||
const sessions = new Map<string, { createdAt: number; lastActivity: number }>();
|
||||
const rateLimits = new Map<string, { startedAt: number; count: number }>();
|
||||
let activeToolCalls = 0;
|
||||
|
||||
function allowRequest(client: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimits.get(client);
|
||||
if (!entry || now - entry.startedAt >= 60_000) {
|
||||
rateLimits.set(client, { startedAt: now, count: 1 });
|
||||
return true;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count <= RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
function auditTool(name: string, ok: boolean, durationMs: number): void {
|
||||
const line = JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
event: "tool_call",
|
||||
tool: name,
|
||||
risk: getToolRisk(name),
|
||||
ok,
|
||||
duration_ms: durationMs,
|
||||
}) + "\n";
|
||||
appendFile(AUDIT_LOG, line, { mode: 0o600 }, () => {});
|
||||
}
|
||||
|
||||
function newSessionId(): string {
|
||||
return crypto.randomUUID();
|
||||
@@ -124,8 +162,20 @@ async function handleMcpRequest(
|
||||
sessionId: sessionId ?? "",
|
||||
};
|
||||
}
|
||||
if (activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
|
||||
return {
|
||||
response: rpcResult(id, {
|
||||
content: [{ type: "text", text: "ERROR: Server busy; retry later" }],
|
||||
isError: true,
|
||||
}),
|
||||
sessionId: sessionId ?? "",
|
||||
};
|
||||
}
|
||||
activeToolCalls += 1;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const text = await tool.handler(args);
|
||||
auditTool(toolName, true, Date.now() - startedAt);
|
||||
const result: Record<string, unknown> = {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
@@ -146,6 +196,7 @@ async function handleMcpRequest(
|
||||
}
|
||||
return { response: rpcResult(id, result), sessionId: sessionId ?? "" };
|
||||
} catch (e) {
|
||||
auditTool(toolName, false, Date.now() - startedAt);
|
||||
return {
|
||||
response: rpcResult(id, {
|
||||
content: [{ type: "text", text: `ERROR: ${String(e)}` }],
|
||||
@@ -153,6 +204,8 @@ async function handleMcpRequest(
|
||||
}),
|
||||
sessionId: sessionId ?? "",
|
||||
};
|
||||
} finally {
|
||||
activeToolCalls -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,15 +236,21 @@ const server = Bun.serve({
|
||||
const method = req.method;
|
||||
const sessionHeader = req.headers.get("mcp-session-id");
|
||||
|
||||
// CORS für lokale Nutzung
|
||||
const corsHeaders: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
// Browserzugriffe sind standardmäßig deaktiviert. Bei Bedarf kann genau
|
||||
// ein vertrauenswürdiger Origin per MUA_CORS_ORIGIN freigegeben werden.
|
||||
const origin = req.headers.get("origin") ?? "";
|
||||
const corsHeaders: Record<string, string> = CORS_ORIGIN && origin === CORS_ORIGIN ? {
|
||||
"Access-Control-Allow-Origin": CORS_ORIGIN,
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Mcp-Session-Id",
|
||||
};
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id",
|
||||
"Vary": "Origin",
|
||||
} : {};
|
||||
|
||||
// ── OPTIONS (CORS Preflight) ────────────────────────────────────────
|
||||
if (method === "OPTIONS") {
|
||||
if (!CORS_ORIGIN || origin !== CORS_ORIGIN) {
|
||||
return Response.json({ error: "CORS origin not allowed" }, { status: 403 });
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
@@ -212,6 +271,13 @@ const server = Bun.serve({
|
||||
|
||||
// ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ─────────────
|
||||
if (path === "/mcp" || path === "/") {
|
||||
const client = server.requestIP(req)?.address ?? "unknown";
|
||||
if (!allowRequest(client)) {
|
||||
return Response.json(
|
||||
rpcError(null, -32002, "Rate limit exceeded"),
|
||||
{ status: 429, headers: { ...corsHeaders, "Retry-After": "60" } },
|
||||
);
|
||||
}
|
||||
if (!checkAuth(req)) {
|
||||
return Response.json(
|
||||
{
|
||||
@@ -234,6 +300,13 @@ const server = Bun.serve({
|
||||
if (path === "/mcp" || path === "/") {
|
||||
// POST: JSON-RPC Request
|
||||
if (method === "POST") {
|
||||
const contentLength = Number(req.headers.get("content-length") ?? 0);
|
||||
if (contentLength > MAX_REQUEST_BYTES) {
|
||||
return Response.json(rpcError(null, -32600, "Request too large"), {
|
||||
status: 413,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
let body: string;
|
||||
try {
|
||||
body = await req.text();
|
||||
@@ -243,6 +316,12 @@ const server = Bun.serve({
|
||||
{ status: 400, headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
|
||||
return Response.json(rpcError(null, -32600, "Request too large"), {
|
||||
status: 413,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
@@ -303,6 +382,14 @@ const server = Bun.serve({
|
||||
// Kein Header-Spoofing, kein File-Permission-Problem.
|
||||
// Wenn der Port belegt ist: Warnung + weiterlaufen (MCP-Server bleibt up).
|
||||
const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3013);
|
||||
const ADMIN_TOKEN_FILE = process.env["MUA_ADMIN_TOKEN_FILE"] ?? "/var/run/mua-admin.token";
|
||||
const adminToken = randomBytes(32).toString("hex");
|
||||
try {
|
||||
writeFileSync(ADMIN_TOKEN_FILE, adminToken + "\n", { mode: 0o600 });
|
||||
chmodSync(ADMIN_TOKEN_FILE, 0o600);
|
||||
} catch (e) {
|
||||
console.error(`[MUA] Config-Token konnte nicht geschrieben werden: ${String(e)}`);
|
||||
}
|
||||
let configServer: ReturnType<typeof Bun.serve> | null = null;
|
||||
try {
|
||||
configServer = Bun.serve({
|
||||
@@ -318,6 +405,10 @@ try {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (req.headers.get("x-mua-admin-token") !== adminToken) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// POST /restart: Service neu starten (für WebGUI-Button)
|
||||
if (path === "/restart" && method === "POST") {
|
||||
const rcScript = process.env["MUA_RC_SCRIPT"] ?? "/etc/rc.d/rc.mua";
|
||||
@@ -336,9 +427,15 @@ try {
|
||||
// GET: Config lesen
|
||||
if (method === "GET") {
|
||||
return Response.json({
|
||||
apiKey: getApiKey(),
|
||||
apiKeyConfigured: true,
|
||||
apiKeyMasked: getMaskedApiKey(),
|
||||
enabledTools: getEnabledTools(),
|
||||
allTools: TOOLS.map((t) => t.name),
|
||||
allToolsEnabled: getAllToolsEnabled(),
|
||||
allTools: TOOLS.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
risk: getToolRisk(t.name),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,21 +449,21 @@ try {
|
||||
}
|
||||
|
||||
// "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);
|
||||
}
|
||||
let generatedApiKey: string | undefined;
|
||||
if (body["generate"] === true) generatedApiKey = generateApiKey();
|
||||
if (Array.isArray(body["enabledTools"])) {
|
||||
const known = new Set(TOOLS.map((t) => t.name));
|
||||
const tools = (body["enabledTools"] as unknown[])
|
||||
.filter((t): t is string => typeof t === "string");
|
||||
setEnabledTools(tools);
|
||||
.filter((t): t is string => typeof t === "string" && known.has(t));
|
||||
setEnabledTools(tools, body["allToolsEnabled"] === true);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
apiKey: getApiKey(),
|
||||
generatedApiKey,
|
||||
apiKeyMasked: getMaskedApiKey(),
|
||||
enabledTools: getEnabledTools(),
|
||||
allToolsEnabled: getAllToolsEnabled(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,6 +525,19 @@ const updateCheckInterval = setInterval(
|
||||
);
|
||||
updateCheckInterval.unref();
|
||||
|
||||
// Abgelaufene Sessions und Rate-Limit-Einträge entfernen, damit lange
|
||||
// Laufzeiten nicht durch beliebig viele Client-Adressen Speicher ansammeln.
|
||||
const housekeepingInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.lastActivity > 60 * 60 * 1000) sessions.delete(id);
|
||||
}
|
||||
for (const [client, entry] of rateLimits) {
|
||||
if (now - entry.startedAt > 2 * 60 * 1000) rateLimits.delete(client);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
housekeepingInterval.unref();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[MUA] SIGTERM received, shutting down");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
||||
import { sanitizeLogOutput } from "./helpers";
|
||||
import { getToolRisk } from "./tools";
|
||||
|
||||
describe("secure tool configuration", () => {
|
||||
test("new and incomplete configs use the read-only baseline", () => {
|
||||
const cfg = parseConfig("MUA_API_KEY=test\n");
|
||||
expect(cfg.allToolsEnabled).toBe(false);
|
||||
expect(cfg.enabledTools).toEqual(SAFE_DEFAULT_TOOLS);
|
||||
expect(cfg.enabledTools).not.toContain("unraid_system_shell");
|
||||
});
|
||||
|
||||
test("none means no tools instead of all tools", () => {
|
||||
const cfg = parseConfig("MUA_API_KEY=test\nMUA_ENABLED_TOOLS=none\n");
|
||||
expect(cfg.allToolsEnabled).toBe(false);
|
||||
expect(cfg.enabledTools).toEqual([]);
|
||||
});
|
||||
|
||||
test("legacy all remains backwards compatible and explicit", () => {
|
||||
const cfg = parseConfig("MUA_API_KEY=test\nMUA_ENABLED_TOOLS=all\n");
|
||||
expect(cfg.allToolsEnabled).toBe(true);
|
||||
expect(cfg.enabledTools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret handling", () => {
|
||||
test("redacts common key-value and JSON secrets", () => {
|
||||
const output = sanitizeLogOutput(
|
||||
'API_KEY=very-secret password:also-secret {"token":"third-secret"}',
|
||||
);
|
||||
expect(output).not.toContain("very-secret");
|
||||
expect(output).not.toContain("also-secret");
|
||||
expect(output).not.toContain("third-secret");
|
||||
expect(output).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
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_restart")).toBe("write");
|
||||
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
||||
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
||||
});
|
||||
});
|
||||
+28
-2
@@ -29,6 +29,32 @@ export interface ToolDef {
|
||||
handler: (args: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
export type ToolRisk = "read" | "active" | "write" | "critical";
|
||||
|
||||
const CRITICAL_TOOLS = new Set([
|
||||
"unraid_docker_create",
|
||||
"unraid_docker_modify",
|
||||
"unraid_docker_update",
|
||||
"unraid_docker_rebuild",
|
||||
"unraid_system_shell",
|
||||
]);
|
||||
const WRITE_TOOLS = new Set([
|
||||
"unraid_docker_start",
|
||||
"unraid_docker_stop",
|
||||
"unraid_docker_restart",
|
||||
]);
|
||||
const ACTIVE_TOOLS = new Set([
|
||||
"unraid_network_audit_tcp",
|
||||
"unraid_network_lan_probe",
|
||||
]);
|
||||
|
||||
export function getToolRisk(name: string): ToolRisk {
|
||||
if (CRITICAL_TOOLS.has(name)) return "critical";
|
||||
if (WRITE_TOOLS.has(name)) return "write";
|
||||
if (ACTIVE_TOOLS.has(name)) return "active";
|
||||
return "read";
|
||||
}
|
||||
|
||||
const str = (desc: string) => ({ type: "string", description: desc });
|
||||
const int = (desc: string) => ({ type: "integer", description: desc });
|
||||
const num = (desc: string) => ({ type: "number", description: desc });
|
||||
@@ -47,7 +73,7 @@ export const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "unraid_docker_inspect",
|
||||
description:
|
||||
"Inspect a single Docker container in detail (state, image, ports, env, mounts).",
|
||||
"Inspect a single Docker container in detail (state, image, ports, redacted environment variable names, mounts). Secret values are never returned.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
@@ -306,7 +332,7 @@ export const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "unraid_system_shell",
|
||||
description:
|
||||
"Execute a shell command on the Unraid host (as root, via /bin/sh -c) and return exit code, stdout, and stderr. Use for direct terminal access: file inspection, system commands, package info, log reading, etc. Commands run with a timeout and output is size-limited.",
|
||||
"CRITICAL: Execute an unrestricted shell command on the Unraid host as root. Keep this tool disabled unless explicitly needed for a supervised maintenance session.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
Reference in New Issue
Block a user