fix uploading and more RBAC refinements for auto deploy
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user