mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
feat(sandbox): tRPC router, OpenAPI exposure and streaming exec websocket
This commit is contained in:
@@ -62,6 +62,7 @@ async function generateOpenAPI() {
|
||||
"rollback",
|
||||
"volumeBackups",
|
||||
"environment",
|
||||
"sandbox",
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user