diff --git a/apps/dokploy/scripts/generate-openapi.ts b/apps/dokploy/scripts/generate-openapi.ts index 16f826474..64081e334 100644 --- a/apps/dokploy/scripts/generate-openapi.ts +++ b/apps/dokploy/scripts/generate-openapi.ts @@ -62,6 +62,7 @@ async function generateOpenAPI() { "rollback", "volumeBackups", "environment", + "sandbox", ], }); diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 64d61d3fb..e15262489 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -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 diff --git a/apps/dokploy/server/api/routers/sandbox.ts b/apps/dokploy/server/api/routers/sandbox.ts new file mode 100644 index 000000000..ff9f6866b --- /dev/null +++ b/apps/dokploy/server/api/routers/sandbox.ts @@ -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 = (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>; + 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; + }), +}); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index db2951ae4..b04553b02 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -707,6 +707,7 @@ export const settingsRouter = createTRPCRouter({ "licenseKey", "organization", "previewDeployment", + "sandbox", ], }); diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 5fe048c92..39b64bd28 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -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"); diff --git a/apps/dokploy/server/wss/sandbox-exec.ts b/apps/dokploy/server/wss/sandbox-exec.ts new file mode 100644 index 000000000..b536daebe --- /dev/null +++ b/apps/dokploy/server/wss/sandbox-exec.ts @@ -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, +) => { + 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; + 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; + } + }); + }); +};