release r010 add Unraid health and Community Apps tools

This commit is contained in:
Mikei386 committed 2026-08-21 10:14:16 +02:00
1 parent e586b22b13
commit 771b8c43a1
14 files changed
+831 -33

No files matched your search

+275 -3
View File
@@ -10,12 +10,14 @@
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.r009";
export const MUA_VERSION = "2026.08.21.r010";
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 {
@@ -81,6 +83,31 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
}
}
async function runArgv(
label: string,
argv: string[],
timeoutSec = 60,
maxStdout = 100_000,
maxStderr = 20_000,
): Promise<string> {
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",
@@ -212,8 +239,14 @@ export async function runPhpHelper(
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);
return runArgv(`php_helper:${action}`, ["/usr/bin/php", helper, action, ...args], 300);
}
export async function runStatusHelper(action: string, ...args: string[]): Promise<string> {
if (!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 ─────────────────────────────────────────────────────────
@@ -668,3 +701,242 @@ export async function connectionTest(): Promise<string> {
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<string, unknown>;
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<string, { fingerprint: string; expiresAt: number }>();
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(/&#xD;|&amp;/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<string, unknown>;
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<string, unknown>[] {
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<string> {
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<CaApp> {
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<string, string> {
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<string, unknown>);
if (entries.length > 32) throw new Error("At most 32 overrides are allowed");
const normalized: Record<string, string> = {};
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<string, string>, 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<string> {
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 (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<string> {
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),
});
}