Files
dokploy/apps/dokploy/server/api/routers/volume-backups.ts
Mauricio Siu 018e2b153e fix: add cross-org ownership checks to cluster, deployment, backup, and WebSocket endpoints
Prevents owner/admin users of one organization from accessing servers,
destinations, and Docker Swarm join tokens belonging to other organizations
by validating organizationId on all endpoints that accept serverId or
destinationId as direct input.

- cluster: validate serverId org on getNodes, addWorker, addManager, removeWorker
- deployment: validate serverId org on allByServer
- backup: validate destinationId + serverId org on listBackupFiles
- volume-backups: validate destinationId + serverId org on restoreVolumeBackupWithLogs
- wss: validate server org on docker-container-logs, docker-container-terminal,
  listen-deployment, and terminal WebSocket handlers
- auth: fix TypeScript type for API key metadata parsing
2026-04-24 12:44:42 -06:00

341 lines
9.0 KiB
TypeScript

import {
createVolumeBackup,
findVolumeBackupById,
IS_CLOUD,
removeVolumeBackup,
removeVolumeBackupJob,
restoreVolume,
runVolumeBackup,
scheduleVolumeBackup,
updateVolumeBackup,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
createVolumeBackupSchema,
updateVolumeBackupSchema,
volumeBackups,
} from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import {
execAsyncRemote,
execAsyncStream,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const volumeBackupsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
id: z.string().min(1),
volumeBackupType: z.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
"libsql",
]),
}),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
volumeBackup: ["read"],
});
return await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
with: {
application: true,
postgres: true,
mysql: true,
mariadb: true,
mongo: true,
redis: true,
compose: true,
libsql: true,
},
orderBy: [desc(volumeBackups.createdAt)],
});
}),
create: protectedProcedure
.input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const serviceId =
input.applicationId ||
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.redisId ||
input.libsqlId ||
input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) {
if (IS_CLOUD) {
await schedule({
cronSchedule: newVolumeBackup.cronExpression,
volumeBackupId: newVolumeBackup.volumeBackupId,
type: "volume-backup",
});
} else {
await scheduleVolumeBackup(newVolumeBackup.volumeBackupId);
}
}
await audit(ctx, {
action: "create",
resourceType: "volumeBackup",
resourceId: newVolumeBackup?.volumeBackupId,
});
return newVolumeBackup;
}),
one: protectedProcedure
.input(
z.object({
volumeBackupId: z.string().min(1),
}),
)
.query(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["read"],
});
}
return vb;
}),
delete: protectedProcedure
.input(
z.object({
volumeBackupId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["delete"],
});
}
const result = await removeVolumeBackup(input.volumeBackupId);
await audit(ctx, {
action: "delete",
resourceType: "volumeBackup",
resourceId: input.volumeBackupId,
});
return result;
}),
update: protectedProcedure
.input(updateVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const existingVb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
existingVb.applicationId ||
existingVb.postgresId ||
existingVb.mysqlId ||
existingVb.mariadbId ||
existingVb.mongoId ||
existingVb.redisId ||
existingVb.libsqlId ||
existingVb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["update"],
});
}
const updatedVolumeBackup = await updateVolumeBackup(
input.volumeBackupId,
input,
);
if (!updatedVolumeBackup) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Volume backup not found",
});
}
if (IS_CLOUD) {
if (updatedVolumeBackup.enabled) {
await updateJob({
cronSchedule: updatedVolumeBackup.cronExpression,
volumeBackupId: updatedVolumeBackup.volumeBackupId,
type: "volume-backup",
});
} else {
await removeJob({
cronSchedule: updatedVolumeBackup.cronExpression,
volumeBackupId: updatedVolumeBackup.volumeBackupId,
type: "volume-backup",
});
}
} else {
if (updatedVolumeBackup?.enabled) {
removeVolumeBackupJob(updatedVolumeBackup.volumeBackupId);
scheduleVolumeBackup(updatedVolumeBackup.volumeBackupId);
} else {
removeVolumeBackupJob(updatedVolumeBackup.volumeBackupId);
}
}
await audit(ctx, {
action: "update",
resourceType: "volumeBackup",
resourceId: updatedVolumeBackup.volumeBackupId,
});
return updatedVolumeBackup;
}),
runManually: protectedProcedure
.input(z.object({ volumeBackupId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const vb = await findVolumeBackupById(input.volumeBackupId);
const serviceId =
vb.applicationId ||
vb.postgresId ||
vb.mysqlId ||
vb.mariadbId ||
vb.mongoId ||
vb.redisId ||
vb.libsqlId ||
vb.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
try {
const result = await runVolumeBackup(input.volumeBackupId);
await audit(ctx, {
action: "run",
resourceType: "volumeBackup",
resourceId: input.volumeBackupId,
});
return result;
} catch (error) {
console.error(error);
return false;
}
}),
restoreVolumeBackupWithLogs: withPermission("volumeBackup", "restore")
.meta({
openapi: {
enabled: false,
path: "/restore-volume-backup-with-logs",
method: "POST",
override: true,
},
})
.input(
z.object({
backupFileName: z.string().min(1),
destinationId: z.string().min(1),
volumeName: z.string().min(1),
id: z.string().min(1),
serviceType: z.enum(["application", "compose"]),
serverId: z.string().optional(),
}),
)
.subscription(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
return observable<string>((emit) => {
const runRestore = async () => {
try {
emit.next("🚀 Starting volume restore process...");
emit.next(`📂 Backup File: ${input.backupFileName}`);
emit.next(`🔧 Volume Name: ${input.volumeName}`);
emit.next(`🏷️ Service Type: ${input.serviceType}`);
emit.next(""); // Empty line for better readability
// Generate the restore command
const restoreCommand = await restoreVolume(
input.id,
input.destinationId,
input.volumeName,
input.backupFileName,
input.serverId || "",
input.serviceType,
);
emit.next("📋 Generated restore command:");
emit.next("▶️ Executing restore...");
emit.next(""); // Empty line
// Execute the restore command with real-time output
if (input.serverId) {
emit.next(`🌐 Executing on remote server: ${input.serverId}`);
await execAsyncRemote(input.serverId, restoreCommand, (data) => {
emit.next(data);
});
} else {
emit.next("🖥️ Executing on local server");
await execAsyncStream(restoreCommand, (data) => {
emit.next(data);
});
}
emit.next("");
emit.next("✅ Volume restore completed successfully!");
emit.next(
"🎉 All containers/services have been restarted with the restored volume.",
);
} catch {
emit.next("");
emit.next("❌ Volume restore failed!");
} finally {
emit.complete();
}
};
// Start the restore process
runRestore();
});
}),
});