release r010 add Unraid health and Community Apps tools
This commit is contained in:
1 parent
e586b22b13
commit
771b8c43a1
14 files changed
+831
-33
No files matched your search
+275
-3
@@ -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(/
|&/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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user