r002: API-Key-Auth + Einstellungs-Maske (API-Key generieren, Tool-Checkboxen)
- src/auth.ts: Config-Management (API-Key + Tool-Filter), timing-safe Auth - src/index.ts: Auth-Check auf /mcp, Tool-Filter in tools/list + tools/call, Config-Server auf 127.0.0.1:3003 (nur localhost) - scripts/mua.page: Einstellungs-UI (API-Key generieren + Tool-Checkboxen) - scripts/rc.mua: Config-Dir sicherstellen - Version: 2026.08.18.r002
This commit is contained in:
BIN
Binary file not shown.
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML
|
ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML
|
||||||
|
|
||||||
const MUA_VERSION = '2026.08.18.r001';
|
const MUA_VERSION = '2026.08.18.r002';
|
||||||
const MUA_SERVER_NAME = 'mua';
|
const MUA_SERVER_NAME = 'mua';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ const MUA_PORT = 3002;
|
|||||||
const MUA_BIND = '0.0.0.0';
|
const MUA_BIND = '0.0.0.0';
|
||||||
const MUA_SESSION_TTL = 3600; // Sekunden
|
const MUA_SESSION_TTL = 3600; // Sekunden
|
||||||
const MUA_SERVER_NAME = 'mua';
|
const MUA_SERVER_NAME = 'mua';
|
||||||
const MUA_VERSION = '2026.08.18.r001';
|
const MUA_VERSION = '2026.08.18.r002';
|
||||||
|
|
||||||
// ── Session-Management (in-memory) ───────────────────────────────────────
|
// ── Session-Management (in-memory) ───────────────────────────────────────
|
||||||
$sessions = []; // session_id => ['created' => time, 'initialized' => bool]
|
$sessions = []; // session_id => ['created' => time, 'initialized' => bool]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"version": "2026.08.18.r001",
|
"version": "2026.08.18.r002",
|
||||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
+9
-4
@@ -2,13 +2,13 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "mua">
|
<!ENTITY name "mua">
|
||||||
<!ENTITY author "Michael">
|
<!ENTITY author "Michael">
|
||||||
<!ENTITY version "2026.08.18.r001">
|
<!ENTITY version "2026.08.18.r002">
|
||||||
<!ENTITY launch "Settings/MUA">
|
<!ENTITY launch "Settings/MUA">
|
||||||
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
|
<!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 pluginLOC "/boot/config/plugins/&name;">
|
||||||
<!ENTITY emhttpLOC "/usr/local/emhttp/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.r001-x86_64-1.txz">
|
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.18.r002-x86_64-1.txz">
|
||||||
<!ENTITY txzSHA256 "723a5b7b3302af9c5da4abc665af5361b55c41484ada1d95be259ad06776b49f">
|
<!ENTITY txzSHA256 "57da68ccdb04ba8539bcc667b3666745f08aaaa6af84cab10887702950206ec2">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.08.18.r002
|
||||||
|
- API-Key-Authentifizierung (Bearer-Token) für den MCP-HTTP-Endpunkt (Port 3002). /health bleibt offen.
|
||||||
|
- Einstellungs-Maske im WebGUI-Tab: API-Key generieren + Tools per Checkbox aktivieren/deaktivieren.
|
||||||
|
- Config-Server auf 127.0.0.1:3003 (nur localhost) für WebGUI-Integration.
|
||||||
|
- Tool-Filter: deaktivierte Tools werden in tools/list ausgeblendet und bei tools/call abgelehnt.
|
||||||
### 2026.08.18.r001
|
### 2026.08.18.r001
|
||||||
- Initial release: 21 Docker, network, and system tools as MCP server over HTTP (Streamable HTTP, port 3002).
|
- Initial release: 21 Docker, network, and system tools as MCP server over HTTP (Streamable HTTP, port 3002).
|
||||||
- Runtime: TypeScript (Bun Runtime, compiled binary) — no PHP built-in server.
|
- Runtime: TypeScript (Bun Runtime, compiled binary) — no PHP built-in server.
|
||||||
@@ -65,7 +70,7 @@ Das .txz enthält:
|
|||||||
install/doinst.sh (läuft nach Installation)
|
install/doinst.sh (läuft nach Installation)
|
||||||
===========================================
|
===========================================
|
||||||
-->
|
-->
|
||||||
<FILE Name="mua-2026.08.18.r001-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
<FILE Name="mua-2026.08.18.r002-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
||||||
<URL>&txzURL;</URL>
|
<URL>&txzURL;</URL>
|
||||||
<SHA256>&txzSHA256;</SHA256>
|
<SHA256>&txzSHA256;</SHA256>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"author": "Michael",
|
"author": "Michael",
|
||||||
"version": "2026.08.18.r001",
|
"version": "2026.08.18.r002",
|
||||||
"minver": "7.0.0",
|
"minver": "7.0.0",
|
||||||
"pluginDirectory": "/usr/local/emhttp/plugins/mua",
|
"pluginDirectory": "/usr/local/emhttp/plugins/mua",
|
||||||
"configDirectory": "/boot/config/plugins/mua",
|
"configDirectory": "/boot/config/plugins/mua",
|
||||||
|
|||||||
+181
-14
@@ -9,15 +9,23 @@ Icon="cubes"
|
|||||||
* MUA - Mikes Unraid Agent
|
* MUA - Mikes Unraid Agent
|
||||||
* WebGUI-Tab (mua.page)
|
* WebGUI-Tab (mua.page)
|
||||||
* Unraid-Standard .page-Format
|
* 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:3003 (nur localhost, kein Auth nötig —
|
||||||
|
* WebGUI läuft auf demselben Host und ist selbst authifiziert).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$plugin_dir = '/usr/local/emhttp/plugins/mua';
|
$plugin_dir = '/usr/local/emhttp/plugins/mua';
|
||||||
$port = 3002;
|
$port = 3002;
|
||||||
|
$config_port = 3003;
|
||||||
$pidfile = '/var/run/mua.pid';
|
$pidfile = '/var/run/mua.pid';
|
||||||
$binary = '/usr/local/bin/mua';
|
$binary = '/usr/local/bin/mua';
|
||||||
$endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp';
|
$endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp';
|
||||||
|
|
||||||
// Status bestimmen
|
// ── Status bestimmen ────────────────────────────────────────────────────
|
||||||
$running = false;
|
$running = false;
|
||||||
$pid = null;
|
$pid = null;
|
||||||
if (file_exists($pidfile)) {
|
if (file_exists($pidfile)) {
|
||||||
@@ -27,7 +35,7 @@ if (file_exists($pidfile)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health-Check
|
// ── Health-Check (MCP-Server, Port 3002) ────────────────────────────────
|
||||||
$health = null;
|
$health = null;
|
||||||
$health_code = 0;
|
$health_code = 0;
|
||||||
$ch = curl_init('http://127.0.0.1:' . $port . '/health');
|
$ch = curl_init('http://127.0.0.1:' . $port . '/health');
|
||||||
@@ -41,9 +49,111 @@ $health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
$health_ok = ($health_code === 200);
|
$health_ok = ($health_code === 200);
|
||||||
|
|
||||||
|
// ── 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) ─────────────────────────────
|
||||||
|
$flash_msg = '';
|
||||||
|
$flash_type = '';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$payload['enabledTools'] = $selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.';
|
||||||
|
$flash_type = 'ok';
|
||||||
|
} else {
|
||||||
|
$flash_msg = 'Tool-Einstellungen gespeichert (' . count($selected) . ' von ' . count($all_tools) . ' aktiv).';
|
||||||
|
$flash_type = 'ok';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$flash_msg = 'Fehler beim Speichern (HTTP ' . $resp_code . ')';
|
||||||
|
$flash_type = 'err';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$binary_exists = file_exists($binary);
|
$binary_exists = file_exists($binary);
|
||||||
$tool_count = 21;
|
|
||||||
$host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
$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',
|
||||||
|
],
|
||||||
|
];
|
||||||
?>
|
?>
|
||||||
<style>
|
<style>
|
||||||
.mua-status { padding: 12px 16px; border-radius: 6px; margin: 12px 0; font-size: 0.95em; }
|
.mua-status { padding: 12px 16px; border-radius: 6px; margin: 12px 0; font-size: 0.95em; }
|
||||||
@@ -51,6 +161,15 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
|||||||
.mua-status.err { background: #ffebee; border: 1px solid #f44336; color: #c62828; }
|
.mua-status.err { background: #ffebee; border: 1px solid #f44336; color: #c62828; }
|
||||||
.mua-status.warn { background: #fff3e0; border: 1px solid #ff9800; color: #e65100; }
|
.mua-status.warn { background: #fff3e0; border: 1px solid #ff9800; color: #e65100; }
|
||||||
.mua-endpoint { font-size: 1.1em; color: #4a90d9; font-weight: 600; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<?php if ($running): ?>
|
<?php if ($running): ?>
|
||||||
@@ -67,19 +186,75 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
|||||||
<div class="mua-status warn">⚠️ Health-Check: nicht erreichbar</div>
|
<div class="mua-status warn">⚠️ Health-Check: nicht erreichbar</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($flash_msg): ?>
|
||||||
|
<div class="mua-status <?= $flash_type ?>"><?= htmlspecialchars($flash_msg) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<h2>MCP-Endpunkt</h2>
|
<h2>MCP-Endpunkt</h2>
|
||||||
<p class="mua-endpoint"><?= $endpoint ?></p>
|
<p class="mua-endpoint"><?= $endpoint ?></p>
|
||||||
<p>Transport: <code>Streamable HTTP</code> (POST-only) · JSON-RPC 2.0</p>
|
<p>Transport: <code>Streamable HTTP</code> (POST-only) · JSON-RPC 2.0</p>
|
||||||
|
|
||||||
|
<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; ?>
|
||||||
|
|
||||||
|
<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; ?>
|
||||||
|
|
||||||
<h2>Details</h2>
|
<h2>Details</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr>
|
<tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr>
|
||||||
<tr><th>Version</th><td>2026.08.18.r001</td></tr>
|
<tr><th>Version</th><td>2026.08.18.r002</td></tr>
|
||||||
<tr><th>Runtime</th><td>TypeScript (Bun Runtime, kompiliertes Binary)</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>Binary</th><td><code><?= $binary ?></code> <?= $binary_exists ? '✅' : '❌ fehlt' ?></td></tr>
|
||||||
<tr><th>Port</th><td><?= $port ?></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>Host-IP</th><td><?= $host_ip ?></td></tr>
|
||||||
<tr><th>Tools</th><td><?= $tool_count ?> (Docker: 14, Netzwerk: 6, System: 1)</td></tr>
|
<tr><th>Tools</th><td><?= count($all_tools) ?: 21 ?> (Docker: 14, Netzwerk: 6, System: 1)</td></tr>
|
||||||
<tr><th>Plugin-Verzeichnis</th><td><code><?= $plugin_dir ?></code></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>
|
<tr><th>Service</th><td><code>/etc/rc.d/rc.mua</code> (SysVinit)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -93,11 +268,3 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
|||||||
<tr><td>Status</td><td><code>/etc/rc.d/rc.mua status</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>
|
<tr><td>Logs</td><td><code>tail -f /var/log/plugins/mua.log</code></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>Tools</h2>
|
|
||||||
<table>
|
|
||||||
<tr><th>Kategorie</th><th>Tools</th></tr>
|
|
||||||
<tr><td>Docker (14)</td><td>list, inspect, logs, analyze_logs, processes, stats, info, start, stop, restart, create, modify, update, rebuild</td></tr>
|
|
||||||
<tr><td>Netzwerk (6)</td><td>inventory, list, inspect, host_state, audit_tcp, lan_probe</td></tr>
|
|
||||||
<tr><td>System (1)</td><td>connection_test</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-2026.08.18.r001}"
|
VERSION="${1:-2026.08.18.r002}"
|
||||||
PKG_NAME="mua"
|
PKG_NAME="mua"
|
||||||
ARCH="x86_64"
|
ARCH="x86_64"
|
||||||
BUILD_NUM="1"
|
BUILD_NUM="1"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ start() {
|
|||||||
echo " FEHLER: Binary nicht gefunden: $DAEMON"
|
echo " FEHLER: Binary nicht gefunden: $DAEMON"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
# Config-Verzeichnis sicherstellen (für API-Key)
|
||||||
|
mkdir -p /boot/config/plugins/mua
|
||||||
# Server starten (detached)
|
# Server starten (detached)
|
||||||
MUA_PORT=$PORT nohup "$DAEMON" >> "$LOGFILE" 2>&1 &
|
MUA_PORT=$PORT nohup "$DAEMON" >> "$LOGFILE" 2>&1 &
|
||||||
echo $! > "$PIDFILE"
|
echo $! > "$PIDFILE"
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* MUA — Mikes Unraid Agent
|
||||||
|
* auth.ts — API-Key-Auth + Tool-Filter (Config-Management)
|
||||||
|
*
|
||||||
|
* Config-Datei: /boot/config/plugins/mua/mua.conf (chmod 600)
|
||||||
|
* Format (INI):
|
||||||
|
* MUA_API_KEY=<64 hex chars>
|
||||||
|
* MUA_ENABLED_TOOLS=all | <comma-separated tool names>
|
||||||
|
*
|
||||||
|
* Auth-Flow:
|
||||||
|
* Client sendet: Authorization: Bearer ***
|
||||||
|
* Server prüft: timing-safe comparison (timing-attack-sicher)
|
||||||
|
* Fehlschlag: 401 Unauthorized
|
||||||
|
*
|
||||||
|
* /health bleibt ohne Auth (Monitoring).
|
||||||
|
* /mcp (POST + DELETE) erfordert gültiges Token.
|
||||||
|
*
|
||||||
|
* Tool-Filter:
|
||||||
|
* MUA_ENABLED_TOOLS=all → alle Tools aktiv
|
||||||
|
* MUA_ENABLED_TOOLS=a,b,c → nur diese Tools (tools/list + tools/call)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const CONFIG_DIR = process.env["MUA_CONFIG_DIR"] ?? "/boot/config/plugins/mua";
|
||||||
|
const CONFIG_FILE = `${CONFIG_DIR}/mua.conf`;
|
||||||
|
|
||||||
|
// ── Config-Struktur ─────────────────────────────────────────────────────
|
||||||
|
interface MUAConfig {
|
||||||
|
apiKey: string;
|
||||||
|
enabledTools: string[]; // leer = alle aktiv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config laden ────────────────────────────────────────────────────────
|
||||||
|
let cachedConfig: MUAConfig | null = null;
|
||||||
|
|
||||||
|
function parseConfig(content: string): MUAConfig {
|
||||||
|
const cfg: MUAConfig = { apiKey: "", enabledTools: [] };
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("#") || trimmed === "") continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1).trim();
|
||||||
|
if (key === "MUA_API_KEY") {
|
||||||
|
cfg.apiKey = value;
|
||||||
|
} else if (key === "MUA_ENABLED_TOOLS") {
|
||||||
|
if (value === "all" || value === "") {
|
||||||
|
cfg.enabledTools = [];
|
||||||
|
} else {
|
||||||
|
cfg.enabledTools = value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(): MUAConfig {
|
||||||
|
if (cachedConfig) return cachedConfig;
|
||||||
|
|
||||||
|
// 1. Env-Override (für Testing / Docker)
|
||||||
|
const envToken = process.env["MUA_API_KEY"];
|
||||||
|
if (envToken && envToken.length > 0) {
|
||||||
|
cachedConfig = { apiKey: envToken, enabledTools: [] };
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Aus Config-Datei laden
|
||||||
|
try {
|
||||||
|
const content = readFileSync(CONFIG_FILE, "utf-8");
|
||||||
|
const cfg = parseConfig(content);
|
||||||
|
if (cfg.apiKey.length > 0) {
|
||||||
|
cachedConfig = cfg;
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Datei nicht vorhanden — generiere neue Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Neue Config generieren
|
||||||
|
const newToken = randomBytes(32).toString("hex"); // 64 hex chars
|
||||||
|
cachedConfig = { apiKey: newToken, enabledTools: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
writeConfigFile(cachedConfig);
|
||||||
|
console.log(`[MUA] Neue Config generiert: ${CONFIG_FILE}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MUA] WARNUNG: Config konnte nicht gespeichert werden: ${String(e)}`);
|
||||||
|
console.error(`[MUA] API-Key (nur in diesem Log): ${newToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config schreiben ────────────────────────────────────────────────────
|
||||||
|
function writeConfigFile(cfg: MUAConfig): void {
|
||||||
|
const toolsLine =
|
||||||
|
cfg.enabledTools.length === 0
|
||||||
|
? "MUA_ENABLED_TOOLS=all"
|
||||||
|
: `MUA_ENABLED_TOOLS=${cfg.enabledTools.join(",")}`;
|
||||||
|
const content = [
|
||||||
|
"# MUA Configuration",
|
||||||
|
"# API-Key für MCP-HTTP-Endpunkt (Port 3002)",
|
||||||
|
"# Format: Authorization: Bearer ***",
|
||||||
|
`MUA_API_KEY=${cfg.apiKey}`,
|
||||||
|
"# Aktive Tools (all = alle, oder kommagetrennte Tool-Namen)",
|
||||||
|
toolsLine,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(CONFIG_FILE, content, { mode: 0o600 });
|
||||||
|
chmodSync(CONFIG_FILE, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(cfg: MUAConfig): void {
|
||||||
|
cachedConfig = cfg;
|
||||||
|
writeConfigFile(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Key ─────────────────────────────────────────────────────────────
|
||||||
|
export function getApiKey(): string {
|
||||||
|
return loadConfig().apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiKey(newKey: string): void {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
cfg.apiKey = newKey;
|
||||||
|
saveConfig(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiKey(): string {
|
||||||
|
const newKey = randomBytes(32).toString("hex");
|
||||||
|
setApiKey(newKey);
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool-Filter ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Liefert die Liste aktiver Tool-Namen.
|
||||||
|
* Leer-Array = alle Tools aktiv.
|
||||||
|
*/
|
||||||
|
export function getEnabledTools(): string[] {
|
||||||
|
return loadConfig().enabledTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die aktiven Tools.
|
||||||
|
* Leer-Array = alle Tools aktiv.
|
||||||
|
*/
|
||||||
|
export function setEnabledTools(names: string[]): void {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
cfg.enabledTools = names;
|
||||||
|
saveConfig(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob ein Tool aktiv ist.
|
||||||
|
* true = aktiv, false = deaktiviert.
|
||||||
|
*/
|
||||||
|
export function isToolEnabled(toolName: string): boolean {
|
||||||
|
const enabled = getEnabledTools();
|
||||||
|
if (enabled.length === 0) return true; // alle aktiv
|
||||||
|
return enabled.includes(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timing-safe Token-Check ─────────────────────────────────────────────
|
||||||
|
export function checkAuth(req: Request): boolean {
|
||||||
|
const token = getApiKey();
|
||||||
|
const authHeader = req.headers.get("authorization") ?? "";
|
||||||
|
|
||||||
|
// Format: "Bearer <token>"
|
||||||
|
if (!authHeader.startsWith("Bearer ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = authHeader.slice(7).trim();
|
||||||
|
|
||||||
|
// Timing-safe comparison (verhindert Timing-Attacks)
|
||||||
|
if (provided.length !== token.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < token.length; i++) {
|
||||||
|
diff |= token.charCodeAt(i) ^ provided.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token für WebGUI-Tab (nur lesbar) ───────────────────────────────────
|
||||||
|
export function getTokenForDisplay(): string {
|
||||||
|
return getApiKey();
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net";
|
|||||||
|
|
||||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||||
export const MUA_SERVER_NAME = "mua";
|
export const MUA_SERVER_NAME = "mua";
|
||||||
export const MUA_VERSION = "2026.08.18.r001";
|
export const MUA_VERSION = "2026.08.18.r002";
|
||||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||||
|
|
||||||
|
|||||||
+96
-3
@@ -19,6 +19,8 @@ import {
|
|||||||
MUA_PROTOCOL_VERSION,
|
MUA_PROTOCOL_VERSION,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import { TOOLS, toolByName } from "./tools";
|
import { TOOLS, toolByName } from "./tools";
|
||||||
|
import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
const PORT = Number(process.env["MUA_PORT"] ?? 3002);
|
const PORT = Number(process.env["MUA_PORT"] ?? 3002);
|
||||||
const HOST = process.env["MUA_HOST"] ?? "0.0.0.0";
|
const HOST = process.env["MUA_HOST"] ?? "0.0.0.0";
|
||||||
@@ -81,9 +83,11 @@ async function handleMcpRequest(
|
|||||||
// ── tools/list ────────────────────────────────────────────────────────
|
// ── tools/list ────────────────────────────────────────────────────────
|
||||||
if (method === "tools/list") {
|
if (method === "tools/list") {
|
||||||
if (sessionId) touchSession(sessionId);
|
if (sessionId) touchSession(sessionId);
|
||||||
|
// Tool-Filter: nur aktive Tools anzeigen
|
||||||
|
const activeTools = TOOLS.filter((t) => isToolEnabled(t.name));
|
||||||
return {
|
return {
|
||||||
response: rpcResult(id, {
|
response: rpcResult(id, {
|
||||||
tools: TOOLS.map((t) => ({
|
tools: activeTools.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
inputSchema: t.inputSchema,
|
inputSchema: t.inputSchema,
|
||||||
@@ -108,6 +112,16 @@ async function handleMcpRequest(
|
|||||||
sessionId: sessionId ?? "",
|
sessionId: sessionId ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Tool-Filter: deaktivierte Tools ablehnen
|
||||||
|
if (!isToolEnabled(toolName)) {
|
||||||
|
return {
|
||||||
|
response: rpcResult(id, {
|
||||||
|
content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }],
|
||||||
|
isError: true,
|
||||||
|
}),
|
||||||
|
sessionId: sessionId ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const text = await tool.handler(args);
|
const text = await tool.handler(args);
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
@@ -179,7 +193,7 @@ const server = Bun.serve({
|
|||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health-Check ────────────────────────────────────────────────────
|
// ── Health-Check (ohne Auth) ────────────────────────────────────────
|
||||||
if (path === "/health") {
|
if (path === "/health") {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
@@ -187,12 +201,33 @@ const server = Bun.serve({
|
|||||||
server: MUA_SERVER_NAME,
|
server: MUA_SERVER_NAME,
|
||||||
version: MUA_VERSION,
|
version: MUA_VERSION,
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
auth: "required",
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
{ headers: corsHeaders },
|
{ headers: corsHeaders },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ─────────────
|
||||||
|
if (path === "/mcp" || path === "/") {
|
||||||
|
if (!checkAuth(req)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: null,
|
||||||
|
error: { code: -32001, message: "Unauthorized: missing or invalid API key" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
"Mcp-Session-Id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── MCP-Endpunkt ────────────────────────────────────────────────────
|
// ── MCP-Endpunkt ────────────────────────────────────────────────────
|
||||||
if (path === "/mcp" || path === "/") {
|
if (path === "/mcp" || path === "/") {
|
||||||
// POST: JSON-RPC Request
|
// POST: JSON-RPC Request
|
||||||
@@ -261,8 +296,66 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Config-Server (nur localhost:127.0.0.1:3003) ───────────────────────
|
||||||
|
// Separater Server, nur von localhost erreichbar (WebGUI auf demselben Host).
|
||||||
|
// Kein Header-Spoofing, kein File-Permission-Problem.
|
||||||
|
const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3003);
|
||||||
|
const configServer = Bun.serve({
|
||||||
|
port: CONFIG_PORT,
|
||||||
|
hostname: "127.0.0.1", // nur localhost
|
||||||
|
idleTimeout: 30,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (path !== "/config") {
|
||||||
|
return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: Config lesen
|
||||||
|
if (method === "GET") {
|
||||||
|
return Response.json({
|
||||||
|
apiKey: getApiKey(),
|
||||||
|
enabledTools: getEnabledTools(),
|
||||||
|
allTools: TOOLS.map((t) => t.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Config schreiben
|
||||||
|
if (method === "POST") {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(await req.text());
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// "generate": neuen API-Key generieren
|
||||||
|
if (body["generate"] === true) {
|
||||||
|
setApiKey(randomBytes(32).toString("hex"));
|
||||||
|
} else if (typeof body["apiKey"] === "string" && (body["apiKey"] as string).length > 0) {
|
||||||
|
setApiKey(body["apiKey"] as string);
|
||||||
|
}
|
||||||
|
if (Array.isArray(body["enabledTools"])) {
|
||||||
|
const tools = (body["enabledTools"] as unknown[])
|
||||||
|
.filter((t): t is string => typeof t === "string");
|
||||||
|
setEnabledTools(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
apiKey: getApiKey(),
|
||||||
|
enabledTools: getEnabledTools(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP Streamable HTTP)`,
|
`[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP) + 127.0.0.1:${CONFIG_PORT} (config)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
Reference in New Issue
Block a user