release r007 security hardening and redesigned UI

This commit is contained in:
Mikei386 committed 2026-08-21 07:43:39 +02:00
1 parent 514310912c
commit 65922b7f57
12 files changed
+485 -346

No files matched your search

+40 -22
View File
@@ -5,9 +5,9 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid.
- **MCP-Endpunkt:** `http://<unraid-ip>:3002/mcp` (Streamable HTTP, POST-only, JSON-RPC 2.0)
- **Health-Check:** `http://<unraid-ip>:3002/health` (offen, ohne Auth)
- **Config-Server:** `http://127.0.0.1:3013/config` (nur localhost, für WebGUI)
- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token)
- **Auth:** API-Key (Bearer-Token) für `/mcp`
- **Tools:** 21 (Docker: 14, Netzwerk: 6, System: 1)
- **Tools:** 22 (Docker: 14, Netzwerk: 6, System: 2)
---
@@ -30,14 +30,30 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid.
│ │
┌────────┴─────────┐ ┌────────┴─────────┐
│ MCP-Client │ │ Unraid WebGUI │
│ (Hermes, etc.) │ │ Settings → MUA │
│ (Hermes, etc.) │ │ User Utilities → MUA │
└──────────────────┘ └──────────────────┘
```
**Wichtig:** Der MCP-Endpunkt (Port 3002) ist **extern** erreichbar und braucht
einen Bearer-Token. Der Config-Server (Port 3013) läuft **nur auf localhost**
und wird ausschließlich von der WebGUI-Page (`mua.page`) über PHP/curl
angesprochen — er ist von außen nicht erreichbar.
und verlangt zusätzlich einen bei jedem Start neu erzeugten Admin-Token.
Die WebGUI schützt schreibende Formulare außerdem mit einem CSRF-Token.
## Sicherheitsmodell
- Neuinstallationen starten im Profil **Nur Lesen**. Container-Steuerung,
aktive Netzwerktests, Container-Umbauten und die Root-Shell sind aus.
- `none` bedeutet tatsächlich **keine Tools aktiv**; `all` ist ein expliziter
Vollzugriff und wird in der GUI deutlich gewarnt.
- Container-Umgebungswerte werden nie ausgegeben, nur ihre Variablennamen.
- Häufige Secret-Formate in Logs werden zusätzlich redigiert. Logs können
trotzdem Nutzdaten enthalten und sollten gezielt abgefragt werden.
- Der API-Key erscheint nur unmittelbar nach seiner Erzeugung vollständig.
- Tool-Aufrufe werden ohne Argumente oder Ausgaben in
`/var/log/plugins/mua-audit.log` protokolliert.
- CORS ist standardmäßig aus. Requests sind auf 1 MiB, 120 pro Minute je
Client und vier gleichzeitig laufende Werkzeuge begrenzt. Diese Grenzen
lassen sich per Umgebungsvariable anpassen.
---
@@ -52,10 +68,10 @@ Oder manuell:
```bash
# .txz von Gitea laden
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.18.r005-x86_64-1.txz
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r007-x86_64-1.txz
# Installieren
./mua-2026.08.18.r005-x86_64-1.txz
upgradepkg --install-new mua-2026.08.21.r007-x86_64-1.txz
```
### 2. Service starten
@@ -70,16 +86,16 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
```bash
curl http://192.168.1.2:3002/health
# → {"status":"ok","version":"2026.08.18.r005","auth":"required"}
# → {"status":"ok","version":"2026.08.21.r007","auth":"required"}
```
---
## API-Key generieren (WebGUI)
1. **Settings → MUA** in der Unraid-WebGUI öffnen
1. **Settings → User Utilities → MUA** in der Unraid-WebGUI öffnen
2. Unter **„API-Key (Authentifizierung)"** auf **„Neuen API-Key generieren"** klicken
3. Den angezeigten Key kopieren (64-stellige Hex-String)
3. Den einmalig angezeigten Key kopieren (64-stellige Hex-Zeichenfolge)
Der Key wird in `/boot/config/plugins/mua/mua.conf` gespeichert (chmod 600).
@@ -177,13 +193,20 @@ curl -X POST http://192.168.1.2:3002/mcp \
## Tools aktivieren/deaktivieren (WebGUI)
In **Settings → MUA** → **„Tools (aktivieren / deaktivieren)"**:
In **Settings → User Utilities → MUA** stehen vier Sicherheitsprofile bereit:
- **Nur Lesen (empfohlen):** Status, Diagnose, Logs und Inventar
- **Betrieb + Diagnose:** zusätzlich aktive Prüfungen und Start/Stop/Restart
- **Alles sperren:** MCP bleibt erreichbar, bietet aber keine Werkzeuge an
- **Vollzugriff:** einschließlich Container-Umbau und uneingeschränkter Root-Shell
Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
| Gruppe | Tools |
|--------|-------|
| **Docker (14)** | `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 (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
| **System (1)** | `unraid_system_connection_test` |
| **System (2)** | `unraid_system_connection_test`, `unraid_system_shell` |
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
@@ -220,18 +243,13 @@ Prozess startet automatisch. Zusätzlich führt der POST-INSTALL-Hook im
### Config-Datei-Format (`mua.conf`)
```json
{
"apiKey": "a1b2c3d4...",
"enabledTools": [
"unraid_docker_list",
"unraid_docker_inspect",
"unraid_network_list",
"unraid_system_connection_test"
]
}
```ini
MUA_API_KEY=a1b2c3d4...
MUA_ENABLED_TOOLS=unraid_docker_list,unraid_docker_inspect,unraid_network_list
```
Sonderwerte: `all` aktiviert ausdrücklich alles, `none` deaktiviert alles.
---
## Entwicklung
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mua",
"version": "2026.08.18.r006",
"version": "2026.08.21.r007",
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
"type": "module",
"main": "src/index.ts",
+18 -6
View File
@@ -2,13 +2,13 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "mua">
<!ENTITY author "Michael">
<!ENTITY version "2026.08.18.r006">
<!ENTITY launch "Settings/MUA">
<!ENTITY version "2026.08.21.r007">
<!ENTITY launch "Settings/mua">
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
<!ENTITY pluginLOC "/boot/config/plugins/&name;">
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.18.r006-x86_64-1.txz">
<!ENTITY txzSHA256 "b01b62d482f91bf51029b85639bb67a9db919a383ae5cf3316c59bd022766e7d">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r007-x86_64-1.txz">
<!ENTITY txzSHA256 "04dd05ca142ccaaf3d20f81a855714934e2bef8c0425484290beffd0f2b3f339">
]>
<PLUGIN name="&name;"
@@ -23,6 +23,16 @@
>
<CHANGES>
### 2026.08.21.r007
- Navigation: MUA liegt unter Settings → User Utilities; der Klick im Plugin-Manager öffnet zuverlässig die MUA-Seite (korrekte Groß-/Kleinschreibung).
- Sicherheitsprofile: Nur Lesen (Standard), Betrieb + Diagnose, Alles sperren und expliziter Vollzugriff.
- Fix: Eine leere Tool-Auswahl aktiviert nicht mehr versehentlich alle Tools.
- Secrets: Container-Umgebungswerte werden nicht mehr ausgegeben; häufige Secrets in Logs werden redigiert.
- API-Key: nur einmalige Klartextanzeige nach Rotation; danach ausschließlich maskiert.
- Config-Server: zusätzlicher, pro Start erneuerter Admin-Token und CSRF-Schutz in der WebGUI.
- Netzwerk: CORS standardmäßig aus, Request-Limit 1 MiB und Rate-Limit pro Client.
- Audit: Werkzeugname, Risikostufe, Ergebnis und Laufzeit ohne Argumente/Inhalte in mua-audit.log.
- WebGUI vollständig neu geordnet: Statuskarten, Sicherheitsprofile und Werkzeuge nach Risikostufe.
### 2026.08.18.r006
- Neues Tool: unraid_system_shell — direkter Shell-Zugriff auf den Unraid-Host (root, /bin/sh -c). Liefert exit_code + stdout + stderr (JSON).
- WebGUI: unraid_system_shell in der System-Tool-Gruppe (jetzt 2 System-Tools, insgesamt 22 Tools).
@@ -81,7 +91,7 @@ Das .txz enthält:
install/doinst.sh (läuft nach Installation)
===========================================
-->
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.18.r006-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.21.r007-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<URL>&txzURL;</URL>
<SHA256>&txzSHA256;</SHA256>
</FILE>
@@ -113,7 +123,7 @@ if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2&gt;&amp;1; then
echo "===================================================="
echo " MUA installiert und läuft auf Port 3002"
echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp"
echo " WebGUI: Settings > MUA"
echo " WebGUI: Settings > User Utilities > MUA"
echo "===================================================="
else
echo ""
@@ -179,7 +189,9 @@ rm -rf &pluginLOC;
# 7. Logs entfernen
rm -f /var/log/plugins/mua.log
rm -f /var/log/plugins/mua-audit.log
rm -f /var/run/mua.pid
rm -f /var/run/mua-admin.token
echo ""
echo " MUA wurde deinstalliert."
+132 -265
View File
@@ -1,306 +1,173 @@
---
Menu="OtherSettings"
Menu="Utilities"
Type="xmenu"
Title="MUA"
Icon="cubes"
---
<?php
/**
* 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:3013 (nur localhost, kein Auth nötig —
* WebGUI läuft auf demselben Host und ist selbst authifiziert).
*/
$plugin_dir = '/usr/local/emhttp/plugins/mua';
/** MUA WebGUI — sicherheitsorientierte Verwaltung. */
$port = 3002;
$config_port = 3013;
$pidfile = '/var/run/mua.pid';
$binary = '/usr/local/bin/mua';
$endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp';
$admin_token_file = '/var/run/mua-admin.token';
$admin_token = is_readable($admin_token_file) ? trim(file_get_contents($admin_token_file)) : '';
$host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
$endpoint = 'http://' . $host_ip . ':' . $port . '/mcp';
function mua_api($method, $path, $payload, $token, $port) {
if (!$token) return [0, null];
$ch = curl_init('http://127.0.0.1:' . $port . $path);
$headers = ['X-MUA-Admin-Token: ' . $token];
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_HTTPHEADER => $headers];
if ($method === 'POST') {
$options[CURLOPT_POST] = true;
if ($payload !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($payload);
$headers[] = 'Content-Type: application/json';
$options[CURLOPT_HTTPHEADER] = $headers;
}
}
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, $raw !== false ? json_decode($raw, true) : null];
}
function csrf_value($token, $offset = 0) {
return hash_hmac('sha256', gmdate('Y-m-d-H', time() + $offset * 3600), $token);
}
function mua_health($port) {
$ch = curl_init('http://127.0.0.1:' . $port . '/health');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3, CURLOPT_CONNECTTIMEOUT => 2]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code === 200 && $raw !== false ? json_decode($raw, true) : null;
}
// ── Status bestimmen ────────────────────────────────────────────────────
$running = false;
$pid = null;
if (file_exists($pidfile)) {
$pid = trim(file_get_contents($pidfile));
if ($pid && is_numeric($pid) && @posix_kill((int)$pid, 0)) {
$running = true;
}
$running = $pid && is_numeric($pid) && @posix_kill((int)$pid, 0);
}
// ── Health-Check (MCP-Server, Port 3002) ────────────────────────────────
$health = null;
$health_code = 0;
$ch = curl_init('http://127.0.0.1:' . $port . '/health');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_CONNECTTIMEOUT => 2,
]);
$health = curl_exec($ch);
$health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$health_ok = ($health_code === 200);
// Version aus /health lesen (dynamisch, kein hartkodiertes rNNN)
$running_version = '';
if ($health_ok && $health !== false) {
$h = json_decode($health, true);
$running_version = $h['version'] ?? '';
}
// ── 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) ─────────────────────────────
$health_data = mua_health($port);
$health_ok = is_array($health_data) && ($health_data['status'] ?? '') === 'ok';
$running_version = $health_data['version'] ?? '';
[$config_code, $config] = mua_api('GET', '/config', null, $admin_token, $config_port);
$config_ok = $config_code === 200 && is_array($config);
$flash_msg = '';
$flash_type = '';
$new_api_key = '';
$csrf = csrf_value($admin_token);
$readonly = [
'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',
];
$operator = array_merge($readonly, [
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
'unraid_docker_start', 'unraid_docker_stop', 'unraid_docker_restart',
]);
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;
$posted_csrf = $_POST['mua_csrf'] ?? '';
$csrf_ok = $admin_token && (hash_equals(csrf_value($admin_token), $posted_csrf) || hash_equals(csrf_value($admin_token, -1), $posted_csrf));
if (!$csrf_ok) {
$flash_msg = 'Sicherheitsprüfung fehlgeschlagen. Seite neu laden und erneut versuchen.';
$flash_type = 'err';
} else {
$action = $_POST['action'] ?? '';
$payload = null;
if ($action === 'generate_key') $payload = ['generate' => true];
if ($action === 'preset_readonly') $payload = ['enabledTools' => $readonly, 'allToolsEnabled' => false];
if ($action === 'preset_operator') $payload = ['enabledTools' => $operator, 'allToolsEnabled' => false];
if ($action === 'preset_none') $payload = ['enabledTools' => [], 'allToolsEnabled' => false];
if ($action === 'preset_all') $payload = ['enabledTools' => [], 'allToolsEnabled' => true];
if ($action === 'save_tools') {
$selected = [];
foreach (($config['allTools'] ?? []) as $tool) {
$name = is_array($tool) ? ($tool['name'] ?? '') : $tool;
if ($name && isset($_POST['tool_' . $name])) $selected[] = $name;
}
$payload = ['enabledTools' => $selected, 'allToolsEnabled' => false];
}
$payload['enabledTools'] = $selected;
}
// C) Service neu starten
if (isset($_POST['action']) && $_POST['action'] === 'restart') {
$ch = curl_init('http://127.0.0.1:' . $config_port . '/restart');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_POST => true,
]);
$resp_raw = curl_exec($ch);
$resp_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($resp_code === 200) {
$flash_msg = 'MUA wird neu gestartet (Service restart).';
$flash_type = 'ok';
} else {
$flash_msg = 'Fehler beim Neustart (HTTP ' . $resp_code . ')';
$flash_type = 'err';
}
}
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.';
if ($action === 'restart') {
[$code, $result] = mua_api('POST', '/restart', null, $admin_token, $config_port);
$flash_msg = $code === 200 ? 'MUA wird neu gestartet.' : 'Neustart fehlgeschlagen (HTTP ' . $code . ').';
$flash_type = $code === 200 ? 'ok' : 'err';
} elseif ($payload !== null) {
[$code, $result] = mua_api('POST', '/config', $payload, $admin_token, $config_port);
if ($code === 200 && is_array($result) && ($result['ok'] ?? false)) {
$new_api_key = $result['generatedApiKey'] ?? '';
$flash_msg = $new_api_key ? 'Neuer API-Key erzeugt. Jetzt kopieren – er wird nur dieses eine Mal angezeigt.' : 'Sicherheitseinstellungen gespeichert.';
$flash_type = 'ok';
[$config_code, $config] = mua_api('GET', '/config', null, $admin_token, $config_port);
$config_ok = $config_code === 200 && is_array($config);
} else {
$flash_msg = 'Tool-Einstellungen gespeichert (' . count($selected) . ' von ' . count($all_tools) . ' aktiv).';
$flash_type = 'ok';
$flash_msg = 'Speichern fehlgeschlagen (HTTP ' . $code . ').';
$flash_type = 'err';
}
} else {
$flash_msg = 'Fehler beim Speichern (HTTP ' . $resp_code . ')';
$flash_type = 'err';
}
}
}
$binary_exists = file_exists($binary);
$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',
'unraid_system_shell',
],
$enabled_tools = $config['enabledTools'] ?? [];
$all_tools_enabled = (bool)($config['allToolsEnabled'] ?? false);
$all_tools = $config['allTools'] ?? [];
$risk_groups = ['read' => [], 'active' => [], 'write' => [], 'critical' => []];
foreach ($all_tools as $tool) {
if (!is_array($tool)) $tool = ['name' => $tool, 'description' => '', 'risk' => 'read'];
$risk = $tool['risk'] ?? 'read';
if (!isset($risk_groups[$risk])) $risk = 'read';
$risk_groups[$risk][] = $tool;
}
$risk_labels = [
'read' => ['Nur Lesen', 'Liest Status und Diagnoseinformationen.', 'safe'],
'active' => ['Aktive Prüfungen', 'Baut gezielt Netzwerkverbindungen für Tests auf.', 'notice'],
'write' => ['Betrieb steuern', 'Startet, stoppt oder startet Container neu.', 'warn'],
'critical' => ['Kritischer Zugriff', 'Verändert Container oder führt uneingeschränkte Root-Befehle aus.', 'danger'],
];
$active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools);
?>
<style>
.mua-status { padding: 12px 16px; border-radius: 6px; margin: 12px 0; font-size: 0.95em; }
.mua-status.ok { background: #e8f5e9; border: 1px solid #4caf50; color: #2e7d32; }
.mua-status.err { background: #ffebee; border: 1px solid #f44336; color: #c62828; }
.mua-status.warn { background: #fff3e0; border: 1px solid #ff9800; color: #e65100; }
.mua-endpoint { font-size: 1.1em; color: #4a90d9; font-weight: 600; }
.mua-key { font-size: 1.05em; word-break: break-all; background: #f5f5f5; padding: 8px 12px; border-radius: 4px; border: 1px solid #ddd; }
.mua-tool-group { margin: 12px 0; }
.mua-tool-group h3 { margin: 8px 0 4px 0; font-size: 1em; color: #555; }
.mua-tool-item { display: inline-block; margin: 4px 12px 4px 0; font-size: 0.9em; }
.mua-tool-item code { font-size: 0.85em; }
.mua-btn { padding: 6px 16px; border-radius: 4px; border: 1px solid #ccc; background: #f5f5f5; cursor: pointer; font-size: 0.9em; }
.mua-btn:hover { background: #e0e0e0; }
.mua-btn-primary { background: #4a90d9; color: white; border-color: #3a7bc0; }
.mua-btn-primary:hover { background: #3a7bc0; }
.mua-wrap{max-width:1280px}.mua-hero{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin:12px 0 20px}.mua-title h2{margin:0 0 5px;font-size:1.55rem}.mua-muted{color:#777}.mua-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(285px,1fr));gap:14px;margin:14px 0}.mua-card{border:1px solid #d8d8d8;border-radius:9px;padding:16px;background:rgba(255,255,255,.03)}.mua-card h3{margin:0 0 8px}.mua-status{padding:11px 14px;border-radius:7px;margin:10px 0;border:1px solid}.mua-status.ok,.safe{border-color:#43a047;background:rgba(67,160,71,.11)}.mua-status.err,.danger{border-color:#e53935;background:rgba(229,57,53,.11)}.mua-status.warn,.warn{border-color:#fb8c00;background:rgba(251,140,0,.12)}.notice{border-color:#1e88e5;background:rgba(30,136,229,.10)}.mua-pill{display:inline-block;padding:3px 9px;border-radius:20px;font-size:.82em;font-weight:600;border:1px solid currentColor}.mua-endpoint{font-size:1.05em;word-break:break-all}.mua-key{display:block;padding:12px;border:1px solid #43a047;border-radius:6px;word-break:break-all;font-size:1.04em;background:rgba(67,160,71,.08)}.mua-actions{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}.mua-btn{padding:8px 14px;border-radius:5px;border:1px solid #aaa;cursor:pointer}.mua-btn-primary{background:#168bd2;color:#fff;border-color:#168bd2}.mua-btn-danger{background:#c62828;color:#fff;border-color:#c62828}.mua-tool{display:grid;grid-template-columns:22px 1fr;gap:8px;padding:9px 0;border-top:1px solid rgba(128,128,128,.2)}.mua-tool:first-of-type{border-top:0}.mua-tool code{font-size:.86em}.mua-tool small{display:block;color:#777;margin-top:3px;line-height:1.35}.mua-section{margin-top:24px}.mua-summary{font-size:1.08em}.mua-preset{margin:0}.mua-details td:first-child{width:190px;font-weight:600}@media(max-width:700px){.mua-hero{display:block}.mua-actions{display:grid}.mua-btn{width:100%}}
</style>
<div class="mua-wrap">
<div class="mua-hero"><div class="mua-title"><h2>MUA · Mikes Unraid Agent</h2><div class="mua-muted">Sicherer MCP-Zugriff auf Docker, Netzwerk und Unraid-Systemfunktionen</div></div><div><span class="mua-pill <?= $running && $health_ok ? 'safe' : 'danger' ?>"><?= $running && $health_ok ? '● Dienst bereit' : '● Dienst gestört' ?></span></div></div>
<?php if ($running): ?>
<div class="mua-status ok">✅ <strong>MCP-Server läuft</strong> (PID <?= $pid ?>, Port <?= $port ?>)</div>
<?php else: ?>
<div class="mua-status err">❌ <strong>MCP-Server läuft nicht</strong> — starte mit <code>/etc/rc.d/rc.mua start</code></div>
<?php endif; ?>
<?php if ($flash_msg): ?><div class="mua-status <?= htmlspecialchars($flash_type) ?>"><?= htmlspecialchars($flash_msg) ?></div><?php endif; ?>
<?php if ($new_api_key): ?><div class="mua-status ok"><strong>Neuer API-Key – einmalige Anzeige</strong><code class="mua-key" id="mua-new-key"><?= htmlspecialchars($new_api_key) ?></code><button type="button" class="mua-btn" onclick="navigator.clipboard.writeText(document.getElementById('mua-new-key').textContent)">In Zwischenablage kopieren</button></div><?php endif; ?>
<?php if ($health_ok): ?>
<div class="mua-status ok">✅ Health-Check: OK</div>
<?php elseif ($health !== false): ?>
<div class="mua-status warn">⚠️ Health-Check: HTTP <?= $health_code ?></div>
<?php else: ?>
<div class="mua-status warn">⚠️ Health-Check: nicht erreichbar</div>
<?php endif; ?>
<div class="mua-grid">
<div class="mua-card"><h3>Dienst</h3><p class="mua-summary"><?= $health_ok ? '✅ MCP-Server antwortet' : '❌ Health-Check fehlgeschlagen' ?></p><div class="mua-muted">Version <?= htmlspecialchars($running_version ?: '—') ?> · PID <?= htmlspecialchars((string)($pid ?: '—')) ?></div></div>
<div class="mua-card"><h3>Zugriffsprofil</h3><p class="mua-summary"><strong><?= $active_count ?></strong> von <?= count($all_tools) ?> Werkzeugen aktiv</p><div class="mua-muted"><?= $all_tools_enabled ? 'Vollzugriff – einschließlich kritischer Werkzeuge' : ($active_count === 0 ? 'Komplett gesperrt' : 'Benutzerdefinierte Freigabe') ?></div></div>
<div class="mua-card"><h3>Authentifizierung</h3><p class="mua-summary"><?= $config_ok ? '✅ Bearer-Key aktiv' : '❌ Verwaltung nicht erreichbar' ?></p><div class="mua-muted"><?= htmlspecialchars($config['apiKeyMasked'] ?? 'nicht verfügbar') ?></div></div>
</div>
<?php if ($flash_msg): ?>
<div class="mua-status <?= $flash_type ?>"><?= htmlspecialchars($flash_msg) ?></div>
<?php endif; ?>
<div class="mua-card"><h3>MCP-Endpunkt</h3><code class="mua-endpoint"><?= htmlspecialchars($endpoint) ?></code><p class="mua-muted">Streamable HTTP · JSON-RPC 2.0 · Bearer-Authentifizierung erforderlich</p></div>
<h2>MCP-Endpunkt</h2>
<p class="mua-endpoint"><?= $endpoint ?></p>
<p>Transport: <code>Streamable HTTP</code> (POST-only) · JSON-RPC 2.0</p>
<div class="mua-section"><h2>Schnelle Sicherheitsprofile</h2><p class="mua-muted">„Nur Lesen“ ist die empfohlene Dauerstellung. Weitergehende Rechte nur bei konkretem Bedarf freigeben.</p><div class="mua-actions">
<?php foreach ([['preset_readonly','Nur Lesen (empfohlen)',''],['preset_operator','Betrieb + Diagnose',''],['preset_none','Alles sperren',''],['preset_all','Vollzugriff','danger']] as $preset): ?>
<form method="post" class="mua-preset" onsubmit="<?= $preset[0] === 'preset_all' ? "return confirm('Vollzugriff aktiviert auch Container-Umbau und uneingeschränkte Root-Shell. Wirklich freigeben?');" : '' ?>"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="<?= $preset[0] ?>"><button class="mua-btn <?= $preset[2] === 'danger' ? 'mua-btn-danger' : ($preset[0] === 'preset_readonly' ? 'mua-btn-primary' : '') ?>" type="submit"><?= $preset[1] ?></button></form>
<?php endforeach; ?></div></div>
<h2>API-Key (Authentifizierung)</h2>
<?php if ($config_ok && $api_key): ?>
<div class="mua-status ok">✅ API-Key aktiv — Clients müssen diesen als Bearer-Token senden</div>
<p><strong>API-Key:*** <code class="mua-key"><?= htmlspecialchars($api_key) ?></code></p>
<p><strong>Authorization-Header:</strong> <code>Authorization: Bearer *** htmlspecialchars($api_key) ?></code></p>
<p style="color:#666; font-size:0.9em;">
Beispiel (curl):<br>
<code>curl -X POST <?= $endpoint ?> -H "Authorization: Bearer *** htmlspecialchars($api_key) ?>" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'</code>
</p>
<form method="post" style="margin-top:8px;">
<input type="hidden" name="action" value="generate_key">
<button type="submit" class="mua-btn mua-btn-primary" onclick="return confirm('Neuen API-Key generieren? Der alte Key wird ungültig!')">🔄 Neuen API-Key generieren</button>
</form>
<?php elseif ($config_ok): ?>
<div class="mua-status warn">⚠️ Kein API-Key gesetzt. Generiere einen, um den MCP-Endpunkt zu schützen.</div>
<form method="post" style="margin-top:8px;">
<input type="hidden" name="action" value="generate_key">
<button type="submit" class="mua-btn mua-btn-primary">🔄 API-Key generieren</button>
</form>
<?php else: ?>
<div class="mua-status warn">⚠️ Config-Server nicht erreichbar (Port <?= $config_port ?>). Starte den MCP-Server, um die Einstellungen zu verwalten.</div>
<?php endif; ?>
<div class="mua-section"><h2>Werkzeuge im Detail</h2><form method="post"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="save_tools"><div class="mua-grid">
<?php foreach ($risk_groups as $risk => $tools): $meta=$risk_labels[$risk]; ?>
<div class="mua-card <?= $meta[2] ?>"><h3><?= $meta[0] ?> <span class="mua-pill"><?= count($tools) ?></span></h3><p class="mua-muted"><?= $meta[1] ?></p>
<?php foreach ($tools as $tool): $name=$tool['name']; $checked=$all_tools_enabled || in_array($name,$enabled_tools,true); ?>
<label class="mua-tool"><input type="checkbox" name="tool_<?= htmlspecialchars($name) ?>" <?= $checked ? 'checked' : '' ?>><span><code><?= htmlspecialchars($name) ?></code><small><?= htmlspecialchars($tool['description'] ?? '') ?></small></span></label>
<?php endforeach; ?></div><?php endforeach; ?>
</div><button type="submit" class="mua-btn mua-btn-primary">Benutzerdefinierte Auswahl speichern</button></form></div>
<h2>Tools (aktivieren / deaktivieren)</h2>
<?php if ($config_ok && !empty($all_tools)): ?>
<p style="color:#666; font-size:0.9em;">
<?= count($enabled_tools) === 0 ? 'Alle Tools aktiv' : count($enabled_tools) . ' von ' . count($all_tools) . ' Tools aktiv' ?>.
Deaktivierte Tools werden nicht in <code>tools/list</code> angezeigt und bei <code>tools/call</code> abgelehnt.
</p>
<form method="post">
<input type="hidden" name="action" value="save_tools">
<?php foreach ($tool_groups as $group_name => $group_tools): ?>
<div class="mua-tool-group">
<h3><?= htmlspecialchars($group_name) ?> (<?= count($group_tools) ?>)</h3>
<?php foreach ($group_tools as $tool): ?>
<?php $checked = (count($enabled_tools) === 0 || in_array($tool, $enabled_tools)) ? 'checked' : ''; ?>
<label class="mua-tool-item">
<input type="checkbox" name="tool_<?= htmlspecialchars($tool) ?>" value="1" <?= $checked ?>>
<code><?= htmlspecialchars($tool) ?></code>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<div style="margin-top:12px;">
<button type="submit" class="mua-btn mua-btn-primary">💾 Einstellungen speichern</button>
</div>
</form>
<?php else: ?>
<div class="mua-status warn">⚠️ Tool-Liste nicht verfügbar (Config-Server nicht erreichbar).</div>
<?php endif; ?>
<div class="mua-section"><h2>API-Key</h2><div class="mua-card"><p>Der vollständige Schlüssel wird nicht dauerhaft angezeigt. Beim Erzeugen erscheint er genau einmal; danach ist nur noch die Endung sichtbar.</p><form method="post" onsubmit="return confirm('Der bisherige Schlüssel wird sofort ungültig. Neuen Schlüssel erzeugen?');"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="generate_key"><button type="submit" class="mua-btn">Neuen API-Key erzeugen</button></form></div></div>
<h2>Details</h2>
<table>
<tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr>
<tr><th>Version</th><td><?php echo $running_version ? htmlspecialchars($running_version) : '—'; ?></td></tr>
<tr><th>Runtime</th><td>TypeScript (Bun Runtime, kompiliertes Binary)</td></tr>
<tr><th>Binary</th><td><code><?= $binary ?></code> <?= $binary_exists ? '✅' : '❌ fehlt' ?></td></tr>
<tr><th>Port (MCP)</th><td><?= $port ?></td></tr>
<tr><th>Port (Config)</th><td><?= $config_port ?> (nur localhost)</td></tr>
<tr><th>Host-IP</th><td><?= $host_ip ?></td></tr>
<tr><th>Tools</th><td><?= count($all_tools) ?: 22 ?> (Docker: 14, Netzwerk: 6, System: 2)</td></tr>
<tr><th>Plugin-Verzeichnis</th><td><code><?= $plugin_dir ?></code></td></tr>
<tr><th>Service</th><td><code>/etc/rc.d/rc.mua</code> (SysVinit)</td></tr>
</table>
<h2>Verwaltung</h2>
<table>
<tr><th>Aktion</th><th>Befehl</th></tr>
<tr><td>Starten</td><td><code>/etc/rc.d/rc.mua start</code></td></tr>
<tr><td>Stoppen</td><td><code>/etc/rc.d/rc.mua stop</code></td></tr>
<tr><td>Neu starten</td><td><code>/etc/rc.d/rc.mua restart</code></td></tr>
<tr><td>Status</td><td><code>/etc/rc.d/rc.mua status</code></td></tr>
<tr><td>Logs</td><td><code>tail -f /var/log/plugins/mua.log</code></td></tr>
</table>
<h2>Service neu starten</h2>
<p>Startet den MCP-Service neu (z.&nbsp;B. nach einem Plugin-Update, wenn sich das Binary geändert hat).</p>
<form method="post" onsubmit="return confirm('MUA-Service neu starten?');">
<input type="hidden" name="action" value="restart">
<button type="submit" class="btn btn-primary">🔄 MUA neu starten</button>
</form>
<div class="mua-section"><h2>Technische Details</h2><table class="mua-details"><tr><td>Version</td><td><?= htmlspecialchars($running_version ?: '—') ?></td></tr><tr><td>MCP-Port</td><td><?= $port ?></td></tr><tr><td>Verwaltung</td><td>127.0.0.1:<?= $config_port ?> · separater Admin-Token</td></tr><tr><td>Konfiguration</td><td><code>/boot/config/plugins/mua/mua.conf</code></td></tr><tr><td>Audit-Protokoll</td><td><code>/var/log/plugins/mua-audit.log</code> (keine Argumente oder Inhalte)</td></tr></table><form method="post" onsubmit="return confirm('MUA-Service neu starten?');"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="restart"><button type="submit" class="mua-btn">Dienst neu starten</button></form></div>
</div>
+9 -8
View File
@@ -34,12 +34,11 @@ echo " MUA - Paket-Builder"
echo " Version: ${VERSION}"
echo "===================================================="
# 1. Binary bauen (falls nicht vorhanden)
if [ ! -f "${DIST_DIR}/mua" ]; then
echo " Kompiliere Binary..."
cd "${ROOT}"
bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua"
fi
# 1. Binary immer frisch bauen. Ein vorhandenes Binary darf niemals
# versehentlich in ein neues Sicherheits-Release übernommen werden.
echo " Kompiliere Binary..."
cd "${ROOT}"
bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua"
chmod +x "${DIST_DIR}/mua"
echo " Binary: $(du -h "${DIST_DIR}/mua" | cut -f1)"
@@ -74,6 +73,8 @@ cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF'
# Verzeichnisse
mkdir -p /var/log/plugins
touch /var/log/plugins/mua.log
touch /var/log/plugins/mua-audit.log
chmod 600 /var/log/plugins/mua.log /var/log/plugins/mua-audit.log
mkdir -p /boot/config/plugins/mua
# Service starten
@@ -99,12 +100,12 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|${PKG_NAME} - Mikes Unraid Agent
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|21 Tools: Docker (14), Netzwerk (6), System (1).
|22 Tools: Docker (14), Netzwerk (6), System (2).
|Port: 3002, Endpunkt: /mcp
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|Service: /etc/rc.d/rc.mua (SysVinit)
|WebGUI: Settings > MUA
|WebGUI: Settings > User Utilities > MUA
|
|Version: ${VERSION}
|Arch: ${ARCH}
+2
View File
@@ -47,6 +47,7 @@ stop() {
echo " MUA gestoppt (PID $pid)"
fi
rm -f "$PIDFILE"
rm -f /var/run/mua-admin.token
else
# Fallback: per Port suchen
local pid=$(lsof -ti :$PORT 2>/dev/null)
@@ -56,6 +57,7 @@ stop() {
else
echo " MUA läuft nicht"
fi
rm -f /var/run/mua-admin.token
fi
}
+63 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+47
View File
@@ -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
View File
@@ -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: {