435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import fs from "fs/promises";
|
|
import path from "path";
|
|
import AdmZip from "adm-zip";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import type { ClientInfo } from "../types";
|
|
import { logger } from "../logger";
|
|
import { encodeMessage, type PluginManifest, type PluginSignatureInfo } from "../protocol";
|
|
import { verifyPluginSignature, getOrVerifySignature } from "./plugin-signature";
|
|
|
|
export type PluginState = {
|
|
enabled: Record<string, boolean>;
|
|
lastError: Record<string, string>;
|
|
autoLoad: Record<string, boolean>;
|
|
autoStartEvents: Record<string, Array<{ event: string; payload: any }>>;
|
|
};
|
|
|
|
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise<PluginState> {
|
|
try {
|
|
const raw = await fs.readFile(pluginStatePath, "utf-8");
|
|
const parsed = JSON.parse(raw) as Partial<PluginState>;
|
|
return {
|
|
enabled: parsed.enabled || {},
|
|
lastError: parsed.lastError || {},
|
|
autoLoad: parsed.autoLoad || {},
|
|
autoStartEvents: parsed.autoStartEvents || {},
|
|
};
|
|
} catch {
|
|
return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} };
|
|
}
|
|
}
|
|
|
|
export async function savePluginStateToDisk(
|
|
pluginRoot: string,
|
|
pluginStatePath: string,
|
|
pluginState: PluginState,
|
|
): Promise<void> {
|
|
await fs.mkdir(pluginRoot, { recursive: true });
|
|
await fs.writeFile(pluginStatePath, JSON.stringify(pluginState, null, 2));
|
|
}
|
|
|
|
export async function ensurePluginExtracted(
|
|
pluginRoot: string,
|
|
pluginId: string,
|
|
sanitizePluginId: (name: string) => string,
|
|
): Promise<void> {
|
|
const safeId = sanitizePluginId(pluginId);
|
|
const zipPath = path.join(pluginRoot, `${safeId}.zip`);
|
|
const pluginDir = path.join(pluginRoot, safeId);
|
|
const manifestPath = path.join(pluginDir, "manifest.json");
|
|
|
|
let zipStat: any = null;
|
|
try {
|
|
zipStat = await fs.stat(zipPath);
|
|
} catch {
|
|
zipStat = null;
|
|
}
|
|
|
|
let manifestStat: any = null;
|
|
try {
|
|
manifestStat = await fs.stat(manifestPath);
|
|
} catch {
|
|
manifestStat = null;
|
|
}
|
|
|
|
if (!zipStat) {
|
|
if (manifestStat) return;
|
|
throw new Error(`Plugin bundle not found: ${safeId}`);
|
|
}
|
|
|
|
if (manifestStat && manifestStat.mtimeMs >= zipStat.mtimeMs) {
|
|
return;
|
|
}
|
|
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
const assetsDir = path.join(pluginDir, "assets");
|
|
await fs.mkdir(assetsDir, { recursive: true });
|
|
|
|
const zip = new AdmZip(zipPath);
|
|
const entries = zip.getEntries();
|
|
|
|
const MAX_PLUGIN_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
|
|
let totalUncompressed = 0;
|
|
for (const entry of entries as any[]) {
|
|
if (entry?.isDirectory) continue;
|
|
const sz = Number(entry?.header?.size ?? 0);
|
|
if (!Number.isFinite(sz) || sz < 0) {
|
|
throw new Error(`Invalid plugin bundle: ${safeId} (malformed zip)`);
|
|
}
|
|
totalUncompressed += sz;
|
|
if (totalUncompressed > MAX_PLUGIN_UNCOMPRESSED_BYTES) {
|
|
throw new Error(`Plugin bundle too large: ${safeId} (uncompressed > 200 MB)`);
|
|
}
|
|
}
|
|
|
|
let htmlEntry: Buffer | null = null;
|
|
let cssEntry: Buffer | null = null;
|
|
let jsEntry: Buffer | null = null;
|
|
let serverEntry: Buffer | null = null;
|
|
let configEntry: Buffer | null = null;
|
|
const nativeBinaries: Map<string, Buffer> = new Map();
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory) continue;
|
|
const base = path.basename(entry.entryName);
|
|
const lower = base.toLowerCase();
|
|
if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) {
|
|
nativeBinaries.set(base, entry.getData());
|
|
} else if (lower === "server.js") {
|
|
serverEntry = entry.getData();
|
|
} else if (lower.endsWith(".html")) {
|
|
htmlEntry = entry.getData();
|
|
} else if (lower.endsWith(".css")) {
|
|
cssEntry = entry.getData();
|
|
} else if (lower.endsWith(".js")) {
|
|
jsEntry = entry.getData();
|
|
} else if (lower === "config.json") {
|
|
configEntry = entry.getData();
|
|
}
|
|
}
|
|
|
|
if (!htmlEntry || !cssEntry || !jsEntry) {
|
|
throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`);
|
|
}
|
|
|
|
const binariesMap: Record<string, string> = {};
|
|
for (const [filename, data] of nativeBinaries) {
|
|
await fs.writeFile(path.join(pluginDir, filename), data);
|
|
const platformKey = derivePlatformKey(filename);
|
|
if (platformKey) {
|
|
binariesMap[platformKey] = filename;
|
|
}
|
|
}
|
|
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry);
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
|
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
|
|
|
const serverScriptPath = path.join(pluginDir, "server.js");
|
|
if (serverEntry) {
|
|
await fs.writeFile(serverScriptPath, serverEntry);
|
|
} else {
|
|
// A previous extraction may have left a stale server.js — drop it so the
|
|
// runtime accurately reflects the current bundle.
|
|
try { await fs.rm(serverScriptPath, { force: true }); } catch {}
|
|
}
|
|
|
|
let extraConfig: any = {};
|
|
if (configEntry) {
|
|
try {
|
|
extraConfig = JSON.parse(configEntry.toString("utf-8"));
|
|
} catch (err) {
|
|
logger.warn(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
|
|
}
|
|
}
|
|
|
|
const manifest: PluginManifest = {
|
|
id: safeId,
|
|
name: extraConfig.name || safeId,
|
|
version: extraConfig.version || "1.0.0",
|
|
description: extraConfig.description,
|
|
binaries: binariesMap,
|
|
entry: `${safeId}.html`,
|
|
assets: {
|
|
html: `${safeId}.html`,
|
|
css: `${safeId}.css`,
|
|
js: `${safeId}.js`,
|
|
},
|
|
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
|
|
hasServer: serverEntry !== null,
|
|
};
|
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
|
|
try {
|
|
const sigInfo = await verifyPluginSignature(zipPath);
|
|
const sigInfoPath = path.join(pluginDir, "signature-info.json");
|
|
await fs.writeFile(sigInfoPath, JSON.stringify(sigInfo, null, 2));
|
|
} catch (err) {
|
|
logger.warn(`[plugin] failed to verify signature for ${safeId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
export async function syncPluginBundles(
|
|
pluginRoot: string,
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
|
): Promise<void> {
|
|
await fs.mkdir(pluginRoot, { recursive: true });
|
|
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
|
for (const ent of entries) {
|
|
if (ent.isFile() && ent.name.toLowerCase().endsWith(".zip")) {
|
|
const pluginId = ent.name.slice(0, -4);
|
|
try {
|
|
await ensureExtracted(pluginId);
|
|
} catch (err) {
|
|
logger.warn(`[plugin] failed to extract ${pluginId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type PluginManifestWithSignature = PluginManifest & {
|
|
signature?: PluginSignatureInfo;
|
|
};
|
|
|
|
export async function listPluginManifests(
|
|
pluginRoot: string,
|
|
pluginState: PluginState,
|
|
saveState: () => Promise<void>,
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
|
): Promise<PluginManifestWithSignature[]> {
|
|
try {
|
|
await syncPluginBundles(pluginRoot, ensureExtracted);
|
|
const entries = await fs.readdir(pluginRoot, { withFileTypes: true });
|
|
const manifests: PluginManifestWithSignature[] = [];
|
|
for (const ent of entries) {
|
|
if (!ent.isDirectory()) continue;
|
|
const manifestPath = path.join(pluginRoot, ent.name, "manifest.json");
|
|
try {
|
|
const raw = await fs.readFile(manifestPath, "utf-8");
|
|
const manifest = JSON.parse(raw) as PluginManifest;
|
|
const id = manifest.id || ent.name;
|
|
const name = manifest.name || ent.name;
|
|
if (pluginState.enabled[id] === undefined) {
|
|
pluginState.enabled[id] = true;
|
|
}
|
|
const sigInfo = await getOrVerifySignature(pluginRoot, id);
|
|
manifests.push({ ...manifest, id, name, signature: sigInfo });
|
|
} catch {}
|
|
}
|
|
await saveState();
|
|
return manifests;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export type PluginBundle = {
|
|
manifest: PluginManifest;
|
|
binaryPath: string | null;
|
|
size: number;
|
|
};
|
|
|
|
export async function loadPluginBundle(
|
|
pluginRoot: string,
|
|
pluginId: string,
|
|
ensureExtracted: (pluginId: string) => Promise<void>,
|
|
clientOS?: string,
|
|
clientArch?: string,
|
|
): Promise<PluginBundle> {
|
|
await ensureExtracted(pluginId);
|
|
const dir = path.join(pluginRoot, pluginId);
|
|
const manifestPath = path.join(dir, "manifest.json");
|
|
const rawManifest = await fs.readFile(manifestPath, "utf-8");
|
|
const manifest = JSON.parse(rawManifest) as PluginManifest;
|
|
manifest.id = manifest.id || pluginId;
|
|
manifest.name = manifest.name || pluginId;
|
|
|
|
const hasBinaries = manifest.binaries && Object.keys(manifest.binaries).length > 0;
|
|
if (!hasBinaries) {
|
|
return { manifest, binaryPath: null, size: 0 };
|
|
}
|
|
|
|
let binaryPath: string | null = null;
|
|
|
|
if (manifest.binaries && clientOS && clientArch) {
|
|
const key = `${clientOS}-${clientArch}`;
|
|
const filename = manifest.binaries[key];
|
|
if (filename) {
|
|
const candidate = path.join(dir, filename);
|
|
try {
|
|
await fs.access(candidate);
|
|
binaryPath = candidate;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
if (!binaryPath) {
|
|
const platformKey = clientOS && clientArch ? `${clientOS}-${clientArch}` : "unknown";
|
|
const files = await fs.readdir(dir);
|
|
const archRegex = /-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i;
|
|
|
|
const found = files.find((f) => {
|
|
const m = f.match(archRegex);
|
|
if (m) {
|
|
return (
|
|
m[1].toLowerCase() === (clientOS || "").toLowerCase() &&
|
|
m[2].toLowerCase() === (clientArch || "").toLowerCase()
|
|
);
|
|
}
|
|
const fl = f.toLowerCase();
|
|
return fl.endsWith(".so") || fl.endsWith(".dll") || fl.endsWith(".dylib");
|
|
});
|
|
if (!found) {
|
|
throw new Error(
|
|
`No compatible plugin binary for ${pluginId} (client=${platformKey}, available=[${Object.keys(manifest.binaries || {}).join(", ")}])`,
|
|
);
|
|
}
|
|
binaryPath = path.join(dir, found);
|
|
}
|
|
|
|
const stat = await fs.stat(binaryPath);
|
|
return { manifest, binaryPath, size: stat.size };
|
|
}
|
|
|
|
export type PluginPull = {
|
|
id: string;
|
|
clientId: string;
|
|
pluginId: string;
|
|
binaryPath: string;
|
|
size: number;
|
|
expiresAt: number;
|
|
timeout: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
const PLUGIN_PULL_TTL_MS = 5 * 60_000;
|
|
const pluginPulls = new Map<string, PluginPull>();
|
|
|
|
export function getPluginPull(id: string): PluginPull | undefined {
|
|
return pluginPulls.get(id);
|
|
}
|
|
|
|
export function deletePluginPull(id: string): void {
|
|
const pull = pluginPulls.get(id);
|
|
if (!pull) return;
|
|
pluginPulls.delete(id);
|
|
clearTimeout(pull.timeout);
|
|
}
|
|
|
|
export function sendPluginBundle(
|
|
target: ClientInfo,
|
|
bundle: PluginBundle,
|
|
): void {
|
|
if (!bundle.binaryPath) return;
|
|
|
|
const pullId = uuidv4();
|
|
const expiresAt = Date.now() + PLUGIN_PULL_TTL_MS;
|
|
const timeout = setTimeout(() => {
|
|
pluginPulls.delete(pullId);
|
|
}, PLUGIN_PULL_TTL_MS);
|
|
|
|
pluginPulls.set(pullId, {
|
|
id: pullId,
|
|
clientId: target.id,
|
|
pluginId: bundle.manifest.id,
|
|
binaryPath: bundle.binaryPath,
|
|
size: bundle.size,
|
|
expiresAt,
|
|
timeout,
|
|
});
|
|
|
|
target.ws.send(
|
|
encodeMessage({
|
|
type: "command",
|
|
commandType: "plugin_load_http",
|
|
id: uuidv4(),
|
|
payload: {
|
|
manifest: bundle.manifest,
|
|
size: bundle.size,
|
|
url: `/api/plugins/pull/${encodeURIComponent(pullId)}`,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function dispatchAutoLoadPlugins(
|
|
client: ClientInfo,
|
|
pluginState: PluginState,
|
|
isPluginLoaded: (clientId: string, pluginId: string) => boolean,
|
|
isPluginLoading: (clientId: string, pluginId: string) => boolean,
|
|
markPluginLoading: (clientId: string, pluginId: string) => void,
|
|
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void,
|
|
loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<PluginBundle>,
|
|
): Promise<void> {
|
|
const autoLoadIds = Object.entries(pluginState.autoLoad)
|
|
.filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false)
|
|
.map(([id]) => id);
|
|
|
|
for (const pluginId of autoLoadIds) {
|
|
if (isPluginLoaded(client.id, pluginId) || isPluginLoading(client.id, pluginId)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const bundle = await loadBundle(pluginId, client.os, client.arch);
|
|
markPluginLoading(client.id, pluginId);
|
|
sendPluginBundle(client, bundle);
|
|
|
|
const autoEvents = pluginState.autoStartEvents[pluginId];
|
|
if (autoEvents && autoEvents.length > 0) {
|
|
for (const evt of autoEvents) {
|
|
enqueuePluginEvent(client.id, pluginId, evt.event, evt.payload);
|
|
}
|
|
}
|
|
|
|
logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
|
|
} catch (err) {
|
|
logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function detectPluginIdFromZip(zip: AdmZip): string | null {
|
|
const entries = zip.getEntries();
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory) continue;
|
|
const base = path.basename(entry.entryName);
|
|
if (base.toLowerCase().endsWith(".html")) {
|
|
return base.slice(0, -5);
|
|
}
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory) continue;
|
|
const base = path.basename(entry.entryName);
|
|
if (base.toLowerCase() === "config.json") {
|
|
try {
|
|
const cfg = JSON.parse(entry.getData().toString("utf-8"));
|
|
if (typeof cfg?.id === "string" && cfg.id.length > 0) return cfg.id;
|
|
} catch {}
|
|
break;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function derivePlatformKey(filename: string): string {
|
|
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
|
|
if (match) {
|
|
return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`;
|
|
}
|
|
const lower = filename.toLowerCase();
|
|
if (lower.endsWith(".dll")) return "windows-amd64";
|
|
if (lower.endsWith(".dylib")) return "darwin-amd64";
|
|
if (lower.endsWith(".so")) return "linux-amd64";
|
|
return "";
|
|
}
|