943 lines
35 KiB
TypeScript
943 lines
35 KiB
TypeScript
/**
|
|
* 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<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)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<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);
|
|
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<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",
|
|
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
|
|
"uptime", "wc", "whoami",
|
|
]);
|
|
|
|
async function readStreamLimited(
|
|
stream: ReadableStream<Uint8Array>,
|
|
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<string> {
|
|
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<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 (!(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<string> {
|
|
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<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, "");
|
|
// 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<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 ?? [],
|
|
// 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<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");
|
|
|
|
// 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<string, number> = {};
|
|
const containersWithMatches: Record<string, number> = {};
|
|
|
|
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<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,
|
|
);
|
|
}
|
|
|
|
// ── 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 (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<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),
|
|
});
|
|
}
|