build hook plugins
This commit is contained in:
@@ -303,6 +303,7 @@ const startBuildProcess = (buildId: string, buildConfig: any) =>
|
||||
generateBuildMutex,
|
||||
sanitizeOutputName,
|
||||
fileShareRoot: FILE_SHARE_ROOT,
|
||||
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
|
||||
});
|
||||
|
||||
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
|
||||
@@ -496,6 +497,7 @@ async function startServer() {
|
||||
startBuildProcess,
|
||||
sanitizeMutex,
|
||||
allowedPlatforms: ALLOWED_PLATFORMS,
|
||||
listPluginManifests,
|
||||
},
|
||||
deploy: {
|
||||
DEPLOY_ROOT,
|
||||
|
||||
@@ -335,7 +335,7 @@ export type PluginManifest = {
|
||||
id: string;
|
||||
name: string;
|
||||
apiVersion?: number;
|
||||
runtime?: "native" | "wasm" | string;
|
||||
runtime?: "native" | "wasm" | "server" | string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
binary?: string;
|
||||
@@ -352,9 +352,54 @@ export type PluginManifest = {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
};
|
||||
build?: PluginBuildIntegration;
|
||||
hasServer?: boolean;
|
||||
};
|
||||
|
||||
export type PluginBuildIntegration = {
|
||||
enabledByDefault?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
settings?: PluginBuildSetting[];
|
||||
actions?: PluginBuildAction[];
|
||||
requires?: PluginBuildRequirement[];
|
||||
};
|
||||
|
||||
export type PluginBuildSetting = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "string" | "number" | "boolean" | "select" | "textarea" | string;
|
||||
default?: unknown;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
options?: Array<{ value: string; label?: string } | string>;
|
||||
};
|
||||
|
||||
export type PluginBuildAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
setBuild?: Record<string, unknown>;
|
||||
setSettings?: Record<string, unknown>;
|
||||
requires?: PluginBuildRequirement[];
|
||||
};
|
||||
|
||||
export type PluginBuildRequirement = {
|
||||
field?: string;
|
||||
pluginSetting?: string;
|
||||
equals?: unknown;
|
||||
notEquals?: unknown;
|
||||
truthy?: boolean;
|
||||
falsy?: boolean;
|
||||
includes?: unknown;
|
||||
platforms?: string[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type PluginFileNeed = {
|
||||
bucket: "home" | "desktop" | "documents" | "downloads" | "temp" | "appData" | "pluginData" | "fullDisk" | string;
|
||||
access: Array<"read" | "write" | "list" | "delete" | "mkdir" | string>;
|
||||
|
||||
@@ -122,8 +122,110 @@ type BuildProcessConfig = {
|
||||
iosBundleId?: string;
|
||||
fetchPublicIP?: boolean;
|
||||
uploadToFileShare?: boolean;
|
||||
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
type BuildHookRunner = (
|
||||
hook: string,
|
||||
payload: unknown,
|
||||
) => Promise<Array<{ pluginId: string; result: unknown }>>;
|
||||
|
||||
type BuildHookMessage = {
|
||||
text: string;
|
||||
level?: "debug" | "info" | "warn" | "error" | "success";
|
||||
};
|
||||
|
||||
function cloneForHook<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? null));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeHookMessages(result: unknown): BuildHookMessage[] {
|
||||
if (!isRecord(result)) return [];
|
||||
const rawMessages = Array.isArray(result.messages)
|
||||
? result.messages
|
||||
: typeof result.message === "string"
|
||||
? [result.message]
|
||||
: [];
|
||||
const messages: BuildHookMessage[] = [];
|
||||
for (const raw of rawMessages) {
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
messages.push({ text: raw.trim(), level: "info" });
|
||||
} else if (isRecord(raw) && typeof raw.text === "string" && raw.text.trim()) {
|
||||
const level = typeof raw.level === "string" ? raw.level : "info";
|
||||
messages.push({
|
||||
text: raw.text.trim(),
|
||||
level: ["debug", "info", "warn", "error", "success"].includes(level) ? level as BuildHookMessage["level"] : "info",
|
||||
});
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function runBuildHooks(
|
||||
hookRunner: BuildHookRunner | undefined,
|
||||
hook: string,
|
||||
payload: unknown,
|
||||
sendToStream: (data: any) => void,
|
||||
): Promise<Array<{ pluginId: string; result: unknown }>> {
|
||||
if (!hookRunner) return [];
|
||||
const results = await hookRunner(hook, cloneForHook(payload));
|
||||
for (const item of results) {
|
||||
for (const message of normalizeHookMessages(item.result)) {
|
||||
sendToStream({
|
||||
type: "output",
|
||||
text: `[plugin:${item.pluginId}] ${message.text}\n`,
|
||||
level: message.level || "info",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function mergeBuildConfigPatch(config: BuildProcessConfig, result: unknown): BuildProcessConfig {
|
||||
if (!isRecord(result)) return config;
|
||||
const patch = isRecord(result.config) ? result.config : isRecord(result.configPatch) ? result.configPatch : undefined;
|
||||
if (!patch) return config;
|
||||
return { ...config, ...patch };
|
||||
}
|
||||
|
||||
function buildTransformHookPayload(args: {
|
||||
buildId: string;
|
||||
stage: string;
|
||||
platform: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
targetKey: string;
|
||||
outDir: string;
|
||||
clientDir: string;
|
||||
filename: string;
|
||||
filePath: string;
|
||||
size?: number;
|
||||
config: BuildProcessConfig;
|
||||
extra?: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
buildId: args.buildId,
|
||||
stage: args.stage,
|
||||
platform: args.platform,
|
||||
os: args.os,
|
||||
arch: args.arch,
|
||||
targetKey: args.targetKey,
|
||||
outDir: args.outDir,
|
||||
clientDir: args.clientDir,
|
||||
file: {
|
||||
filename: args.filename,
|
||||
path: args.filePath,
|
||||
platform: args.platform,
|
||||
size: args.size ?? (fs.existsSync(args.filePath) ? fs.statSync(args.filePath).size : 0),
|
||||
},
|
||||
config: args.config,
|
||||
...(args.extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stripUpxHeaders(filePath: string): boolean {
|
||||
try {
|
||||
@@ -154,6 +256,7 @@ type BuildProcessDeps = {
|
||||
generateBuildMutex: (length?: number) => string;
|
||||
sanitizeOutputName: (name: string) => string;
|
||||
fileShareRoot?: string;
|
||||
runBuildHookForAll?: BuildHookRunner;
|
||||
};
|
||||
|
||||
function guessMimeTypeForUpload(filename: string): string {
|
||||
@@ -384,6 +487,15 @@ export async function startBuildProcess(
|
||||
}, BUILD_STREAM_HEARTBEAT_MS);
|
||||
|
||||
try {
|
||||
for (const item of await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"prepare",
|
||||
{ buildId, config },
|
||||
sendToStream,
|
||||
)) {
|
||||
config = mergeBuildConfigPatch(config, item.result);
|
||||
}
|
||||
|
||||
const serverConfig = getConfig();
|
||||
const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
|
||||
|
||||
@@ -809,10 +921,10 @@ func runBoundFiles() {
|
||||
const namePrefix = config.outputName || "agent";
|
||||
const winExt = config.outputExtension || ".exe";
|
||||
const buildSlug = buildId.substring(0, 8);
|
||||
const friendlyOutputName = deps.sanitizeOutputName(
|
||||
let friendlyOutputName = deps.sanitizeOutputName(
|
||||
platform.includes("windows") ? `${namePrefix}-${platform}${winExt}` : `${namePrefix}-${platform}`,
|
||||
);
|
||||
const outputName = deps.sanitizeOutputName(
|
||||
let outputName = deps.sanitizeOutputName(
|
||||
platform.includes("windows") ? `${namePrefix}-${platform}-${buildSlug}${winExt}` : `${namePrefix}-${platform}-${buildSlug}`,
|
||||
);
|
||||
|
||||
@@ -1084,7 +1196,7 @@ func runBoundFiles() {
|
||||
}
|
||||
|
||||
// Base tags: always present regardless of build pass
|
||||
const baseTags: string[] = [];
|
||||
let baseTags: string[] = [];
|
||||
if (config.noPrinting) baseTags.push("noprint");
|
||||
if (config.disableKeylogger) baseTags.push("nokeylogger");
|
||||
if (config.enableWebrtc) baseTags.push("overlord_webrtc");
|
||||
@@ -1095,7 +1207,7 @@ func runBoundFiles() {
|
||||
if (config.fetchPublicIP) baseTags.push("fetch_public_ip");
|
||||
|
||||
// Windows persistence tags (omitted in shellcode mode — handled by two-pass below)
|
||||
const winPersistTags: string[] = [];
|
||||
let winPersistTags: string[] = [];
|
||||
if (config.enablePersistence && os === "windows" && !isShellcodeMode) {
|
||||
const methods = config.persistenceMethods?.length ? config.persistenceMethods : ["startup"];
|
||||
if (methods.includes("startup")) winPersistTags.push("persist_startup");
|
||||
@@ -1104,6 +1216,73 @@ func runBoundFiles() {
|
||||
if (methods.includes("wmi")) winPersistTags.push("persist_wmi");
|
||||
}
|
||||
|
||||
let skipTarget = false;
|
||||
const targetHookPayload = {
|
||||
buildId,
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
effectiveOs,
|
||||
targetKey,
|
||||
outputName,
|
||||
friendlyOutputName,
|
||||
outputPath: path.join(outDir, outputName),
|
||||
outDir,
|
||||
clientDir,
|
||||
env,
|
||||
ldflags,
|
||||
tags: [...baseTags, ...winPersistTags],
|
||||
baseTags,
|
||||
persistenceTags: winPersistTags,
|
||||
garbleFlags,
|
||||
config,
|
||||
};
|
||||
for (const item of await runBuildHooks(deps.runBuildHookForAll, "target", targetHookPayload, sendToStream)) {
|
||||
if (!isRecord(item.result)) continue;
|
||||
if (item.result.skip === true) {
|
||||
skipTarget = true;
|
||||
}
|
||||
if (isRecord(item.result.env)) {
|
||||
for (const [key, value] of Object.entries(item.result.env)) {
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== null && value !== undefined) {
|
||||
env[key] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof item.result.ldflags === "string") {
|
||||
ldflags = item.result.ldflags;
|
||||
}
|
||||
if (typeof item.result.ldflagsAppend === "string" && item.result.ldflagsAppend.trim()) {
|
||||
ldflags = ldflags ? `${ldflags} ${item.result.ldflagsAppend.trim()}` : item.result.ldflagsAppend.trim();
|
||||
}
|
||||
if (Array.isArray(item.result.removeTags)) {
|
||||
const remove = new Set(item.result.removeTags.filter((tag: unknown) => typeof tag === "string"));
|
||||
baseTags = baseTags.filter((tag) => !remove.has(tag));
|
||||
winPersistTags = winPersistTags.filter((tag) => !remove.has(tag));
|
||||
}
|
||||
if (Array.isArray(item.result.tags)) {
|
||||
baseTags = item.result.tags.filter((tag: unknown) => typeof tag === "string" && tag.trim()).map((tag: string) => tag.trim());
|
||||
winPersistTags = [];
|
||||
}
|
||||
if (Array.isArray(item.result.addTags)) {
|
||||
for (const tag of item.result.addTags) {
|
||||
if (typeof tag === "string" && tag.trim() && !baseTags.includes(tag.trim())) {
|
||||
baseTags.push(tag.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof item.result.outputName === "string" && item.result.outputName.trim()) {
|
||||
outputName = deps.sanitizeOutputName(item.result.outputName.trim());
|
||||
}
|
||||
if (typeof item.result.friendlyOutputName === "string" && item.result.friendlyOutputName.trim()) {
|
||||
friendlyOutputName = deps.sanitizeOutputName(item.result.friendlyOutputName.trim());
|
||||
}
|
||||
}
|
||||
if (skipTarget) {
|
||||
sendToStream({ type: "output", text: `Skipping ${platform}; requested by server plugin hook.\n`, level: "warn" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const runBuild = async (tags: string[], outputPath: string) => {
|
||||
const buildArgs: string[] = [];
|
||||
if (tags.length > 0) buildArgs.push("-tags", tags.join(" "));
|
||||
@@ -1159,12 +1338,51 @@ func runBoundFiles() {
|
||||
|
||||
const filePath = `${outDir}/${outputName}`;
|
||||
let finalSize = Bun.file(filePath).size;
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"post_build",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "post_build",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
// For script outputs: go build writes a PE binary; UPX must run first (it needs PE format),
|
||||
// then after compression we wrap it in a script with an embedded base64 payload.
|
||||
const isBatchWrapper = os === "windows" && (winExt === ".bat" || winExt === ".cmd");
|
||||
const isPowerShellWrapper = os === "windows" && winExt === ".ps1";
|
||||
|
||||
if (upxBin) {
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_upx",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_upx",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
extra: { upxBin },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `Compressing ${outputName} with UPX...\n`, level: "info" });
|
||||
const originalSize = finalSize;
|
||||
try {
|
||||
@@ -1190,9 +1408,49 @@ func runBoundFiles() {
|
||||
} catch (upxErr: any) {
|
||||
sendToStream({ type: "output", text: `WARNING: UPX failed: ${upxErr.message || upxErr}\n`, level: "warn" });
|
||||
}
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_upx",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_upx",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
extra: { upxBin, originalSize },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
}
|
||||
|
||||
if (isBatchWrapper || isPowerShellWrapper) {
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_script_wrapper",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_script_wrapper",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
extra: { extension: winExt },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `Wrapping PE binary as ${winExt} script...\n`, level: "info" });
|
||||
try {
|
||||
const exeBytes = fs.readFileSync(filePath);
|
||||
@@ -1252,10 +1510,49 @@ func runBoundFiles() {
|
||||
} catch (wrapErr: any) {
|
||||
sendToStream({ type: "output", text: `WARNING: Failed to generate ${winExt} wrapper: ${wrapErr.message || wrapErr}. Output is a raw PE binary with ${winExt} extension.\n`, level: "warn" });
|
||||
}
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_script_wrapper",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_script_wrapper",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
extra: { extension: winExt },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
}
|
||||
|
||||
// ── IPA packaging for iOS targets ──────────────────────────────────────
|
||||
if (os === "ios") {
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_ipa",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_ipa",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: outputName,
|
||||
filePath,
|
||||
size: finalSize,
|
||||
config,
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `Packaging ${outputName} as IPA...\n`, level: "info" });
|
||||
try {
|
||||
const appName = config.outputName || "Agent";
|
||||
@@ -1355,6 +1652,27 @@ func runBoundFiles() {
|
||||
// Clean up temp dirs
|
||||
fs.rmSync(ipaWorkDir, { recursive: true, force: true });
|
||||
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_ipa",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_ipa",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: ipaOutputName,
|
||||
filePath: ipaPath,
|
||||
size: finalSize,
|
||||
config,
|
||||
extra: { inputFilename: outputName },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
|
||||
// Push IPA file entry instead of raw binary
|
||||
(build.files as any[]).push({
|
||||
name: ipaOutputName.replace(`-${buildSlug}`, ""),
|
||||
@@ -1387,6 +1705,26 @@ func runBoundFiles() {
|
||||
const scOutputName = deps.sanitizeOutputName(outputName.replace(/\.[^.]+$/, ".bin"));
|
||||
const binPath = path.join(outDir, scOutputName);
|
||||
const donutArch = (actualArch === "386" ? "386" : "amd64") as "386" | "amd64";
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_donut",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_donut",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { outputFilename: scOutputName, outputPath: binPath, donutArch },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
const ok = await runDonut(filePath, binPath, donutArch, sendToStream);
|
||||
if (!ok) throw new Error(`Donut shellcode conversion failed for ${platform}`);
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
@@ -1394,6 +1732,26 @@ func runBoundFiles() {
|
||||
finalOutputSize = Bun.file(binPath).size;
|
||||
shellcodeBinPath = binPath;
|
||||
shellcodeArch = donutArch;
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_donut",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_donut",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: binPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { inputFilename: outputName, donutArch },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
}
|
||||
|
||||
@@ -1403,6 +1761,26 @@ func runBoundFiles() {
|
||||
sendToStream({ type: "output", text: `\nWrapping ELF with Linux shellcode stub...\n`, level: "info" });
|
||||
const scOutputName = deps.sanitizeOutputName(outputName + ".bin");
|
||||
const binPath = path.join(outDir, scOutputName);
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_linux_shellcode",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_linux_shellcode",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { outputFilename: scOutputName, outputPath: binPath },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
const ok = buildLinuxShellcode(filePath, binPath, sendToStream);
|
||||
if (!ok) throw new Error(`Linux shellcode wrap failed for ${platform}`);
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
@@ -1410,6 +1788,26 @@ func runBoundFiles() {
|
||||
finalOutputSize = Bun.file(binPath).size;
|
||||
shellcodeBinPath = binPath;
|
||||
shellcodeArch = "amd64";
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_linux_shellcode",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_linux_shellcode",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: binPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { inputFilename: outputName },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
}
|
||||
|
||||
@@ -1419,27 +1817,172 @@ func runBoundFiles() {
|
||||
sendToStream({ type: "output", text: `\nEncoding shellcode with SGN (${iters} iteration${iters === 1 ? "" : "s"})...\n`, level: "info" });
|
||||
const sgnOutputName = deps.sanitizeOutputName(finalOutputName + ".sgn");
|
||||
const sgnOutputPath = path.join(outDir, sgnOutputName);
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_sgn",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_sgn",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: shellcodeBinPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { outputFilename: sgnOutputName, outputPath: sgnOutputPath, shellcodeArch, iterations: iters },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
const ok = await runSgn(shellcodeBinPath, sgnOutputPath, shellcodeArch, iters, sendToStream);
|
||||
if (!ok) throw new Error(`SGN encoding failed for ${platform}`);
|
||||
try { fs.unlinkSync(shellcodeBinPath); } catch {}
|
||||
finalOutputName = sgnOutputName;
|
||||
finalOutputSize = Bun.file(sgnOutputPath).size;
|
||||
shellcodeBinPath = sgnOutputPath;
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_sgn",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_sgn",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: sgnOutputPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { shellcodeArch, iterations: iters },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `SGN-encoded shellcode: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
|
||||
if (config.outputSgnTxt) {
|
||||
sendToStream({ type: "status", text: `Writing ${platform} SGN output as TXT…` });
|
||||
const txtOutputName = deps.sanitizeOutputName(`${finalOutputName}.txt`);
|
||||
const txtOutputPath = path.join(outDir, txtOutputName);
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"before_sgn_txt",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "before_sgn_txt",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: sgnOutputPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { outputFilename: txtOutputName, outputPath: txtOutputPath, shellcodeArch, iterations: iters },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
const txtSize = writeSgnTextArtifact(sgnOutputPath, txtOutputPath, platform, shellcodeArch, iters);
|
||||
try { fs.unlinkSync(sgnOutputPath); } catch {}
|
||||
finalOutputName = txtOutputName;
|
||||
finalOutputSize = txtSize;
|
||||
shellcodeBinPath = txtOutputPath;
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"after_sgn_txt",
|
||||
buildTransformHookPayload({
|
||||
buildId,
|
||||
stage: "after_sgn_txt",
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
filename: finalOutputName,
|
||||
filePath: txtOutputPath,
|
||||
size: finalOutputSize,
|
||||
config,
|
||||
extra: { shellcodeArch, iterations: iters },
|
||||
}),
|
||||
sendToStream,
|
||||
);
|
||||
sendToStream({ type: "output", text: `SGN TXT ready: ${txtSize} bytes → ${finalOutputName}\n`, level: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
const extraArtifactFiles: any[] = [];
|
||||
const artifactHookPayload = {
|
||||
buildId,
|
||||
platform,
|
||||
os,
|
||||
arch: actualArch,
|
||||
targetKey,
|
||||
outDir,
|
||||
clientDir,
|
||||
file: {
|
||||
name: finalOutputName.replace(`-${buildSlug}`, ""),
|
||||
filename: finalOutputName,
|
||||
path: path.join(outDir, finalOutputName),
|
||||
platform,
|
||||
version: agentVersion,
|
||||
size: finalOutputSize,
|
||||
},
|
||||
files: cloneForHook(build.files),
|
||||
config,
|
||||
};
|
||||
for (const item of await runBuildHooks(deps.runBuildHookForAll, "artifact", artifactHookPayload, sendToStream)) {
|
||||
if (!isRecord(item.result)) continue;
|
||||
const replacement = isRecord(item.result.file) ? item.result.file : item.result;
|
||||
if (typeof replacement.filename === "string" && replacement.filename.trim()) {
|
||||
const safeFilename = deps.sanitizeOutputName(path.basename(replacement.filename.trim()));
|
||||
const candidatePath = resolveContainedPath(outDir, safeFilename);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
finalOutputName = safeFilename;
|
||||
finalOutputSize = fs.statSync(candidatePath).size;
|
||||
} else {
|
||||
sendToStream({
|
||||
type: "output",
|
||||
text: `[plugin:${item.pluginId}] WARNING: artifact hook returned missing file ${safeFilename}; keeping ${finalOutputName}\n`,
|
||||
level: "warn",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (typeof replacement.size === "number" && Number.isFinite(replacement.size) && replacement.size >= 0) {
|
||||
finalOutputSize = Math.floor(replacement.size);
|
||||
}
|
||||
if (Array.isArray(item.result.files)) {
|
||||
for (const f of item.result.files) {
|
||||
if (!isRecord(f) || typeof f.filename !== "string" || !f.filename.trim()) continue;
|
||||
const safeFilename = deps.sanitizeOutputName(path.basename(f.filename.trim()));
|
||||
const candidatePath = resolveContainedPath(outDir, safeFilename);
|
||||
if (!fs.existsSync(candidatePath)) {
|
||||
sendToStream({
|
||||
type: "output",
|
||||
text: `[plugin:${item.pluginId}] WARNING: extra artifact not found: ${safeFilename}\n`,
|
||||
level: "warn",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const stat = fs.statSync(candidatePath);
|
||||
extraArtifactFiles.push({
|
||||
name: typeof f.name === "string" && f.name.trim() ? f.name.trim() : safeFilename,
|
||||
filename: safeFilename,
|
||||
platform: typeof f.platform === "string" ? f.platform : platform,
|
||||
version: typeof f.version === "string" ? f.version : agentVersion,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(build.files as any[]).push({
|
||||
name: finalOutputName.replace(`-${buildSlug}`, ""),
|
||||
filename: finalOutputName,
|
||||
@@ -1447,6 +1990,7 @@ func runBoundFiles() {
|
||||
version: agentVersion,
|
||||
size: finalOutputSize,
|
||||
});
|
||||
(build.files as any[]).push(...extraArtifactFiles);
|
||||
} catch (err: any) {
|
||||
const errorMsg = `[ERROR] Failed to build ${platform}: ${err.message || err}\n`;
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
|
||||
@@ -1483,6 +2027,21 @@ func runBoundFiles() {
|
||||
});
|
||||
}
|
||||
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"complete",
|
||||
{
|
||||
buildId,
|
||||
status: build.status,
|
||||
files: build.files,
|
||||
outputDir: outDir,
|
||||
expiresAt: build.expiresAt,
|
||||
userId: config.builtByUserId,
|
||||
config,
|
||||
},
|
||||
sendToStream,
|
||||
);
|
||||
|
||||
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
|
||||
|
||||
saveBuild({
|
||||
@@ -1502,6 +2061,23 @@ func runBoundFiles() {
|
||||
} catch (err: any) {
|
||||
build.status = "failed";
|
||||
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
|
||||
try {
|
||||
await runBuildHooks(
|
||||
deps.runBuildHookForAll,
|
||||
"failed",
|
||||
{
|
||||
buildId,
|
||||
status: build.status,
|
||||
error: err?.message || String(err),
|
||||
files: build.files,
|
||||
userId: config.builtByUserId,
|
||||
config,
|
||||
},
|
||||
sendToStream,
|
||||
);
|
||||
} catch (hookErr) {
|
||||
logger.warn(`[build:${buildId.substring(0, 8)}] failed hook error:`, hookErr);
|
||||
}
|
||||
sendToStream({ type: "error", error: err.message || String(err) });
|
||||
sendToStream({ type: "complete", success: false, buildId });
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ type Subscriber = {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type PendingRpc = {
|
||||
type PendingCall = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
@@ -24,12 +24,14 @@ type PluginInstance = {
|
||||
resolveReady: () => void;
|
||||
rejectReady: (err: Error) => void;
|
||||
isReady: boolean;
|
||||
pending: Map<string, PendingRpc>;
|
||||
pending: Map<string, PendingCall>;
|
||||
pendingBuildHooks: Map<string, PendingCall>;
|
||||
subscribers: Set<Subscriber>;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
const RPC_TIMEOUT_MS = 30_000;
|
||||
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||
const SHUTDOWN_GRACE_MS = 750;
|
||||
|
||||
export type PluginRuntime = {
|
||||
@@ -48,6 +50,8 @@ export type PluginRuntime = {
|
||||
params: unknown,
|
||||
caller: PluginRpcCaller,
|
||||
) => Promise<unknown>;
|
||||
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
|
||||
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
|
||||
subscribe: (
|
||||
pluginId: string,
|
||||
send: (sse: string) => void,
|
||||
@@ -55,6 +59,7 @@ export type PluginRuntime = {
|
||||
) => (() => void) | null;
|
||||
isRunning: (pluginId: string) => boolean;
|
||||
hasServerCode: (pluginId: string) => boolean;
|
||||
runningPluginIds: () => string[];
|
||||
shutdownAll: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -105,6 +110,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
||||
rejectReady,
|
||||
isReady: false,
|
||||
pending: new Map(),
|
||||
pendingBuildHooks: new Map(),
|
||||
subscribers: new Set(),
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
@@ -161,6 +167,11 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
||||
pending.reject(new Error("Plugin runtime stopped"));
|
||||
}
|
||||
inst.pending.clear();
|
||||
for (const pending of inst.pendingBuildHooks.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Plugin runtime stopped"));
|
||||
}
|
||||
inst.pendingBuildHooks.clear();
|
||||
|
||||
try {
|
||||
const shutdownMsg: WorkerInbound = { type: "shutdown" };
|
||||
@@ -237,6 +248,50 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
||||
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
|
||||
}
|
||||
|
||||
function runBuildHook(
|
||||
pluginId: string,
|
||||
hook: string,
|
||||
payload: unknown,
|
||||
): Promise<unknown> {
|
||||
const inst = instances.get(pluginId);
|
||||
if (!inst) {
|
||||
return Promise.reject(new Error("Plugin runtime not running"));
|
||||
}
|
||||
const callOnReady = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = uuidv4();
|
||||
const timer = setTimeout(() => {
|
||||
if (inst.pendingBuildHooks.delete(id)) {
|
||||
reject(new Error(`Build hook timeout: ${hook}`));
|
||||
}
|
||||
}, BUILD_HOOK_TIMEOUT_MS);
|
||||
inst.pendingBuildHooks.set(id, { resolve, reject, timer });
|
||||
const msg: WorkerInbound = { type: "build_hook", id, hook, payload };
|
||||
try {
|
||||
inst.worker.postMessage(msg);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
inst.pendingBuildHooks.delete(id);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
|
||||
}
|
||||
|
||||
async function runBuildHookForAll(
|
||||
hook: string,
|
||||
payload: unknown,
|
||||
): Promise<Array<{ pluginId: string; result: unknown }>> {
|
||||
const results: Array<{ pluginId: string; result: unknown }> = [];
|
||||
for (const pluginId of runningPluginIds()) {
|
||||
const result = await runBuildHook(pluginId, hook, payload);
|
||||
if (result !== null && result !== undefined) {
|
||||
results.push({ pluginId, result });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
pluginId: string,
|
||||
send: (sse: string) => void,
|
||||
@@ -270,6 +325,15 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
||||
else pending.reject(new Error(msg.error));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "build_hook_reply") {
|
||||
const pending = inst.pendingBuildHooks.get(msg.id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
inst.pendingBuildHooks.delete(msg.id);
|
||||
if (msg.ok) pending.resolve(msg.result);
|
||||
else pending.reject(new Error(msg.error));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "broadcast") {
|
||||
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
|
||||
const dataLine = JSON.stringify(msg.data ?? null);
|
||||
@@ -293,15 +357,22 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
|
||||
await Promise.all(ids.map((id) => stopPlugin(id)));
|
||||
}
|
||||
|
||||
function runningPluginIds(): string[] {
|
||||
return Array.from(instances.keys()).sort();
|
||||
}
|
||||
|
||||
return {
|
||||
startPlugin,
|
||||
stopPlugin,
|
||||
restartPlugin,
|
||||
dispatchClientEvent,
|
||||
rpc,
|
||||
runBuildHook,
|
||||
runBuildHookForAll,
|
||||
subscribe,
|
||||
isRunning,
|
||||
hasServerCode,
|
||||
runningPluginIds,
|
||||
shutdownAll,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ export type WorkerInbound =
|
||||
params: unknown;
|
||||
caller: PluginRpcCaller;
|
||||
}
|
||||
| {
|
||||
type: "build_hook";
|
||||
id: string;
|
||||
hook: string;
|
||||
payload: unknown;
|
||||
}
|
||||
| {
|
||||
type: "shutdown";
|
||||
};
|
||||
@@ -33,5 +39,7 @@ export type WorkerOutbound =
|
||||
| { type: "boot_error"; error: string }
|
||||
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
|
||||
| { type: "rpc_reply"; id: string; ok: false; error: string }
|
||||
| { type: "build_hook_reply"; id: string; ok: true; result: unknown }
|
||||
| { type: "build_hook_reply"; id: string; ok: false; error: string }
|
||||
| { type: "broadcast"; channel: string; data: unknown }
|
||||
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
|
||||
|
||||
@@ -12,6 +12,27 @@ type PluginExports = {
|
||||
setup?: (ctx: PluginContext) => unknown | Promise<unknown>;
|
||||
onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise<unknown>;
|
||||
rpc?: Record<string, (ctx: PluginContext, params: unknown, meta: { caller: { id: number; role: string } }) => unknown | Promise<unknown>>;
|
||||
buildHooks?: Record<string, (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>>;
|
||||
onBuildPrepare?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildTarget?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildPostBuild?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildBeforeSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildAfterSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildFailed?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -118,6 +139,49 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "build_hook") {
|
||||
if (!plugin || !ctx) {
|
||||
send({ type: "build_hook_reply", id: msg.id, ok: false, error: "Plugin not initialised" });
|
||||
return;
|
||||
}
|
||||
const handler =
|
||||
plugin.buildHooks?.[msg.hook] ||
|
||||
(msg.hook === "prepare" ? plugin.onBuildPrepare : undefined) ||
|
||||
(msg.hook === "target" ? plugin.onBuildTarget : undefined) ||
|
||||
(msg.hook === "post_build" ? plugin.onBuildPostBuild : undefined) ||
|
||||
(msg.hook === "before_upx" ? plugin.onBuildBeforeUpx : undefined) ||
|
||||
(msg.hook === "after_upx" ? plugin.onBuildAfterUpx : undefined) ||
|
||||
(msg.hook === "before_script_wrapper" ? plugin.onBuildBeforeScriptWrapper : undefined) ||
|
||||
(msg.hook === "after_script_wrapper" ? plugin.onBuildAfterScriptWrapper : undefined) ||
|
||||
(msg.hook === "before_ipa" ? plugin.onBuildBeforeIpa : undefined) ||
|
||||
(msg.hook === "after_ipa" ? plugin.onBuildAfterIpa : undefined) ||
|
||||
(msg.hook === "before_donut" ? plugin.onBuildBeforeDonut : undefined) ||
|
||||
(msg.hook === "after_donut" ? plugin.onBuildAfterDonut : undefined) ||
|
||||
(msg.hook === "before_linux_shellcode" ? plugin.onBuildBeforeLinuxShellcode : undefined) ||
|
||||
(msg.hook === "after_linux_shellcode" ? plugin.onBuildAfterLinuxShellcode : undefined) ||
|
||||
(msg.hook === "before_sgn" ? plugin.onBuildBeforeSgn : undefined) ||
|
||||
(msg.hook === "after_sgn" ? plugin.onBuildAfterSgn : undefined) ||
|
||||
(msg.hook === "before_sgn_txt" ? plugin.onBuildBeforeSgnTxt : undefined) ||
|
||||
(msg.hook === "after_sgn_txt" ? plugin.onBuildAfterSgnTxt : undefined) ||
|
||||
(msg.hook === "artifact" ? plugin.onBuildArtifact : undefined) ||
|
||||
(msg.hook === "complete" ? plugin.onBuildComplete : undefined) ||
|
||||
(msg.hook === "failed" ? plugin.onBuildFailed : undefined);
|
||||
if (typeof handler !== "function") {
|
||||
send({ type: "build_hook_reply", id: msg.id, ok: true, result: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handler(ctx, msg.payload);
|
||||
send({ type: "build_hook_reply", id: msg.id, ok: true, result: result ?? null });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack || "" : "";
|
||||
console.error(`[plugin-worker] build hook ${msg.hook} error: ${error}`, stack);
|
||||
send({ type: "build_hook_reply", id: msg.id, ok: false, error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "shutdown") {
|
||||
if (plugin && ctx && typeof plugin.teardown === "function") {
|
||||
try {
|
||||
|
||||
@@ -195,6 +195,7 @@ export async function ensurePluginExtracted(
|
||||
|
||||
const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null);
|
||||
const needs = normalizePluginNeeds(extraConfig.needs);
|
||||
const build = normalizePluginBuildIntegration(extraConfig.build);
|
||||
const manifest: PluginManifest = {
|
||||
id: safeId,
|
||||
name: extraConfig.name || safeId,
|
||||
@@ -212,6 +213,7 @@ export async function ensurePluginExtracted(
|
||||
js: `${safeId}.js`,
|
||||
},
|
||||
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
|
||||
...(build && { build }),
|
||||
hasServer: serverEntry !== null || !!serverTsEntry,
|
||||
};
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
@@ -622,6 +624,71 @@ function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "was
|
||||
return "native";
|
||||
}
|
||||
|
||||
function normalizePluginBuildIntegration(raw: any): any | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const build: any = {};
|
||||
if (typeof raw.enabledByDefault === "boolean") build.enabledByDefault = raw.enabledByDefault;
|
||||
if (typeof raw.label === "string" && raw.label.trim()) build.label = raw.label.trim().slice(0, 80);
|
||||
if (typeof raw.description === "string" && raw.description.trim()) build.description = raw.description.trim().slice(0, 300);
|
||||
|
||||
const settings: any[] = [];
|
||||
if (Array.isArray(raw.settings)) {
|
||||
for (const item of raw.settings.slice(0, 20)) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const key = typeof item.key === "string" ? item.key.trim() : "";
|
||||
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(key)) continue;
|
||||
const type = typeof item.type === "string" ? item.type.trim().toLowerCase() : "string";
|
||||
if (!["string", "number", "boolean", "select", "textarea"].includes(type)) continue;
|
||||
const setting: any = { key, type };
|
||||
if (typeof item.label === "string" && item.label.trim()) setting.label = item.label.trim().slice(0, 80);
|
||||
if (typeof item.placeholder === "string") setting.placeholder = item.placeholder.slice(0, 120);
|
||||
if (typeof item.description === "string" && item.description.trim()) setting.description = item.description.trim().slice(0, 240);
|
||||
if (typeof item.required === "boolean") setting.required = item.required;
|
||||
if (typeof item.min === "number" && Number.isFinite(item.min)) setting.min = item.min;
|
||||
if (typeof item.max === "number" && Number.isFinite(item.max)) setting.max = item.max;
|
||||
if (item.default !== undefined) setting.default = item.default;
|
||||
if (Array.isArray(item.options)) {
|
||||
const options = item.options.slice(0, 50).map((opt: any) => {
|
||||
if (typeof opt === "string") return opt.slice(0, 120);
|
||||
if (opt && typeof opt === "object" && typeof opt.value === "string") {
|
||||
return {
|
||||
value: opt.value.slice(0, 120),
|
||||
...(typeof opt.label === "string" && { label: opt.label.slice(0, 120) }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
if (options.length > 0) setting.options = options;
|
||||
}
|
||||
settings.push(setting);
|
||||
}
|
||||
}
|
||||
if (settings.length > 0) build.settings = settings;
|
||||
|
||||
const actions: any[] = [];
|
||||
if (Array.isArray(raw.actions)) {
|
||||
for (const item of raw.actions.slice(0, 12)) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const id = typeof item.id === "string" ? item.id.trim() : "";
|
||||
const label = typeof item.label === "string" ? item.label.trim() : "";
|
||||
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(id) || !label) continue;
|
||||
actions.push({
|
||||
id,
|
||||
label: label.slice(0, 80),
|
||||
...(typeof item.icon === "string" && { icon: item.icon.trim().slice(0, 80) }),
|
||||
...(typeof item.description === "string" && { description: item.description.trim().slice(0, 240) }),
|
||||
...(item.setBuild && typeof item.setBuild === "object" && !Array.isArray(item.setBuild) && { setBuild: item.setBuild }),
|
||||
...(item.setSettings && typeof item.setSettings === "object" && !Array.isArray(item.setSettings) && { setSettings: item.setSettings }),
|
||||
...(Array.isArray(item.requires) && { requires: item.requires.slice(0, 20) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (actions.length > 0) build.actions = actions;
|
||||
if (Array.isArray(raw.requires)) build.requires = raw.requires.slice(0, 20);
|
||||
|
||||
return Object.keys(build).length > 0 ? build : undefined;
|
||||
}
|
||||
|
||||
export function detectPluginIdFromZip(zip: any): string | null {
|
||||
const entries = zip.getEntries();
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { logger } from "../../logger";
|
||||
import { normalizeClientOs } from "../deploy-utils";
|
||||
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
|
||||
import { getConfig } from "../../config";
|
||||
import { canUploadFiles } from "../../users";
|
||||
import { canUploadFiles, canUserAccessPlugin } from "../../users";
|
||||
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
@@ -37,8 +37,93 @@ type BuildRouteDeps = {
|
||||
startBuildProcess: (buildId: string, config: any) => Promise<void>;
|
||||
sanitizeMutex: (value?: string) => string | undefined;
|
||||
allowedPlatforms: Set<string>;
|
||||
listPluginManifests?: () => Promise<any[]>;
|
||||
};
|
||||
|
||||
function getPathValue(source: any, dotted: string): any {
|
||||
if (!source || !dotted) return undefined;
|
||||
return dotted.split(".").reduce((cur, key) => (cur && typeof cur === "object" ? cur[key] : undefined), source);
|
||||
}
|
||||
|
||||
function requirementMet(req: any, buildConfig: any, pluginSettings: any): boolean {
|
||||
if (!req || typeof req !== "object") return true;
|
||||
let value: any;
|
||||
if (typeof req.field === "string") value = getPathValue(buildConfig, req.field);
|
||||
else if (typeof req.pluginSetting === "string") value = getPathValue(pluginSettings, req.pluginSetting);
|
||||
else if (Array.isArray(req.platforms)) {
|
||||
const platforms = Array.isArray(buildConfig.platforms) ? buildConfig.platforms : [];
|
||||
return req.platforms.some((p: any) => typeof p === "string" && platforms.includes(p));
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
if (req.truthy === true && !value) return false;
|
||||
if (req.falsy === true && value) return false;
|
||||
if (Object.prototype.hasOwnProperty.call(req, "equals") && value !== req.equals) return false;
|
||||
if (Object.prototype.hasOwnProperty.call(req, "notEquals") && value === req.notEquals) return false;
|
||||
if (Object.prototype.hasOwnProperty.call(req, "includes")) {
|
||||
if (!Array.isArray(value) || !value.includes(req.includes)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizePluginBuildValue(setting: any, value: any): any {
|
||||
const type = typeof setting?.type === "string" ? setting.type : "string";
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return setting?.default !== undefined ? setting.default : undefined;
|
||||
}
|
||||
if (type === "boolean") return value === true || value === "true" || value === "1";
|
||||
if (type === "number") {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return setting?.default !== undefined ? setting.default : undefined;
|
||||
const min = typeof setting.min === "number" ? setting.min : -1_000_000_000;
|
||||
const max = typeof setting.max === "number" ? setting.max : 1_000_000_000;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
const str = String(value).slice(0, type === "textarea" ? 10_000 : 1_000);
|
||||
if (type === "select" && Array.isArray(setting.options)) {
|
||||
const allowed = new Set(setting.options.map((opt: any) => typeof opt === "string" ? opt : opt?.value).filter((v: any) => typeof v === "string"));
|
||||
return allowed.has(str) ? str : setting.default;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
async function sanitizeBuildPlugins(
|
||||
raw: any,
|
||||
deps: BuildRouteDeps,
|
||||
user: any,
|
||||
buildConfig: any,
|
||||
): Promise<{ value: Record<string, any>; error?: string }> {
|
||||
if (!raw || typeof raw !== "object" || !deps.listPluginManifests) return { value: {} };
|
||||
const manifests = await deps.listPluginManifests();
|
||||
const byId = new Map(manifests.map((m) => [m.id, m]));
|
||||
const value: Record<string, any> = {};
|
||||
for (const [pluginId, rawPlugin] of Object.entries(raw)) {
|
||||
if (!/^[A-Za-z0-9._-]{1,128}$/.test(pluginId)) continue;
|
||||
const manifest = byId.get(pluginId);
|
||||
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
|
||||
const rawRecord = rawPlugin && typeof rawPlugin === "object" ? rawPlugin as any : {};
|
||||
const enabled = rawRecord.enabled !== false;
|
||||
const settings: Record<string, any> = {};
|
||||
for (const setting of Array.isArray(manifest.build.settings) ? manifest.build.settings : []) {
|
||||
if (!setting || typeof setting.key !== "string") continue;
|
||||
const sanitized = sanitizePluginBuildValue(setting, rawRecord.settings?.[setting.key]);
|
||||
if (enabled && setting.required && (sanitized === undefined || sanitized === "")) {
|
||||
return { value: {}, error: `Build plugin ${manifest.name || pluginId} requires ${setting.label || setting.key}` };
|
||||
}
|
||||
if (sanitized !== undefined) settings[setting.key] = sanitized;
|
||||
}
|
||||
value[pluginId] = { enabled, settings };
|
||||
if (enabled) {
|
||||
for (const req of Array.isArray(manifest.build.requires) ? manifest.build.requires : []) {
|
||||
if (!requirementMet(req, buildConfig, settings)) {
|
||||
return { value: {}, error: req.message || `Build plugin ${manifest.name || pluginId} requirements are not met` };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
|
||||
export async function handleBuildRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
@@ -120,6 +205,7 @@ export async function handleBuildRoutes(
|
||||
outputSgnTxt,
|
||||
fetchPublicIP,
|
||||
uploadToFileShare,
|
||||
buildPlugins,
|
||||
} = body;
|
||||
|
||||
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
|
||||
@@ -377,7 +463,7 @@ export async function handleBuildRoutes(
|
||||
const rateLimitActive = getConfig().registration.mode !== "off";
|
||||
if (rateLimitActive) recordBuildStart(user.userId);
|
||||
|
||||
deps.startBuildProcess(buildId, {
|
||||
const safeBuildConfig: any = {
|
||||
platforms: allowedPlatforms,
|
||||
serverUrl: safeServerUrl,
|
||||
rawServerList: safeRawServerList,
|
||||
@@ -424,13 +510,42 @@ export async function handleBuildRoutes(
|
||||
outputSgnTxt: safeOutputSgnTxt,
|
||||
fetchPublicIP: !!fetchPublicIP,
|
||||
uploadToFileShare: safeUploadToFileShare,
|
||||
}).finally(() => {
|
||||
};
|
||||
|
||||
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
|
||||
if (pluginBuildResult.error) {
|
||||
if (rateLimitActive) recordBuildEnd(user.userId);
|
||||
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
|
||||
}
|
||||
safeBuildConfig.buildPlugins = pluginBuildResult.value;
|
||||
|
||||
deps.startBuildProcess(buildId, safeBuildConfig).finally(() => {
|
||||
if (rateLimitActive) recordBuildEnd(user.userId);
|
||||
});
|
||||
|
||||
return Response.json({ buildId });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/plugins") {
|
||||
requirePermission(user, "clients:build");
|
||||
if (!deps.listPluginManifests) {
|
||||
return Response.json({ plugins: [] });
|
||||
}
|
||||
const manifests = await deps.listPluginManifests();
|
||||
const plugins = manifests
|
||||
.filter((manifest) => manifest?.build && manifest?.hasServer === true)
|
||||
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
|
||||
.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name || manifest.id,
|
||||
runtime: manifest.runtime || "native",
|
||||
hasServer: manifest.hasServer === true,
|
||||
enabled: manifest.enabled !== false,
|
||||
build: manifest.build,
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/list") {
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
|
||||
@@ -164,21 +164,25 @@ export async function handlePluginRoutes(
|
||||
const plugins = await deps.listPluginManifests();
|
||||
const enriched = plugins
|
||||
.filter((p) => canUserAccessPlugin(user.userId, user.role, p.id))
|
||||
.map((p) => ({
|
||||
...p,
|
||||
enabled: deps.pluginState.enabled[p.id] !== false,
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
runtime: p.runtime || "native",
|
||||
apiVersion: p.apiVersion || 1,
|
||||
needs: p.needs || {},
|
||||
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
|
||||
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
|
||||
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
|
||||
serverRunning: deps.pluginRuntime.isRunning(p.id),
|
||||
}));
|
||||
.map((p) => {
|
||||
const runtime = p.runtime || "native";
|
||||
const isServerOnly = runtime === "server";
|
||||
return {
|
||||
...p,
|
||||
enabled: deps.pluginState.enabled[p.id] !== false,
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: isServerOnly ? false : deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: isServerOnly ? [] : deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
runtime,
|
||||
apiVersion: p.apiVersion || 1,
|
||||
needs: p.needs || {},
|
||||
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
|
||||
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
|
||||
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
|
||||
serverRunning: deps.pluginRuntime.isRunning(p.id),
|
||||
};
|
||||
});
|
||||
return Response.json({ plugins: enriched });
|
||||
}
|
||||
|
||||
@@ -261,6 +265,7 @@ export async function handlePluginRoutes(
|
||||
const manifests = await deps.listPluginManifests();
|
||||
const plugins = manifests
|
||||
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
|
||||
.filter((manifest) => manifest.runtime !== "server")
|
||||
.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name || manifest.id,
|
||||
@@ -451,6 +456,17 @@ export async function handlePluginRoutes(
|
||||
body = await req.json();
|
||||
} catch {}
|
||||
const autoLoad = !!body.autoLoad;
|
||||
const manifest = await loadManifest(pluginId);
|
||||
|
||||
if (manifest.runtime === "server") {
|
||||
delete deps.pluginState.autoLoad[pluginId];
|
||||
delete deps.pluginState.autoStartEvents[pluginId];
|
||||
await deps.savePluginState();
|
||||
return Response.json(
|
||||
{ ok: false, error: "Server-side plugins do not support client auto-load" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (autoLoad && deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json(
|
||||
@@ -868,6 +884,13 @@ export async function handlePluginRoutes(
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const manifest = await loadManifest(pluginId);
|
||||
if (manifest.runtime === "server") {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Server-side plugins do not load onto clients" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
if (deps.isPluginLoaded(targetId, pluginId)) {
|
||||
|
||||
Reference in New Issue
Block a user