build hook plugins
This commit is contained in:
@@ -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