release r007 security hardening and redesigned UI
This commit is contained in:
1 parent
514310912c
commit
65922b7f57
12 files changed
+485
-346
No files matched your search
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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
@@ -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>&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
@@ -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. 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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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