Refactor: PHP-Built-in-Server durch TypeScript-Binary (Bun Runtime) ersetzt
- src/*.ts: Port von mcp/*.php (helpers, tools, server) - Bun.serve() statt php -S — production-grade HTTP, POST funktioniert - dist/mua-1.0.0-x86_64-1.txz: Slackware-Paket (Binary + rc.mua + mua.page + PHP-Helper) - plugin/mua.plg: .txz-Installation via upgradepkg - scripts/rc.mua: Binary statt php -S - scripts/mua.page: Binary-Info statt PHP-Version - scripts/package.sh: .txz-Builder Fix: MCP-POST-Endpunkt lieferte leeren Body (php -S Limitation). Bun.serve() löst das Problem.
This commit is contained in:
+497
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* helpers.ts — Command execution, JSON-RPC, read operations
|
||||
*
|
||||
* Portiert von mcp/helpers.php. Alle Funktionen laufen LOKAL auf dem
|
||||
* Unraid-Host. Read-Operationen via `docker` CLI. Write-Operationen
|
||||
* werden an den PHP-Helper delegiert (Bun.spawn), da diese Unraids
|
||||
* PHP-Klassen (DockerClient) benötigen.
|
||||
*/
|
||||
|
||||
import { spawn } from "bun";
|
||||
import { createConnection, type Socket } from "net";
|
||||
|
||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||
export const MUA_SERVER_NAME = "mua";
|
||||
export const MUA_VERSION = "1.0.0";
|
||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||
|
||||
// ── Command Execution ───────────────────────────────────────────────────
|
||||
export interface CmdResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führe einen lokalen Shell-Befehl aus (via /bin/sh -c).
|
||||
* Timeout in Sekunden.
|
||||
*/
|
||||
export async function runLocal(
|
||||
label: string,
|
||||
cmd: string,
|
||||
timeoutSec = 60,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const proc = spawn(["/bin/sh", "-c", cmd], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
let result = stdout.trim();
|
||||
if (result === "" && stderr.trim() !== "") result = stderr.trim();
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new Error(`run_local failed for ${label}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker-Befehl ausführen (kompakt).
|
||||
*/
|
||||
export async function dockerExec(cmd: string, timeoutSec = 60): Promise<string> {
|
||||
return runLocal("docker", `/usr/bin/docker ${cmd} 2>&1`, timeoutSec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegiert eine Write-Operation an den PHP-Helper.
|
||||
*/
|
||||
export async function runPhpHelper(
|
||||
action: string,
|
||||
...args: string[]
|
||||
): Promise<string> {
|
||||
const helper = PHP_HELPER;
|
||||
if (!Bun.file(helper).exists()) {
|
||||
throw new Error(`PHP helper not found: ${helper}`);
|
||||
}
|
||||
const escaped = [action, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
return runLocal(`php_helper:${action}`, `/usr/bin/php ${helper} ${escaped}`, 300);
|
||||
}
|
||||
|
||||
// ── Validierung ─────────────────────────────────────────────────────────
|
||||
export function validateName(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) {
|
||||
throw new Error(`Invalid ${field}: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── JSON-Lines ──────────────────────────────────────────────────────────
|
||||
export function jsonLines(text: string): Record<string, unknown>[] {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") continue;
|
||||
try {
|
||||
result.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Host-Adressen ───────────────────────────────────────────────────────
|
||||
export interface HostAddresses {
|
||||
ipv4: string;
|
||||
ipv6: string;
|
||||
public_ipv6: string;
|
||||
}
|
||||
|
||||
export function hostAddresses(raw: string): HostAddresses {
|
||||
const result: HostAddresses = { ipv4: "", ipv6: "", public_ipv6: "" };
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (!Array.isArray(data)) return result;
|
||||
|
||||
for (const iface of data as Record<string, unknown>[]) {
|
||||
const ifname = (iface["ifname"] as string) ?? "";
|
||||
if (ifname === "lo") continue;
|
||||
const addrInfo = (iface["addr_info"] as Record<string, unknown>[]) ?? [];
|
||||
for (const addr of addrInfo) {
|
||||
const local = (addr["local"] as string) ?? "";
|
||||
const family = (addr["family"] as string) ?? "";
|
||||
if (family === "inet") {
|
||||
if (local !== "127.0.0.1" && local !== "0.0.0.0" && result.ipv4 === "") {
|
||||
result.ipv4 = local;
|
||||
}
|
||||
} else if (family === "inet6") {
|
||||
if (local.startsWith("fe80") && result.ipv6 === "") {
|
||||
result.ipv6 = local;
|
||||
}
|
||||
if (
|
||||
!local.startsWith("fe80") &&
|
||||
!local.startsWith("fd") &&
|
||||
!local.startsWith("fc") &&
|
||||
local !== "::1" &&
|
||||
result.public_ipv6 === ""
|
||||
) {
|
||||
result.public_ipv6 = local;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── TCP Probe ───────────────────────────────────────────────────────────
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function tcpProbe(
|
||||
host: string,
|
||||
port: number,
|
||||
family: 4 | 6,
|
||||
timeoutSec: number,
|
||||
): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
if (host === "") {
|
||||
resolve({ reachable: false, error: "no host address" });
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
const socket: Socket = createConnection({
|
||||
host,
|
||||
port,
|
||||
family,
|
||||
timeout: timeoutSec * 1000,
|
||||
});
|
||||
const done = (result: ProbeResult) => {
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.on("connect", () => {
|
||||
const elapsed = Date.now() - start;
|
||||
done({ reachable: true, latency_ms: Math.round(elapsed * 10) / 10 });
|
||||
});
|
||||
socket.on("timeout", () => {
|
||||
done({ reachable: false, error: "timeout" });
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
done({ reachable: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Log Sanitize ────────────────────────────────────────────────────────
|
||||
export function sanitizeLogOutput(text: string, maxChars = 50000): string {
|
||||
// Entferne ANSI-Escape-Sequenzen
|
||||
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, "");
|
||||
// Begrenze Länge
|
||||
if (result.length > maxChars) {
|
||||
result = "... [truncated] ..." + result.slice(-maxChars);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Compact Container Inspect ───────────────────────────────────────────
|
||||
export async function compactContainerInspect(container: string): Promise<string> {
|
||||
const raw = await dockerExec(`inspect --type container -- ${container}`);
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
if (Array.isArray(data)) data = data[0];
|
||||
const d = data as Record<string, any>;
|
||||
const compact = {
|
||||
Id: (d.Id ?? "").slice(0, 12),
|
||||
Name: d.Name ?? "",
|
||||
State: {
|
||||
Status: d.State?.Status ?? "",
|
||||
Running: d.State?.Running ?? false,
|
||||
Pid: d.State?.Pid ?? 0,
|
||||
ExitCode: d.State?.ExitCode ?? 0,
|
||||
},
|
||||
Image: d.Config?.Image ?? "",
|
||||
NetworkMode: d.HostConfig?.NetworkMode ?? "",
|
||||
Ports: d.NetworkSettings?.Ports ?? [],
|
||||
Env: d.Config?.Env ?? [],
|
||||
Mounts: (d.Mounts ?? []).map((m: any) => ({
|
||||
Type: m.Type ?? "",
|
||||
Source: m.Source ?? "",
|
||||
Destination: m.Destination ?? "",
|
||||
})),
|
||||
RestartCount: d.RestartCount ?? 0,
|
||||
Created: d.Created ?? "",
|
||||
};
|
||||
return JSON.stringify(compact);
|
||||
}
|
||||
|
||||
// ── Container Runtime Summary ───────────────────────────────────────────
|
||||
export async function containerRuntimeSummary(): Promise<string> {
|
||||
const ps = await dockerExec(`ps -a --format '{{json .}}'`);
|
||||
const containers = jsonLines(ps);
|
||||
|
||||
const runningIds = containers
|
||||
.map((c) => (c["ID"] as string) ?? "")
|
||||
.filter((id) => id !== "");
|
||||
|
||||
const stats: Record<string, Record<string, unknown>> = {};
|
||||
if (runningIds.length > 0) {
|
||||
const statsRaw = await dockerExec(`stats --no-stream --format '{{json .}}'`);
|
||||
for (const s of jsonLines(statsRaw)) {
|
||||
stats[(s["ID"] as string) ?? ""] = s;
|
||||
}
|
||||
}
|
||||
|
||||
const result = containers.map((c) => {
|
||||
const id = (c["ID"] as string) ?? "";
|
||||
const entry: Record<string, unknown> = {
|
||||
id: id.slice(0, 12),
|
||||
name: c["Names"] ?? "",
|
||||
image: c["Image"] ?? "",
|
||||
status: c["Status"] ?? "",
|
||||
state: c["State"] ?? "",
|
||||
ports: c["Ports"] ?? "",
|
||||
};
|
||||
if (stats[id]) {
|
||||
entry.cpu_percent = stats[id]["CPUPerc"] ?? "";
|
||||
entry.mem_usage = stats[id]["MemUsage"] ?? "";
|
||||
entry.mem_percent = stats[id]["MemPerc"] ?? "";
|
||||
entry.net_io = stats[id]["NetIO"] ?? "";
|
||||
entry.block_io = stats[id]["BlockIO"] ?? "";
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
container_count: result.length,
|
||||
containers: result,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Compact Network Inventory ───────────────────────────────────────────
|
||||
export async function compactNetworkInventory(): Promise<string> {
|
||||
const networksRaw = await dockerExec(`network ls --no-trunc --format '{{json .}}'`);
|
||||
const networks = jsonLines(networksRaw);
|
||||
|
||||
const result = [];
|
||||
for (const n of networks) {
|
||||
const entry: Record<string, unknown> = {
|
||||
name: n["Name"] ?? "",
|
||||
id: ((n["ID"] as string) ?? "").slice(0, 12),
|
||||
driver: n["Driver"] ?? "",
|
||||
scope: n["Scope"] ?? "",
|
||||
};
|
||||
const inspect = await dockerExec(
|
||||
`network inspect --format '{{len .Containers}}' ${n["Name"]}`,
|
||||
);
|
||||
entry.container_count = parseInt(inspect.trim(), 10) || 0;
|
||||
result.push(entry);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
network_count: result.length,
|
||||
networks: result,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Analyze Container Logs ──────────────────────────────────────────────
|
||||
export async function analyzeContainerLogs(
|
||||
severity: string,
|
||||
container: string | null,
|
||||
since: string,
|
||||
scanTail: number,
|
||||
maxResults: number,
|
||||
): Promise<string> {
|
||||
const severityLevels: Record<string, string[]> = {
|
||||
error: ["error", "fatal", "panic", "exception", "traceback", "critical"],
|
||||
warn: ["warn", "warning", "deprecated"],
|
||||
info: ["info", "started", "listening", "ready"],
|
||||
};
|
||||
const patterns = severityLevels[severity] ?? severityLevels["error"];
|
||||
const regex = new RegExp(`(${patterns.join("|")})`, "i");
|
||||
|
||||
let logCmd = `logs --timestamps --since ${since} --tail ${scanTail}`;
|
||||
if (container) logCmd += ` ${container}`;
|
||||
const raw = await dockerExec(logCmd, 120);
|
||||
const lines = raw.split("\n");
|
||||
|
||||
const matches: { pattern: string; line: string }[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
for (const line of lines) {
|
||||
const m = line.match(regex);
|
||||
if (m) {
|
||||
const key = m[1].toLowerCase();
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
if (matches.length < maxResults) {
|
||||
matches.push({ pattern: m[1], line: line.trim().slice(0, 300) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
severity,
|
||||
container,
|
||||
since,
|
||||
scan_tail: scanTail,
|
||||
total_matches: Object.values(counts).reduce((a, b) => a + b, 0),
|
||||
pattern_counts: counts,
|
||||
sample_matches: matches,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dualstack LAN Probe ─────────────────────────────────────────────────
|
||||
export async function probeDualstack(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutSec: number,
|
||||
): Promise<string> {
|
||||
const [ipv4, ipv6] = await Promise.all([
|
||||
tcpProbe(host, port, 4, timeoutSec),
|
||||
tcpProbe(host, port, 6, timeoutSec),
|
||||
]);
|
||||
return JSON.stringify({ host, port, ipv4, ipv6 });
|
||||
}
|
||||
|
||||
// ── Audit All TCP Endpoints ─────────────────────────────────────────────
|
||||
export async function auditAllTcpEndpoints(
|
||||
timeoutSec: number,
|
||||
includeAllEndpoints = false,
|
||||
): Promise<string> {
|
||||
const inventoryCmd =
|
||||
'ids=$(docker ps -aq); [ -z "$ids" ] || docker inspect --type container --format \'{"ID":{{json .Id}},"Name":{{json .Name}},"Image":{{json .Config.Image}},"Status":{{json .State.Status}},"Running":{{json .State.Running}},"Health":{{json (index .State "Health")}},"NetworkMode":{{json .HostConfig.NetworkMode}},"ExposedPorts":{{json (index .Config "ExposedPorts")}},"Ports":{{json .NetworkSettings.Ports}}}\' $ids';
|
||||
const containersRaw = await runLocal("audit", inventoryCmd, 60);
|
||||
const containers = jsonLines(containersRaw);
|
||||
|
||||
const hostCmd =
|
||||
"printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup";
|
||||
const hostRaw = await runLocal("audit", hostCmd, 30);
|
||||
const addrs = hostAddresses(hostRaw);
|
||||
|
||||
const inactive: { container: string; status: string }[] = [];
|
||||
const noTcp: { container: string; mode: string }[] = [];
|
||||
const udp: { container: string; container_port: string }[] = [];
|
||||
const endpoints: {
|
||||
container: string;
|
||||
mode: string;
|
||||
container_port: string;
|
||||
host_port: number;
|
||||
ipv4_reachable?: boolean;
|
||||
ipv6_reachable?: boolean;
|
||||
classification?: string;
|
||||
}[] = [];
|
||||
const endpointKeys = new Set<string>();
|
||||
|
||||
for (const item of containers) {
|
||||
const name = ((item["Name"] as string) ?? "").replace(/^\//, "");
|
||||
if (!(item["Running"] as boolean)) {
|
||||
inactive.push({ container: name, status: (item["Status"] as string) ?? "" });
|
||||
continue;
|
||||
}
|
||||
const mode = (item["NetworkMode"] as string) ?? "unknown";
|
||||
let foundTcp = false;
|
||||
const ports = (item["Ports"] as Record<string, { HostPort?: string }[]>) ?? {};
|
||||
for (const [containerPort, bindings] of Object.entries(ports)) {
|
||||
const protocol = containerPort.split("/").pop() ?? "";
|
||||
if (protocol === "udp" && bindings.length > 0) {
|
||||
udp.push({ container: name, container_port: containerPort });
|
||||
continue;
|
||||
}
|
||||
if (protocol !== "tcp" || bindings.length === 0) continue;
|
||||
for (const binding of bindings) {
|
||||
if (binding.HostPort) {
|
||||
const key = `${name}:${binding.HostPort}:${containerPort}`;
|
||||
if (!endpointKeys.has(key)) {
|
||||
endpointKeys.add(key);
|
||||
endpoints.push({
|
||||
container: name,
|
||||
mode,
|
||||
container_port: containerPort,
|
||||
host_port: parseInt(binding.HostPort, 10),
|
||||
});
|
||||
}
|
||||
foundTcp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode !== "host" && !foundTcp) {
|
||||
noTcp.push({ container: name, mode });
|
||||
}
|
||||
}
|
||||
|
||||
const classifications = { dualstack: 0, "ipv4-only": 0, "ipv6-only": 0, unreachable: 0 };
|
||||
for (const ep of endpoints) {
|
||||
const [v4, v6] = await Promise.all([
|
||||
tcpProbe(addrs.ipv4, ep.host_port, 4, timeoutSec),
|
||||
tcpProbe(addrs.ipv6, ep.host_port, 6, timeoutSec),
|
||||
]);
|
||||
ep.ipv4_reachable = v4.reachable;
|
||||
ep.ipv6_reachable = v6.reachable;
|
||||
ep.classification =
|
||||
v4.reachable && v6.reachable
|
||||
? "dualstack"
|
||||
: v4.reachable
|
||||
? "ipv4-only"
|
||||
: v6.reachable
|
||||
? "ipv6-only"
|
||||
: "unreachable";
|
||||
classifications[ep.classification as keyof typeof classifications]++;
|
||||
}
|
||||
|
||||
const issueEndpoints = endpoints.filter((e) => e.classification !== "dualstack");
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
schema_version: "2.0",
|
||||
targets: { ipv4: addrs.ipv4, lan_ipv6: addrs.ipv6 },
|
||||
counts: {
|
||||
containers_total: containers.length,
|
||||
tcp_endpoints_total: endpoints.length,
|
||||
tcp_dualstack: classifications.dualstack,
|
||||
tcp_ipv4_only: classifications["ipv4-only"],
|
||||
tcp_ipv6_only: classifications["ipv6-only"],
|
||||
tcp_unreachable: classifications.unreachable,
|
||||
tcp_problem_endpoints: issueEndpoints.length,
|
||||
},
|
||||
problem_endpoints_only: issueEndpoints,
|
||||
inactive_containers: inactive,
|
||||
running_without_published_tcp: noTcp,
|
||||
task_complete: true,
|
||||
};
|
||||
if (includeAllEndpoints) {
|
||||
result.all_tcp_endpoints = endpoints;
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
// ── Host State ──────────────────────────────────────────────────────────
|
||||
export async function hostState(): Promise<string> {
|
||||
return runLocal(
|
||||
"host_state",
|
||||
"printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- IPv4 routes ---'; ip -j -4 route show; printf '%s\\n' '--- IPv6 routes ---'; ip -j -6 route show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup",
|
||||
30,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Connection Test ─────────────────────────────────────────────────────
|
||||
export async function connectionTest(): Promise<string> {
|
||||
return runLocal(
|
||||
"connection_test",
|
||||
"id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version",
|
||||
15,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user