/** * 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"; import { createHash, randomUUID } from "node:crypto"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; export const MUA_VERSION = "2026.08.21.r012"; export const MUA_PROTOCOL_VERSION = "2025-03-26"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-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 { 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)}`); } } /** * Shell-Befehl ausführen und strukturiertes Ergebnis liefern * (exit_code + stdout + stderr). Für das unraid_system_shell-Tool. * Liefert JSON, damit der Agent Exit-Code und beides Streams sieht. */ export async function runShell(cmd: string, timeoutSec = 60): Promise { 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); return JSON.stringify({ exit_code: code, stdout: sanitizeLogOutput(stdout.trim(), 100000), stderr: sanitizeLogOutput(stderr.trim(), 20000), }); } catch (e) { throw new Error(`run_shell failed: ${String(e)}`); } } async function runArgv( label: string, argv: string[], timeoutSec = 60, maxStdout = 100_000, maxStderr = 20_000, ): Promise { try { const proc = spawn(argv, { stdout: "pipe", stderr: "pipe", cwd: "/" }); const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); const [stdout, stderr, code] = await Promise.all([ readStreamLimited(proc.stdout, maxStdout), readStreamLimited(proc.stderr, maxStderr), proc.exited, ]); clearTimeout(timeout); if (code !== 0) { throw new Error(`${label} failed (exit ${code}): ${sanitizeLogOutput(stderr.text || stdout.text, maxStderr)}`); } return sanitizeLogOutput(stdout.text.trim(), maxStdout); } catch (error) { throw new Error(`${label} failed: ${String(error)}`); } } const READ_ONLY_PROGRAMS = new Set([ "cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep", "head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount", "ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname", "uptime", "wc", "whoami", ]); async function readStreamLimited( stream: ReadableStream, maxBytes: number, ): Promise<{ text: string; truncated: boolean }> { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let kept = 0; let truncated = false; while (true) { const { done, value } = await reader.read(); if (done) break; const available = Math.max(0, maxBytes - kept); if (kept < maxBytes) { const slice = value.subarray(0, available); if (slice.length > 0) chunks.push(slice); kept += slice.length; } if (value.length > available) truncated = true; } const combined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return { text: new TextDecoder().decode(combined), truncated }; } /** * Führt ausschließlich freigegebene Leseprogramme direkt als argv aus. * Kein /bin/sh, keine Pipes, Umleitungen, Substitutionen oder Verkettungen. */ export async function runReadOnlyCommand( program: string, args: string[], timeoutSec = 30, ): Promise { if (!READ_ONLY_PROGRAMS.has(program)) { throw new Error(`Program is not allowed in read-only mode: ${program}`); } if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) { throw new Error("Invalid or excessive arguments"); } const lowered = args.map((arg) => arg.toLowerCase()); const reject = (message: string) => { throw new Error(message); }; if (["hostname", "whoami", "uptime"].includes(program) && args.length > 0) { reject(`${program} does not accept arguments in read-only mode`); } if (program === "date") { const safeDateFlags = new Set(["-u", "--utc", "-r", "--reference", "--rfc-email", "-d", "--date"]); for (let i = 0; i < args.length; i++) { const arg = lowered[i]; if (i > 0 && ["-d", "--date", "-r", "--reference"].includes(lowered[i - 1])) continue; if (arg.startsWith("+") || safeDateFlags.has(arg) || arg.startsWith("--date=") || arg.startsWith("--iso-8601") || arg.startsWith("--rfc-3339")) continue; reject("Only date display and parsing options are allowed"); } } if (program === "dmesg" && args.some((a) => ["-C", "-D", "-E", "-n"].includes(a) || ["--clear", "--read-clear", "--console-off", "--console-on", "--console-level"].includes(a.toLowerCase()) )) { reject("Changing or clearing the kernel log is not allowed"); } if (program === "ss" && lowered.some((a) => a === "-k" || a === "--kill")) { reject("Killing sockets is not allowed"); } if (program === "mount" && args.length > 0) { reject("mount is display-only and accepts no arguments in read-only mode"); } if (program === "ip") { const safeObjects = new Set(["address", "addr", "route", "link", "neigh", "neighbor"]); const mutating = new Set(["add", "append", "change", "delete", "del", "flush", "replace", "set"]); if (args.length === 0 || !safeObjects.has(lowered[0]) || lowered.some((a) => mutating.has(a))) { reject("Only read-only ip objects and show/list operations are allowed"); } } if (program === "find") { const mutatingFind = ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"]; if (lowered.some((a) => mutatingFind.some((blocked) => a === blocked || a.startsWith(blocked)))) { reject("Mutating find actions are not allowed"); } } try { const proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" }); const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); const [stdout, stderr, code] = await Promise.all([ readStreamLimited(proc.stdout, 100_000), readStreamLimited(proc.stderr, 20_000), proc.exited, ]); clearTimeout(timeout); return JSON.stringify({ exit_code: code, stdout: sanitizeLogOutput(stdout.text.trim(), 100_000), stderr: sanitizeLogOutput(stderr.text.trim(), 20_000), truncated: stdout.truncated || stderr.truncated, mode: "read-only", }); } catch (e) { throw new Error(`read-only command failed: ${String(e)}`); } } /** * Docker-Befehl ausführen (kompakt). */ export async function dockerExec(cmd: string, timeoutSec = 60): Promise { 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 { const helper = PHP_HELPER; if (!(await Bun.file(helper).exists())) { throw new Error(`PHP helper not found: ${helper}`); } return runArgv(`php_helper:${action}`, ["/usr/bin/php", helper, action, ...args], 300); } export async function runStatusHelper(action: string, ...args: string[]): Promise { if (!(await Bun.file(STATUS_HELPER).exists())) { throw new Error(`Status helper not found: ${STATUS_HELPER}`); } return runArgv(`status_helper:${action}`, ["/usr/bin/php", STATUS_HELPER, action, ...args], 60); } // ── 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[] { const result: Record[] = []; 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[]) { const ifname = (iface["ifname"] as string) ?? ""; if (ifname === "lo") continue; const addrInfo = (iface["addr_info"] as Record[]) ?? []; 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 { 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, ""); // 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); } return result; } // ── Compact Container Inspect ─────────────────────────────────────────── export async function compactContainerInspect(container: string): Promise { 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; 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 ?? [], // 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 ?? "", Destination: m.Destination ?? "", })), RestartCount: d.RestartCount ?? 0, Created: d.Created ?? "", }; return JSON.stringify(compact); } // ── Container Runtime Summary ─────────────────────────────────────────── export async function containerRuntimeSummary(): Promise { 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> = {}; 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 = { 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 { const networksRaw = await dockerExec(`network ls --no-trunc --format '{{json .}}'`); const networks = jsonLines(networksRaw); const result = []; for (const n of networks) { const entry: Record = { 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 { const severityLevels: Record = { 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"); // Ziel-Container: einzelner Container oder alle laufenden let targets: string[]; if (container) { targets = [container]; } else { const psRaw = await dockerExec("ps --format '{{.Names}}'", 30); targets = psRaw .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); } const matches: { pattern: string; line: string; container: string }[] = []; const counts: Record = {}; const containersWithMatches: Record = {}; for (const name of targets) { const logCmd = `logs --timestamps --since ${since} --tail ${scanTail} ${name}`; const raw = await dockerExec(logCmd, 120); let containerMatches = 0; for (const line of raw.split("\n")) { const m = line.match(regex); if (m) { const key = m[1].toLowerCase(); counts[key] = (counts[key] ?? 0) + 1; containerMatches++; if (matches.length < maxResults) { matches.push({ pattern: m[1], line: line.trim().slice(0, 300), container: name }); } } } if (containerMatches > 0) containersWithMatches[name] = containerMatches; } 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, containers_with_matches: containersWithMatches, sample_matches: matches, }); } // ── Dualstack LAN Probe ───────────────────────────────────────────────── export async function probeDualstack( host: string, port: number, timeoutSec: number, ): Promise { 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 { 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(); 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) ?? {}; 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 = { 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 { 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 { return runLocal( "connection_test", "id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version", 15, ); } // ── Community Applications ───────────────────────────────────────────── const CA_FEED_URL = "https://ca.unraid.net/assets/feed/applicationFeed.json"; const CA_CACHE_MS = 15 * 60 * 1000; interface CaConfig { "@attributes"?: Record; value?: unknown; } interface CaApp { Name?: unknown; Repository?: unknown; Network?: unknown; Privileged?: unknown; Overview?: unknown; CategoryList?: unknown; TemplateURL?: unknown; Repo?: unknown; Project?: unknown; Support?: unknown; Config?: unknown; downloads?: unknown; stars?: unknown; } let caCache: { loadedAt: number; updated?: unknown; apps: CaApp[] } | null = null; const caApprovalTickets = new Map(); function caText(value: unknown, max = 500): string { if (typeof value !== "string") return ""; return value .replace(/\[br\]/gi, " ") .replace(/\[(?:\/?(?:b|i|u|span|li|font|center|url)[^\]]*)\]/gi, " ") .replace(/ |&/gi, " ") .replace(/\s+/g, " ") .trim() .slice(0, max); } function caAppId(app: CaApp): string { return createHash("sha256").update(String(app.TemplateURL ?? "")).digest("hex").slice(0, 16); } async function caFeed(): Promise<{ updated?: unknown; apps: CaApp[] }> { if (caCache && Date.now() - caCache.loadedAt < CA_CACHE_MS) return caCache; const response = await fetch(CA_FEED_URL, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) throw new Error(`Community Applications feed returned HTTP ${response.status}`); const contentLength = Number(response.headers.get("content-length") ?? 0); if (contentLength > 40_000_000) throw new Error("Community Applications feed is unexpectedly large"); const raw = await response.text(); if (raw.length > 40_000_000) throw new Error("Community Applications feed is unexpectedly large"); const parsed = JSON.parse(raw) as Record; if (!Array.isArray(parsed["applist"])) throw new Error("Community Applications feed has an invalid schema"); const apps = (parsed["applist"] as CaApp[]).filter((app) => typeof app?.Name === "string" && typeof app?.Repository === "string" && typeof app?.TemplateURL === "string" && app.Repository !== "" && app.TemplateURL !== "" ); caCache = { loadedAt: Date.now(), updated: parsed["last_updated"], apps }; return caCache; } function caConfigSummary(app: CaApp): Record[] { if (!Array.isArray(app.Config)) return []; return (app.Config as CaConfig[]).slice(0, 64).map((config) => { const attrs = config?.["@attributes"] ?? {}; const target = caText(attrs["Target"], 200); const masked = String(attrs["Mask"] ?? "false").toLowerCase() === "true" || /(?:api[_-]?key|token|secret|password|passwd)/i.test(target); return { type: caText(attrs["Type"], 40), name: caText(attrs["Name"], 120), target, default: masked ? "[MASKED]" : caText(config.value ?? attrs["Default"], 500), required: String(attrs["Required"] ?? "false").toLowerCase() === "true", masked, description: caText(attrs["Description"], 300), }; }); } export async function searchCommunityApps(query: string, limit = 10): Promise { const needle = query.trim().toLowerCase(); if (needle.length < 2 || needle.length > 100) throw new Error("query must contain 2-100 characters"); limit = Math.max(1, Math.min(25, Math.floor(limit))); const feed = await caFeed(); const scored = feed.apps.map((app) => { const name = caText(app.Name, 200); const repo = caText(app.Repository, 300); const extra = `${caText(app.Overview, 1000)} ${caText(app.Repo, 200)}`.toLowerCase(); const lower = name.toLowerCase(); let score = 0; if (lower === needle) score += 100; else if (lower.startsWith(needle)) score += 60; else if (lower.includes(needle)) score += 40; if (repo.toLowerCase().includes(needle)) score += 20; if (extra.includes(needle)) score += 5; return { app, score, name, repo }; }).filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)) .slice(0, limit); return JSON.stringify({ schema_version: "1.0", source: "Unraid Community Applications official feed", feed_updated: feed.updated ?? null, query, result_count: scored.length, results: scored.map(({ app, name, repo }) => ({ app_id: caAppId(app), name, image: repo, network: caText(app.Network, 80), privileged: String(app.Privileged ?? "false").toLowerCase() === "true", categories: Array.isArray(app.CategoryList) ? app.CategoryList.slice(0, 12) : [], overview: caText(app.Overview, 500), template_repository: caText(app.Repo, 160), project: caText(app.Project, 500), support: caText(app.Support, 500), downloads: Number(app.downloads ?? 0), stars: Number(app.stars ?? 0), })), }); } async function caFindById(appId: string): Promise { if (!/^[a-f0-9]{16}$/.test(appId)) throw new Error("Invalid app_id"); const feed = await caFeed(); const app = feed.apps.find((candidate) => caAppId(candidate) === appId); if (!app) throw new Error("Community Applications entry no longer exists"); const url = new URL(String(app.TemplateURL)); if (url.protocol !== "https:") throw new Error("Only HTTPS Community Applications templates are accepted"); if (url.hostname === "localhost" || url.hostname.endsWith(".local")) throw new Error("Private template hosts are rejected"); return app; } function normalizeCaOverrides(value: unknown): Record { if (value === undefined || value === null) return {}; if (typeof value !== "object" || Array.isArray(value)) throw new Error("overrides must be an object"); const entries = Object.entries(value as Record); if (entries.length > 32) throw new Error("At most 32 overrides are allowed"); const normalized: Record = {}; for (const [key, item] of entries.sort(([a], [b]) => a.localeCompare(b))) { if (!/^[A-Za-z0-9_./:-]{1,200}$/.test(key)) throw new Error(`Invalid override target: ${key}`); if (typeof item !== "string" || item.length > 4096 || /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(item)) { throw new Error(`Invalid override value for ${key}`); } normalized[key] = item; } return normalized; } function caFingerprint(appId: string, containerName: string, overrides: Record, start: boolean): string { return createHash("sha256").update(JSON.stringify({ appId, containerName, overrides, start })).digest("hex"); } export async function previewCommunityAppInstall( appId: string, containerName: string, rawOverrides: unknown, start: boolean, ): Promise { containerName = validateName(containerName, "container_name"); const overrides = normalizeCaOverrides(rawOverrides); const app = await caFindById(appId); const configuration = caConfigSummary(app); const allowedTargets = new Set(configuration.map((item) => String(item["target"] ?? ""))); for (const target of Object.keys(overrides)) { if (!allowedTargets.has(target)) throw new Error(`Override target is not present in the CA template: ${target}`); } const names = (await dockerExec("ps -a --format '{{.Names}}'", 30)).split("\n").map((x) => x.trim()); if (names.includes(containerName)) throw new Error(`Container already exists: ${containerName}`); if (await Bun.file(`/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`).exists()) { throw new Error(`Unraid user template already exists: ${containerName}`); } const ticket = randomUUID(); const expiresAt = Date.now() + 10 * 60 * 1000; caApprovalTickets.set(ticket, { fingerprint: caFingerprint(appId, containerName, overrides, start), expiresAt }); for (const [key, value] of caApprovalTickets) { if (value.expiresAt < Date.now()) caApprovalTickets.delete(key); } return JSON.stringify({ schema_version: "1.0", action: "preview-only", app: { app_id: appId, name: caText(app.Name, 200), image: caText(app.Repository, 300) }, container_name: containerName, network: caText(app.Network, 80), privileged: String(app.Privileged ?? "false").toLowerCase() === "true", configuration, requested_overrides: Object.entries(overrides).map(([target, value]) => ({ target, value: /(?:api[_-]?key|token|secret|password|passwd)/i.test(target) ? "[REDACTED]" : value, })), start_after_install: start, writes_user_template: `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`, approval_ticket: ticket, approval_expires_in_seconds: 600, next_step: "Review this preview. Only after explicit user approval call unraid_ca_install with exactly the same app_id, container_name, overrides and start_after_install plus confirm=true and this approval_ticket.", }); } export async function installCommunityApp( appId: string, containerName: string, rawOverrides: unknown, start: boolean, confirm: boolean, ticket: string, ): Promise { containerName = validateName(containerName, "container_name"); const overrides = normalizeCaOverrides(rawOverrides); const approval = caApprovalTickets.get(ticket); const fingerprint = caFingerprint(appId, containerName, overrides, start); if (!confirm || !approval || approval.expiresAt < Date.now() || approval.fingerprint !== fingerprint) { throw new Error("Approval ticket missing, expired, or does not match this exact change. Run unraid_ca_install_preview, show its preview to the user, then repeat unchanged with confirm=true and the returned approval_ticket."); } caApprovalTickets.delete(ticket); const app = await caFindById(appId); const result = await runPhpHelper( "ca-install", String(app.TemplateURL), containerName, JSON.stringify(overrides), start ? "true" : "false", ); return JSON.stringify({ schema_version: "1.0", ok: true, app: caText(app.Name, 200), image: caText(app.Repository, 300), container_name: containerName, started: start, gui_managed: true, template: `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`, helper_result: result.slice(0, 3000), }); }