feat(sandbox): tRPC router, OpenAPI exposure and streaming exec websocket

This commit is contained in:
Mauricio Siu
2026-09-10 01:16:54 -06:00
parent 084fe765f0
commit ad00da50c1
6 changed files with 409 additions and 0 deletions
+1
View File
@@ -62,6 +62,7 @@ async function generateOpenAPI() {
"rollback",
"volumeBackups",
"environment",
"sandbox",
],
});
+2
View File
@@ -45,6 +45,7 @@ import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { rollbackRouter } from "./routers/rollbacks";
import { sandboxRouter } from "./routers/sandbox";
import { scheduleRouter } from "./routers/schedule";
import { securityRouter } from "./routers/security";
import { serverRouter } from "./routers/server";
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
sandbox: sandboxRouter,
});
// export type definition of API
+281
View File
@@ -0,0 +1,281 @@
import {
createSandbox,
execInSandbox,
findEnvironmentById,
findProjectById,
findSandboxById,
findSandboxesByEnvironmentId,
findSandboxesByProjectId,
getAccessibleServerIds,
getWebServerSettings,
IS_CLOUD,
killSandbox,
listSandboxFiles,
readSandboxFile,
removeSandbox,
SANDBOX_TEMPLATES,
setSandboxTimeout,
writeSandboxFile,
} from "@dokploy/server";
import {
addNewService,
checkEnvironmentAccess,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateSandbox,
apiExecSandbox,
apiFindOneSandbox,
apiListFilesSandbox,
apiListSandboxes,
apiReadFileSandbox,
apiSetTimeoutSandbox,
apiWriteFileSandbox,
} from "@/server/db/schema";
type Ctx = {
user: { id: string; role: string };
session: { activeOrganizationId: string };
};
const findAccessibleSandbox = async (
ctx: Ctx,
sandboxId: string,
mode: "read" | "use" | "delete",
) => {
if (mode === "read") {
await checkServiceAccess(ctx, sandboxId, "read");
} else if (mode === "delete") {
await checkServiceAccess(ctx, sandboxId, "delete");
} else {
await checkServicePermissionAndAccess(ctx, sandboxId, {
deployment: ["create"],
});
}
const sandbox = await findSandboxById(sandboxId);
if (
sandbox.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this sandbox",
});
}
return sandbox;
};
const stripSecrets = <T extends { envVars: string | null }>(sandbox: T) => {
const { envVars: _envVars, ...rest } = sandbox;
return rest;
};
export const sandboxRouter = createTRPCRouter({
templates: protectedProcedure.query(() =>
Object.entries(SANDBOX_TEMPLATES).map(([name, template]) => ({
name,
...template,
})),
),
create: protectedProcedure
.input(apiCreateSandbox)
.mutation(async ({ input, ctx }) => {
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
await checkServiceAccess(ctx, project.projectId, "create");
if (project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this project",
});
}
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a sandbox",
});
}
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const sandbox = await createSandbox(input);
await addNewService(ctx, sandbox.sandboxId);
await audit(ctx, {
action: "create",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return stripSecrets(sandbox);
}),
one: protectedProcedure
.input(apiFindOneSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
return stripSecrets(sandbox);
}),
list: protectedProcedure
.input(apiListSandboxes)
.query(async ({ input, ctx }) => {
let sandboxes: Awaited<ReturnType<typeof findSandboxesByEnvironmentId>>;
if (input.environmentId) {
await checkEnvironmentAccess(ctx, input.environmentId, "read");
const environment = await findEnvironmentById(input.environmentId);
if (
environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
sandboxes = await findSandboxesByEnvironmentId(input.environmentId);
} else {
const project = await findProjectById(input.projectId ?? "");
if (project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!member.accessedProjects.includes(project.projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
sandboxes = await findSandboxesByProjectId(project.projectId);
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
sandboxes = sandboxes.filter((sandbox) =>
member.accessedServices.includes(sandbox.sandboxId),
);
}
return sandboxes.map(stripSecrets);
}),
exec: protectedProcedure
.input(apiExecSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const result = await execInSandbox(sandbox, {
cmd: input.cmd,
cwd: input.cwd,
env: input.env,
timeoutMs: input.timeoutMs,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
timedOut: result.timedOut,
truncated: result.truncated,
};
}),
writeFile: protectedProcedure
.input(apiWriteFileSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
return writeSandboxFile(sandbox, {
path: input.path,
content: Buffer.from(input.content, input.encoding),
mode: input.mode,
});
}),
readFile: protectedProcedure
.input(apiReadFileSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
const file = await readSandboxFile(sandbox, input.path);
return {
path: file.path,
size: file.size,
encoding: input.encoding,
content: file.content.toString(input.encoding),
};
}),
listFiles: protectedProcedure
.input(apiListFilesSandbox)
.query(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "read");
return listSandboxFiles(sandbox, input.path);
}),
setTimeout: protectedProcedure
.input(apiSetTimeoutSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const updated = await setSandboxTimeout(sandbox, input.timeoutMs);
await audit(ctx, {
action: "update",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
metadata: { timeoutMs: input.timeoutMs },
});
return updated ? stripSecrets(updated) : null;
}),
kill: protectedProcedure
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(ctx, input.sandboxId, "use");
const killed = await killSandbox(sandbox.sandboxId);
await audit(ctx, {
action: "stop",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return killed ? stripSecrets(killed) : null;
}),
remove: protectedProcedure
.input(apiFindOneSandbox)
.mutation(async ({ input, ctx }) => {
const sandbox = await findAccessibleSandbox(
ctx,
input.sandboxId,
"delete",
);
await removeSandbox(sandbox.sandboxId);
await audit(ctx, {
action: "delete",
resourceType: "sandbox",
resourceId: sandbox.sandboxId,
resourceName: sandbox.name,
});
return true;
}),
});
@@ -707,6 +707,7 @@ export const settingsRouter = createTRPCRouter({
"licenseKey",
"organization",
"previewDeployment",
"sandbox",
],
});
+4
View File
@@ -8,6 +8,7 @@ import {
initCronJobs,
initEnterpriseBackupCronJobs,
initializeNetwork,
initSandboxReaper,
initSchedules,
initVolumeBackupsCronJobs,
sendDokployRestartNotifications,
@@ -21,6 +22,7 @@ import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-contai
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
import { setupDrawerLogsWebSocketServer } from "./wss/drawer-logs";
import { setupDeploymentLogsWebSocketServer } from "./wss/listen-deployment";
import { setupSandboxExecWebSocketServer } from "./wss/sandbox-exec";
import { setupTerminalWebSocketServer } from "./wss/terminal";
config({ path: ".env" });
@@ -52,6 +54,7 @@ void app.prepare().then(async () => {
setupDockerContainerLogsWebSocketServer(server);
setupDockerContainerTerminalWebSocketServer(server);
setupTerminalWebSocketServer(server);
setupSandboxExecWebSocketServer(server);
if (!IS_CLOUD) {
setupDockerStatsMonitoringSocketServer(server);
}
@@ -68,6 +71,7 @@ void app.prepare().then(async () => {
await sendDokployRestartNotifications();
}
await initEnterpriseBackupCronJobs();
void initSandboxReaper();
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
+120
View File
@@ -0,0 +1,120 @@
import type http from "node:http";
import {
execInSandbox,
findSandboxById,
validateRequest,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { type WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { apiExecSandbox, SANDBOX_DEFAULTS } from "@/server/db/schema";
const execMessage = apiExecSandbox.omit({ sandboxId: true }).extend({
type: z.literal("exec"),
});
type ServerMessage =
| { type: "ready"; sandboxId: string }
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "exit"; exitCode: number; timedOut: boolean }
| { type: "error"; message: string };
const send = (ws: WebSocket, message: ServerMessage) => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
};
export const setupSandboxExecWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
const wss = new WebSocketServer({ noServer: true, path: "/sandbox-exec" });
server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
if (pathname === "/sandbox-exec") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
});
wss.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sandboxId = url.searchParams.get("sandboxId");
if (!sandboxId) {
ws.close(4000, "sandboxId not provided");
return;
}
const { user, session } = await validateRequest(req);
if (!user || !session?.activeOrganizationId) {
ws.close(4001, "Unauthorized");
return;
}
const ctx = {
user: { id: user.id },
session: { activeOrganizationId: session.activeOrganizationId },
};
try {
await checkServicePermissionAndAccess(ctx, sandboxId, {
deployment: ["create"],
});
const sandbox = await findSandboxById(sandboxId);
if (
sandbox.environment.project.organizationId !==
session.activeOrganizationId
) {
ws.close(4003, "Not authorized");
return;
}
} catch {
ws.close(4003, "Not authorized");
return;
}
let running = false;
send(ws, { type: "ready", sandboxId });
ws.on("message", async (raw) => {
let parsed: z.infer<typeof execMessage>;
try {
parsed = execMessage.parse(JSON.parse(raw.toString()));
} catch (error) {
send(ws, {
type: "error",
message: error instanceof Error ? error.message : "Invalid message",
});
return;
}
if (running) {
send(ws, { type: "error", message: "A command is already running" });
return;
}
running = true;
try {
const sandbox = await findSandboxById(sandboxId);
const result = await execInSandbox(sandbox, {
cmd: parsed.cmd,
cwd: parsed.cwd,
env: parsed.env,
timeoutMs: parsed.timeoutMs ?? SANDBOX_DEFAULTS.execTimeoutMs,
onStdout: (data) => send(ws, { type: "stdout", data }),
onStderr: (data) => send(ws, { type: "stderr", data }),
});
send(ws, {
type: "exit",
exitCode: result.exitCode,
timedOut: result.timedOut,
});
} catch (error) {
send(ws, {
type: "error",
message: error instanceof Error ? error.message : String(error),
});
} finally {
running = false;
}
});
});
};