240 lines
7.5 KiB
TypeScript
240 lines
7.5 KiB
TypeScript
/**
|
|
* 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 | <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 ─────────────────────────────────────────────────────
|
|
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 <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;
|
|
}
|
|
|
|
export function getMaskedApiKey(): string {
|
|
const key = getApiKey();
|
|
return key.length >= 8 ? `••••••••${key.slice(-8)}` : "gesetzt";
|
|
}
|