From 7659420d88f02535f9456279cb5853fe79349912 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Dec 2025 21:12:20 +0000 Subject: [PATCH 01/11] feat: add S3 backup encryption at rest Add support for encrypting backups before uploading to S3 and decrypting during restore. This includes: - Database schema changes: - Added encryptionEnabled, encryptionMethod, and encryptionKey fields to the destination table - Created migration 0130_add_destination_encryption.sql - Server-side backup encryption: - Added getEncryptionCommand/getDecryptionCommand utilities - Updated all backup handlers (postgres, mysql, mariadb, mongo, compose, web-server) to encrypt backups when enabled - Encrypted backups have .enc extension appended - Server-side restore decryption: - Updated all restore handlers to detect encrypted backups and decrypt them during restore - Added isEncryptedBackup utility to check file extensions - UI changes: - Added encryption settings section to destination configuration - Toggle for enabling/disabling encryption - Dropdown for selecting encryption method (AES-256-CBC, AES-256-GCM) - Input for encryption key with generate button - Warning about storing keys securely Encryption uses OpenSSL with PBKDF2 key derivation (100,000 iterations) for secure password-based encryption. --- .../destination/handle-destinations.tsx | 169 ++++++++++++++++-- .../0130_add_destination_encryption.sql | 9 + apps/dokploy/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema/destination.ts | 25 ++- packages/server/src/utils/backups/compose.ts | 13 +- packages/server/src/utils/backups/mariadb.ts | 13 +- packages/server/src/utils/backups/mongo.ts | 13 +- packages/server/src/utils/backups/mysql.ts | 13 +- packages/server/src/utils/backups/postgres.ts | 13 +- packages/server/src/utils/backups/utils.ts | 71 +++++++- .../server/src/utils/backups/web-server.ts | 27 ++- packages/server/src/utils/restore/compose.ts | 22 ++- packages/server/src/utils/restore/mariadb.ts | 19 +- packages/server/src/utils/restore/mongo.ts | 13 +- packages/server/src/utils/restore/mysql.ts | 19 +- packages/server/src/utils/restore/postgres.ts | 19 +- packages/server/src/utils/restore/utils.ts | 49 ++++- .../server/src/utils/restore/web-server.ts | 35 +++- 18 files changed, 502 insertions(+), 47 deletions(-) create mode 100644 apps/dokploy/drizzle/0130_add_destination_encryption.sql diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index d36d53af26..6ea58e215b 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { KeyRound, PenBoxIcon, PlusIcon, RefreshCw, Shield } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -18,6 +18,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -33,23 +34,54 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { S3_PROVIDERS } from "./constants"; -const addDestination = z.object({ - name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), -}); +const ENCRYPTION_METHODS = [ + { key: "aes-256-cbc", name: "AES-256-CBC" }, + { key: "aes-256-gcm", name: "AES-256-GCM" }, +] as const; + +const addDestination = z + .object({ + name: z.string().min(1, "Name is required"), + provider: z.string().min(1, "Provider is required"), + accessKeyId: z.string().min(1, "Access Key Id is required"), + secretAccessKey: z.string().min(1, "Secret Access Key is required"), + bucket: z.string().min(1, "Bucket is required"), + region: z.string(), + endpoint: z.string().min(1, "Endpoint is required"), + serverId: z.string().optional(), + encryptionEnabled: z.boolean().optional(), + encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), + encryptionKey: z.string().optional(), + }) + .refine( + (data) => { + if (data.encryptionEnabled) { + return data.encryptionMethod && data.encryptionKey; + } + return true; + }, + { + message: + "Encryption method and key are required when encryption is enabled", + path: ["encryptionKey"], + }, + ); type AddDestination = z.infer; +const generateEncryptionKey = (): string => { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +}; + interface Props { destinationId?: string; } @@ -89,9 +121,15 @@ export const HandleDestinations = ({ destinationId }: Props) => { region: "", secretAccessKey: "", endpoint: "", + encryptionEnabled: false, + encryptionMethod: undefined, + encryptionKey: "", }, resolver: zodResolver(addDestination), }); + + const encryptionEnabled = form.watch("encryptionEnabled"); + useEffect(() => { if (destination) { form.reset({ @@ -102,6 +140,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { bucket: destination.bucket, region: destination.region, endpoint: destination.endpoint, + encryptionEnabled: destination.encryptionEnabled ?? false, + encryptionMethod: destination.encryptionMethod ?? undefined, + encryptionKey: destination.encryptionKey ?? "", }); } else { form.reset(); @@ -118,6 +159,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { region: data.region, secretAccessKey: data.secretAccessKey, destinationId: destinationId || "", + encryptionEnabled: data.encryptionEnabled, + encryptionMethod: data.encryptionMethod, + encryptionKey: data.encryptionKey, }) .then(async () => { toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); @@ -355,6 +399,109 @@ export const HandleDestinations = ({ destinationId }: Props) => { )} /> + + {/* Encryption Settings */} +
+
+ + + Backup Encryption (At Rest) + +
+ + ( + +
+ Enable Encryption + + Encrypt backups before uploading to S3 + +
+ + + +
+ )} + /> + + {encryptionEnabled && ( + <> + ( + + Encryption Method + + + + + + )} + /> + + ( + + Encryption Key +
+ + + + +
+ + + Store this key securely. Lost keys cannot be + recovered and encrypted backups will be unrecoverable. + + +
+ )} + /> + + )} +
statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "encryptionEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "encryptionMethod" "encryptionMethod";--> statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "encryptionKey" text; diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 2284f3ef4b..fe6fd83d8c 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -911,6 +911,13 @@ "when": 1765136384035, "tag": "0129_pale_roughhouse", "breakpoints": true + }, + { + "idx": 130, + "version": "7", + "when": 1765200000000, + "tag": "0130_add_destination_encryption", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index d1442a9a95..0098925f45 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -1,11 +1,16 @@ import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { boolean, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; import { organization } from "./account"; import { backups } from "./backups"; +export const encryptionMethod = pgEnum("encryptionMethod", [ + "aes-256-cbc", + "aes-256-gcm", +]); + export const destinations = pgTable("destination", { destinationId: text("destinationId") .notNull() @@ -22,6 +27,9 @@ export const destinations = pgTable("destination", { .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp("createdAt").notNull().defaultNow(), + encryptionEnabled: boolean("encryptionEnabled").notNull().default(false), + encryptionMethod: encryptionMethod("encryptionMethod"), + encryptionKey: text("encryptionKey"), }); export const destinationsRelations = relations( @@ -44,6 +52,9 @@ const createSchema = createInsertSchema(destinations, { endpoint: z.string(), secretAccessKey: z.string(), region: z.string(), + encryptionEnabled: z.boolean().optional(), + encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), + encryptionKey: z.string().optional(), }); export const apiCreateDestination = createSchema @@ -55,10 +66,16 @@ export const apiCreateDestination = createSchema region: true, endpoint: true, secretAccessKey: true, + encryptionEnabled: true, + encryptionMethod: true, + encryptionKey: true, }) .required() .extend({ serverId: z.string().optional(), + encryptionEnabled: z.boolean().optional(), + encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), + encryptionKey: z.string().optional(), }); export const apiFindOneDestination = createSchema @@ -83,8 +100,14 @@ export const apiUpdateDestination = createSchema secretAccessKey: true, destinationId: true, provider: true, + encryptionEnabled: true, + encryptionMethod: true, + encryptionKey: true, }) .required() .extend({ serverId: z.string().optional(), + encryptionEnabled: z.boolean().optional(), + encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), + encryptionKey: z.string().optional(), }); diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 1963f2c910..0822e086c4 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -8,7 +8,12 @@ import { findEnvironmentById } from "@dokploy/server/services/environment"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runComposeBackup = async ( compose: Compose, @@ -19,7 +24,10 @@ export const runComposeBackup = async ( const project = await findProjectById(environment.projectId); const { prefix, databaseType } = backup; const destination = backup.destination; - const backupFileName = `${new Date().toISOString()}.sql.gz`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const backupFileName = encryptionConfig.enabled + ? `${new Date().toISOString()}.sql.gz.enc` + : `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -36,6 +44,7 @@ export const runComposeBackup = async ( backup, rcloneCommand, deployment.logPath, + encryptionConfig, ); if (compose.serverId) { await execAsyncRemote(compose.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 56cb1a9aaf..998ddfb6a1 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -8,7 +8,12 @@ import type { Mariadb } from "@dokploy/server/services/mariadb"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runMariadbBackup = async ( mariadb: Mariadb, @@ -19,7 +24,10 @@ export const runMariadbBackup = async ( const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const backupFileName = `${new Date().toISOString()}.sql.gz`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const backupFileName = encryptionConfig.enabled + ? `${new Date().toISOString()}.sql.gz.enc` + : `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -35,6 +43,7 @@ export const runMariadbBackup = async ( backup, rcloneCommand, deployment.logPath, + encryptionConfig, ); if (mariadb.serverId) { await execAsyncRemote(mariadb.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 2071478a0b..6fe7f64072 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -8,7 +8,12 @@ import type { Mongo } from "@dokploy/server/services/mongo"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { const { environmentId, name } = mongo; @@ -16,7 +21,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const backupFileName = `${new Date().toISOString()}.sql.gz`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const backupFileName = encryptionConfig.enabled + ? `${new Date().toISOString()}.sql.gz.enc` + : `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -32,6 +40,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { backup, rcloneCommand, deployment.logPath, + encryptionConfig, ); if (mongo.serverId) { diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index d131090fa3..27fce264a1 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -8,7 +8,12 @@ import type { MySql } from "@dokploy/server/services/mysql"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const { environmentId, name } = mysql; @@ -16,7 +21,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const backupFileName = `${new Date().toISOString()}.sql.gz`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const backupFileName = encryptionConfig.enabled + ? `${new Date().toISOString()}.sql.gz.enc` + : `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -34,6 +42,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { backup, rcloneCommand, deployment.logPath, + encryptionConfig, ); if (mysql.serverId) { diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 9241f2103a..26c89b0d31 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -8,7 +8,12 @@ import type { Postgres } from "@dokploy/server/services/postgres"; import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; +import { + getBackupCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runPostgresBackup = async ( postgres: Postgres, @@ -25,7 +30,10 @@ export const runPostgresBackup = async ( }); const { prefix } = backup; const destination = backup.destination; - const backupFileName = `${new Date().toISOString()}.sql.gz`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const backupFileName = encryptionConfig.enabled + ? `${new Date().toISOString()}.sql.gz.enc` + : `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); @@ -37,6 +45,7 @@ export const runPostgresBackup = async ( backup, rcloneCommand, deployment.logPath, + encryptionConfig, ); if (postgres.serverId) { await execAsyncRemote(postgres.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index f30577a53b..83feb5da42 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -10,6 +10,58 @@ import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; import { runWebServerBackup } from "./web-server"; +export type EncryptionMethod = "aes-256-cbc" | "aes-256-gcm"; + +export interface EncryptionConfig { + enabled: boolean; + method?: EncryptionMethod | null; + key?: string | null; +} + +export const getEncryptionCommand = (config: EncryptionConfig): string => { + if (!config.enabled || !config.method || !config.key) { + return ""; + } + + const escapedKey = config.key.replace(/'/g, "'\\''"); + + switch (config.method) { + case "aes-256-cbc": + return `openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; + case "aes-256-gcm": + return `openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; + default: + return ""; + } +}; + +export const getDecryptionCommand = (config: EncryptionConfig): string => { + if (!config.enabled || !config.method || !config.key) { + return ""; + } + + const escapedKey = config.key.replace(/'/g, "'\\''"); + + switch (config.method) { + case "aes-256-cbc": + return `openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; + case "aes-256-gcm": + return `openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; + default: + return ""; + } +}; + +export const getEncryptionConfigFromDestination = ( + destination: Destination, +): EncryptionConfig => { + return { + enabled: destination.encryptionEnabled ?? false, + method: destination.encryptionMethod as EncryptionMethod | null, + key: destination.encryptionKey, + }; +}; + export const scheduleBackup = (backup: BackupSchedule) => { const { schedule, @@ -220,9 +272,14 @@ export const getBackupCommand = ( backup: BackupSchedule, rcloneCommand: string, logPath: string, + encryptionConfig?: EncryptionConfig, ) => { const containerSearch = getContainerSearchCommand(backup); const backupCommand = generateBackupCommand(backup); + const encryptionCommand = encryptionConfig + ? getEncryptionCommand(encryptionConfig) + : ""; + const isEncrypted = encryptionConfig?.enabled && encryptionCommand; logger.info( { @@ -230,13 +287,23 @@ export const getBackupCommand = ( backupCommand, rcloneCommand, logPath, + encryptionEnabled: isEncrypted, }, `Executing backup command: ${backup.databaseType} ${backup.backupType}`, ); + const pipelineCommand = isEncrypted + ? `${backupCommand} | ${encryptionCommand} | ${rcloneCommand}` + : `${backupCommand} | ${rcloneCommand}`; + + const encryptionLogMessage = isEncrypted + ? `echo "[$(date)] 🔐 Encryption enabled (${encryptionConfig?.method})" >> ${logPath};` + : ""; + return ` set -eo pipefail; echo "[$(date)] Starting backup process..." >> ${logPath}; + ${encryptionLogMessage} echo "[$(date)] Executing backup command..." >> ${logPath}; CONTAINER_ID=$(${containerSearch}) @@ -255,10 +322,10 @@ export const getBackupCommand = ( } echo "[$(date)] ✅ backup completed successfully" >> ${logPath}; - echo "[$(date)] Starting upload to S3..." >> ${logPath}; + echo "[$(date)] Starting upload to S3${isEncrypted ? " (encrypted)" : ""}..." >> ${logPath}; # Run the upload command and capture the exit status - UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || { + UPLOAD_OUTPUT=$(${pipelineCommand} 2>&1 >/dev/null) || { echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath}; echo "Error: $UPLOAD_OUTPUT" >> ${logPath}; exit 1; diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 4d13ae31ae..cf31da73ec 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -10,7 +10,12 @@ import { } from "@dokploy/server/services/deployment"; import { findDestinationById } from "@dokploy/server/services/destination"; import { execAsync } from "../process/execAsync"; -import { getS3Credentials, normalizeS3Path } from "./utils"; +import { + getEncryptionCommand, + getEncryptionConfigFromDestination, + getS3Credentials, + normalizeS3Path, +} from "./utils"; export const runWebServerBackup = async (backup: BackupSchedule) => { if (IS_CLOUD) { @@ -26,11 +31,15 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { try { const destination = await findDestinationById(backup.destinationId); + const encryptionConfig = getEncryptionConfigFromDestination(destination); const rcloneFlags = getS3Credentials(destination); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); - const backupFileName = `webserver-backup-${timestamp}.zip`; + const baseBackupFileName = `webserver-backup-${timestamp}.zip`; + const backupFileName = encryptionConfig.enabled + ? `${baseBackupFileName}.enc` + : baseBackupFileName; const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { @@ -74,15 +83,25 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { await execAsync( // Zip all .sql files since we created more than one - `cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`, + `cd ${tempDir} && zip -r ${baseBackupFileName} *.sql filesystem/ > /dev/null 2>&1`, ); writeStream.write("Zipped database and filesystem\n"); + // Encrypt the backup if encryption is enabled + if (encryptionConfig.enabled && encryptionConfig.method && encryptionConfig.key) { + const encryptionCommand = getEncryptionCommand(encryptionConfig); + writeStream.write(`🔐 Encrypting backup with ${encryptionConfig.method}...\n`); + await execAsync( + `cd ${tempDir} && cat "${baseBackupFileName}" | ${encryptionCommand} > "${backupFileName}"`, + ); + writeStream.write("Backup encrypted successfully\n"); + } + const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`; writeStream.write("Running command to upload backup to S3\n"); await execAsync(uploadCommand); - writeStream.write("Uploaded backup to S3 ✅\n"); + writeStream.write(`Uploaded backup to S3${encryptionConfig.enabled ? " (encrypted)" : ""} ✅\n`); writeStream.end(); await updateDeploymentStatus(deployment.deploymentId, "done"); return true; diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index d55b12fd8b..c0c23e9fba 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -2,9 +2,12 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Destination } from "@dokploy/server/services/destination"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand } from "./utils"; +import { getRestoreCommand, isEncryptedBackup } from "./utils"; interface DatabaseCredentials { databaseUser?: string; @@ -25,11 +28,19 @@ export const restoreComposeBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupInput.backupFile); const backupPath = `${bucketPath}/${backupInput.backupFile}`; - let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + let rcloneCommand: string; if (backupInput.metadata?.mongo) { + // Mongo uses rclone copy regardless of encryption rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; + } else if (isEncrypted) { + // For encrypted non-mongo, don't decompress - getRestoreCommand handles it + rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`; + } else { + rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; } let credentials: DatabaseCredentials; @@ -69,10 +80,15 @@ export const restoreComposeBackup = async ( }, restoreType: composeType, rcloneCommand, + backupFile: backupInput.backupFile, + encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); emit(`Backup path: ${backupPath}`); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt during restore"); + } emit(`Executing command: ${restoreCommand}`); diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts index ffbceba765..860d06acef 100644 --- a/packages/server/src/utils/restore/mariadb.ts +++ b/packages/server/src/utils/restore/mariadb.ts @@ -2,9 +2,12 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mariadb } from "@dokploy/server/services/mariadb"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand } from "./utils"; +import { getRestoreCommand, isEncryptedBackup } from "./utils"; export const restoreMariadbBackup = async ( mariadb: Mariadb, @@ -17,9 +20,14 @@ export const restoreMariadbBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupInput.backupFile); const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // For encrypted files, we don't decompress here - getRestoreCommand handles decryption + const rcloneCommand = isEncrypted + ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` + : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; const command = getRestoreCommand({ appName, @@ -31,9 +39,14 @@ export const restoreMariadbBackup = async ( type: "mariadb", rcloneCommand, restoreType: "database", + backupFile: backupInput.backupFile, + encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt during restore"); + } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts index 4329a49857..6abb363ce4 100644 --- a/packages/server/src/utils/restore/mongo.ts +++ b/packages/server/src/utils/restore/mongo.ts @@ -2,9 +2,12 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mongo } from "@dokploy/server/services/mongo"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand } from "./utils"; +import { getRestoreCommand, isEncryptedBackup } from "./utils"; export const restoreMongoBackup = async ( mongo: Mongo, @@ -17,6 +20,8 @@ export const restoreMongoBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupInput.backupFile); const backupPath = `${bucketPath}/${backupInput.backupFile}`; const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; @@ -31,9 +36,13 @@ export const restoreMongoBackup = async ( restoreType: "database", rcloneCommand, backupFile: backupInput.backupFile, + encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt during restore"); + } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts index f5187242cf..3152386301 100644 --- a/packages/server/src/utils/restore/mysql.ts +++ b/packages/server/src/utils/restore/mysql.ts @@ -2,9 +2,12 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { MySql } from "@dokploy/server/services/mysql"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand } from "./utils"; +import { getRestoreCommand, isEncryptedBackup } from "./utils"; export const restoreMySqlBackup = async ( mysql: MySql, @@ -17,9 +20,14 @@ export const restoreMySqlBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupInput.backupFile); const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // For encrypted files, we don't decompress here - getRestoreCommand handles decryption + const rcloneCommand = isEncrypted + ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` + : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; const command = getRestoreCommand({ appName, @@ -30,9 +38,14 @@ export const restoreMySqlBackup = async ( }, restoreType: "database", rcloneCommand, + backupFile: backupInput.backupFile, + encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt during restore"); + } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 19f32989f0..70925f452b 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -2,9 +2,12 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Postgres } from "@dokploy/server/services/postgres"; import type { z } from "zod"; -import { getS3Credentials } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand } from "./utils"; +import { getRestoreCommand, isEncryptedBackup } from "./utils"; export const restorePostgresBackup = async ( postgres: Postgres, @@ -17,13 +20,21 @@ export const restorePostgresBackup = async ( const rcloneFlags = getS3Credentials(destination); const bucketPath = `:s3:${destination.bucket}`; + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupInput.backupFile); const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // For encrypted files, we don't decompress here - getRestoreCommand handles decryption + const rcloneCommand = isEncrypted + ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` + : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; emit("Starting restore..."); emit(`Backup path: ${backupPath}`); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt during restore"); + } const command = getRestoreCommand({ appName, @@ -34,6 +45,8 @@ export const restorePostgresBackup = async ( type: "postgres", rcloneCommand, restoreType: "database", + backupFile: backupInput.backupFile, + encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 23052e642b..6e29e6af08 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -1,8 +1,14 @@ import { + type EncryptionConfig, getComposeContainerCommand, + getDecryptionCommand, getServiceContainerCommand, } from "../backups/utils"; +export const isEncryptedBackup = (backupFile: string): boolean => { + return backupFile.endsWith(".enc"); +}; + export const getPostgresRestoreCommand = ( database: string, databaseUser: string, @@ -79,9 +85,32 @@ const getMongoSpecificCommand = ( rcloneCommand: string, restoreCommand: string, backupFile: string, + encryptionConfig?: EncryptionConfig, ): string => { const tempDir = "/tmp/dokploy-restore"; const fileName = backupFile.split("/").pop() || "backup.sql.gz"; + const isEncrypted = isEncryptedBackup(backupFile); + const decryptionCommand = encryptionConfig + ? getDecryptionCommand(encryptionConfig) + : ""; + + if (isEncrypted && decryptionCommand) { + // For encrypted mongo backups: download -> decrypt -> decompress -> restore + const decryptedName = fileName.replace(".enc", ""); + const decompressedName = decryptedName.replace(".gz", ""); + return ` +rm -rf ${tempDir} && \ +mkdir -p ${tempDir} && \ +${rcloneCommand} ${tempDir} && \ +cd ${tempDir} && \ +cat "${fileName}" | ${decryptionCommand} > "${decryptedName}" && \ +gunzip -f "${decryptedName}" && \ +${restoreCommand} < "${decompressedName}" && \ +rm -rf ${tempDir} + `; + } + + // Original non-encrypted flow const decompressedName = fileName.replace(".gz", ""); return ` rm -rf ${tempDir} && \ @@ -102,6 +131,7 @@ interface RestoreOptions { serviceName?: string; rcloneCommand: string; backupFile?: string; + encryptionConfig?: EncryptionConfig; } export const getRestoreCommand = ({ @@ -112,6 +142,7 @@ export const getRestoreCommand = ({ serviceName, rcloneCommand, backupFile, + encryptionConfig, }: RestoreOptions) => { const containerSearch = getComposeSearchCommand( appName, @@ -121,10 +152,24 @@ export const getRestoreCommand = ({ const restoreCommand = generateRestoreCommand(type, credentials); let cmd = `CONTAINER_ID=$(${containerSearch})`; + // Detect if backup is encrypted based on file extension + const isEncrypted = backupFile ? isEncryptedBackup(backupFile) : false; + const decryptionCommand = + isEncrypted && encryptionConfig + ? getDecryptionCommand(encryptionConfig) + : ""; + if (type !== "mongo") { - cmd += ` && ${rcloneCommand} | ${restoreCommand}`; + // For non-mongo databases: rclone cat | [decrypt | gunzip |] restore + // Note: for encrypted backups, rcloneCommand doesn't include gunzip, + // so we need to add it after decryption + if (isEncrypted && decryptionCommand) { + cmd += ` && ${rcloneCommand} | ${decryptionCommand} | gunzip | ${restoreCommand}`; + } else { + cmd += ` && ${rcloneCommand} | ${restoreCommand}`; + } } else { - cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`; + cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "", encryptionConfig)}`; } return cmd; diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index 683a1898ae..1e8e7b3908 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -3,8 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Destination } from "@dokploy/server/services/destination"; -import { getS3Credentials } from "../backups/utils"; +import { + getDecryptionCommand, + getEncryptionConfigFromDestination, + getS3Credentials, +} from "../backups/utils"; import { execAsync } from "../process/execAsync"; +import { isEncryptedBackup } from "./utils"; export const restoreWebServerBackup = async ( destination: Destination, @@ -19,6 +24,11 @@ export const restoreWebServerBackup = async ( const bucketPath = `:s3:${destination.bucket}`; const backupPath = `${bucketPath}/${backupFile}`; const { BASE_PATH } = paths(); + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const isEncrypted = isEncryptedBackup(backupFile); + const decryptedFileName = isEncrypted + ? backupFile.replace(".enc", "") + : backupFile; // Create a temporary directory outside of BASE_PATH const tempDir = await mkdtemp(join(tmpdir(), "dokploy-restore-")); @@ -27,6 +37,9 @@ export const restoreWebServerBackup = async ( emit("Starting restore..."); emit(`Backup path: ${backupPath}`); emit(`Temp directory: ${tempDir}`); + if (isEncrypted) { + emit("🔐 Encrypted backup detected - will decrypt before extraction"); + } // Create temp directory emit("Creating temporary directory..."); @@ -38,14 +51,30 @@ export const restoreWebServerBackup = async ( `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`, ); + // Decrypt if necessary + if (isEncrypted && encryptionConfig.enabled) { + emit("Decrypting backup..."); + const decryptionCommand = getDecryptionCommand(encryptionConfig); + if (decryptionCommand) { + await execAsync( + `cd ${tempDir} && cat "${backupFile}" | ${decryptionCommand} > "${decryptedFileName}"`, + ); + // Remove the encrypted file + await execAsync(`rm -f "${tempDir}/${backupFile}"`); + emit("Backup decrypted successfully"); + } + } + // List files before extraction emit("Listing files before extraction..."); const { stdout: beforeFiles } = await execAsync(`ls -la ${tempDir}`); emit(`Files before extraction: ${beforeFiles}`); - // Extract backup + // Extract backup (use decrypted filename) emit("Extracting backup..."); - await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`); + await execAsync( + `cd ${tempDir} && unzip ${decryptedFileName} > /dev/null 2>&1`, + ); // Restore filesystem first emit("Restoring filesystem..."); From f252f967573fd992be3a94bd303fa2aeaaf036dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Dec 2025 21:27:09 +0000 Subject: [PATCH 02/11] refactor: use rclone crypt for S3 backup encryption Replace OpenSSL-based encryption with rclone's native crypt backend for better integration and simpler architecture. The crypt backend provides transparent encryption/decryption using NaCl SecretBox (XSalsa20 cipher + Poly1305 for integrity). Changes: - Remove encryptionMethod field (rclone uses its own algorithm) - Add getRcloneS3Remote() to generate crypt-wrapped remotes - Simplify backup commands (encryption handled by rclone remote) - Simplify restore commands (decryption happens automatically) - Update UI to remove encryption method selection - Simplify migration to only add encryptionEnabled and encryptionKey --- .../destination/handle-destinations.tsx | 118 ++++++------------ .../0130_add_destination_encryption.sql | 7 -- packages/server/src/db/schema/destination.ts | 13 +- packages/server/src/utils/backups/compose.ts | 14 +-- packages/server/src/utils/backups/mariadb.ts | 14 +-- packages/server/src/utils/backups/mongo.ts | 14 +-- packages/server/src/utils/backups/mysql.ts | 14 +-- packages/server/src/utils/backups/postgres.ts | 14 +-- packages/server/src/utils/backups/utils.ts | 84 ++++++------- .../server/src/utils/backups/web-server.ts | 29 ++--- packages/server/src/utils/restore/compose.ts | 31 +++-- packages/server/src/utils/restore/mariadb.ts | 23 ++-- packages/server/src/utils/restore/mongo.ts | 20 +-- packages/server/src/utils/restore/mysql.ts | 23 ++-- packages/server/src/utils/restore/postgres.ts | 26 ++-- packages/server/src/utils/restore/utils.ts | 54 +------- .../server/src/utils/restore/web-server.ts | 48 +++---- 17 files changed, 201 insertions(+), 345 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index 6ea58e215b..f3af5b0f50 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -39,11 +39,6 @@ import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { S3_PROVIDERS } from "./constants"; -const ENCRYPTION_METHODS = [ - { key: "aes-256-cbc", name: "AES-256-CBC" }, - { key: "aes-256-gcm", name: "AES-256-GCM" }, -] as const; - const addDestination = z .object({ name: z.string().min(1, "Name is required"), @@ -55,19 +50,17 @@ const addDestination = z endpoint: z.string().min(1, "Endpoint is required"), serverId: z.string().optional(), encryptionEnabled: z.boolean().optional(), - encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), encryptionKey: z.string().optional(), }) .refine( (data) => { if (data.encryptionEnabled) { - return data.encryptionMethod && data.encryptionKey; + return !!data.encryptionKey; } return true; }, { - message: - "Encryption method and key are required when encryption is enabled", + message: "Encryption key is required when encryption is enabled", path: ["encryptionKey"], }, ); @@ -122,7 +115,6 @@ export const HandleDestinations = ({ destinationId }: Props) => { secretAccessKey: "", endpoint: "", encryptionEnabled: false, - encryptionMethod: undefined, encryptionKey: "", }, resolver: zodResolver(addDestination), @@ -141,7 +133,6 @@ export const HandleDestinations = ({ destinationId }: Props) => { region: destination.region, endpoint: destination.endpoint, encryptionEnabled: destination.encryptionEnabled ?? false, - encryptionMethod: destination.encryptionMethod ?? undefined, encryptionKey: destination.encryptionKey ?? "", }); } else { @@ -160,7 +151,6 @@ export const HandleDestinations = ({ destinationId }: Props) => { secretAccessKey: data.secretAccessKey, destinationId: destinationId || "", encryptionEnabled: data.encryptionEnabled, - encryptionMethod: data.encryptionMethod, encryptionKey: data.encryptionKey, }) .then(async () => { @@ -431,75 +421,45 @@ export const HandleDestinations = ({ destinationId }: Props) => { /> {encryptionEnabled && ( - <> - ( - - Encryption Method + ( + + Encryption Key +
- + - - - )} - /> - - ( - - Encryption Key -
- - - - -
- - - Store this key securely. Lost keys cannot be - recovered and encrypted backups will be unrecoverable. - - -
- )} - /> - + +
+ + + Store this key securely. Lost keys cannot be + recovered and encrypted backups will be unrecoverable. + + +
+ )} + /> )} diff --git a/apps/dokploy/drizzle/0130_add_destination_encryption.sql b/apps/dokploy/drizzle/0130_add_destination_encryption.sql index 102827cfad..ac5fc464b2 100644 --- a/apps/dokploy/drizzle/0130_add_destination_encryption.sql +++ b/apps/dokploy/drizzle/0130_add_destination_encryption.sql @@ -1,9 +1,2 @@ -DO $$ BEGIN - CREATE TYPE "public"."encryptionMethod" AS ENUM('aes-256-cbc', 'aes-256-gcm'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint ALTER TABLE "destination" ADD COLUMN "encryptionEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "destination" ADD COLUMN "encryptionMethod" "encryptionMethod";--> statement-breakpoint ALTER TABLE "destination" ADD COLUMN "encryptionKey" text; diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index 0098925f45..c5d2252de4 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -1,16 +1,11 @@ import { relations } from "drizzle-orm"; -import { boolean, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { nanoid } from "nanoid"; import { z } from "zod"; import { organization } from "./account"; import { backups } from "./backups"; -export const encryptionMethod = pgEnum("encryptionMethod", [ - "aes-256-cbc", - "aes-256-gcm", -]); - export const destinations = pgTable("destination", { destinationId: text("destinationId") .notNull() @@ -28,7 +23,6 @@ export const destinations = pgTable("destination", { .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp("createdAt").notNull().defaultNow(), encryptionEnabled: boolean("encryptionEnabled").notNull().default(false), - encryptionMethod: encryptionMethod("encryptionMethod"), encryptionKey: text("encryptionKey"), }); @@ -53,7 +47,6 @@ const createSchema = createInsertSchema(destinations, { secretAccessKey: z.string(), region: z.string(), encryptionEnabled: z.boolean().optional(), - encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), encryptionKey: z.string().optional(), }); @@ -67,14 +60,12 @@ export const apiCreateDestination = createSchema endpoint: true, secretAccessKey: true, encryptionEnabled: true, - encryptionMethod: true, encryptionKey: true, }) .required() .extend({ serverId: z.string().optional(), encryptionEnabled: z.boolean().optional(), - encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), encryptionKey: z.string().optional(), }); @@ -101,13 +92,11 @@ export const apiUpdateDestination = createSchema destinationId: true, provider: true, encryptionEnabled: true, - encryptionMethod: true, encryptionKey: true, }) .required() .extend({ serverId: z.string().optional(), encryptionEnabled: z.boolean().optional(), - encryptionMethod: z.enum(["aes-256-cbc", "aes-256-gcm"]).optional(), encryptionKey: z.string().optional(), }); diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 0822e086c4..954f115302 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -11,7 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -25,9 +25,7 @@ export const runComposeBackup = async ( const { prefix, databaseType } = backup; const destination = backup.destination; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const backupFileName = encryptionConfig.enabled - ? `${new Date().toISOString()}.sql.gz.enc` - : `${new Date().toISOString()}.sql.gz`; + const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -36,9 +34,11 @@ export const runComposeBackup = async ( }); try { - const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}/${bucketDestination}`; + const rcloneCommand = envVars + ? `${envVars} rclone rcat "${rcloneDestination}"` + : `rclone rcat "${rcloneDestination}"`; const backupCommand = getBackupCommand( backup, diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 998ddfb6a1..b477420a2b 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -11,7 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -25,9 +25,7 @@ export const runMariadbBackup = async ( const { prefix } = backup; const destination = backup.destination; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const backupFileName = encryptionConfig.enabled - ? `${new Date().toISOString()}.sql.gz.enc` - : `${new Date().toISOString()}.sql.gz`; + const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -35,9 +33,11 @@ export const runMariadbBackup = async ( description: "MariaDB Backup", }); try { - const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}/${bucketDestination}`; + const rcloneCommand = envVars + ? `${envVars} rclone rcat "${rcloneDestination}"` + : `rclone rcat "${rcloneDestination}"`; const backupCommand = getBackupCommand( backup, diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 6fe7f64072..183f27dfab 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -11,7 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -22,9 +22,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { const { prefix } = backup; const destination = backup.destination; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const backupFileName = encryptionConfig.enabled - ? `${new Date().toISOString()}.sql.gz.enc` - : `${new Date().toISOString()}.sql.gz`; + const backupFileName = `${new Date().toISOString()}.archive.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -32,9 +30,11 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { description: "MongoDB Backup", }); try { - const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}/${bucketDestination}`; + const rcloneCommand = envVars + ? `${envVars} rclone rcat "${rcloneDestination}"` + : `rclone rcat "${rcloneDestination}"`; const backupCommand = getBackupCommand( backup, diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 27fce264a1..3af7e73e55 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -11,7 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -22,9 +22,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const { prefix } = backup; const destination = backup.destination; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const backupFileName = encryptionConfig.enabled - ? `${new Date().toISOString()}.sql.gz.enc` - : `${new Date().toISOString()}.sql.gz`; + const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, @@ -33,10 +31,12 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { }); try { - const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const rcloneCommand = envVars + ? `${envVars} rclone rcat "${rcloneDestination}"` + : `rclone rcat "${rcloneDestination}"`; const backupCommand = getBackupCommand( backup, diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 26c89b0d31..5b44809afe 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -11,7 +11,7 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -31,15 +31,15 @@ export const runPostgresBackup = async ( const { prefix } = backup; const destination = backup.destination; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const backupFileName = encryptionConfig.enabled - ? `${new Date().toISOString()}.sql.gz.enc` - : `${new Date().toISOString()}.sql.gz`; + const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { - const rcloneFlags = getS3Credentials(destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}/${bucketDestination}`; - const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`; + const rcloneCommand = envVars + ? `${envVars} rclone rcat "${rcloneDestination}"` + : `rclone rcat "${rcloneDestination}"`; const backupCommand = getBackupCommand( backup, diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 83feb5da42..e13d672e3a 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -10,55 +10,53 @@ import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; import { runWebServerBackup } from "./web-server"; -export type EncryptionMethod = "aes-256-cbc" | "aes-256-gcm"; - export interface EncryptionConfig { enabled: boolean; - method?: EncryptionMethod | null; key?: string | null; } -export const getEncryptionCommand = (config: EncryptionConfig): string => { - if (!config.enabled || !config.method || !config.key) { - return ""; - } - - const escapedKey = config.key.replace(/'/g, "'\\''"); - - switch (config.method) { - case "aes-256-cbc": - return `openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; - case "aes-256-gcm": - return `openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; - default: - return ""; - } +export const getEncryptionConfigFromDestination = ( + destination: Destination, +): EncryptionConfig => { + return { + enabled: destination.encryptionEnabled ?? false, + key: destination.encryptionKey, + }; }; -export const getDecryptionCommand = (config: EncryptionConfig): string => { - if (!config.enabled || !config.method || !config.key) { - return ""; - } +/** + * Get rclone flags for S3 credentials with optional crypt encryption overlay. + * When encryption is enabled, uses rclone's native crypt backend which provides + * file name and content encryption using NaCl SecretBox (XSalsa20 cipher + Poly1305). + */ +export const getRcloneS3Remote = ( + destination: Destination, + encryptionConfig?: EncryptionConfig, +): { remote: string; envVars: string } => { + const { accessKey, secretAccessKey, region, endpoint, provider, bucket } = + destination; - const escapedKey = config.key.replace(/'/g, "'\\''"); + // Build the base S3 remote string + let s3Remote = `:s3,access_key_id="${accessKey}",secret_access_key="${secretAccessKey}",region="${region}",endpoint="${endpoint}",no_check_bucket=true,force_path_style=true`; + if (provider) { + s3Remote = `:s3,provider="${provider}",access_key_id="${accessKey}",secret_access_key="${secretAccessKey}",region="${region}",endpoint="${endpoint}",no_check_bucket=true,force_path_style=true`; + } - switch (config.method) { - case "aes-256-cbc": - return `openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; - case "aes-256-gcm": - return `openssl enc -aes-256-cbc -d -salt -pbkdf2 -iter 100000 -pass pass:'${escapedKey}'`; - default: - return ""; + // If encryption is enabled, wrap the S3 remote with crypt + if (encryptionConfig?.enabled && encryptionConfig?.key) { + const escapedKey = encryptionConfig.key.replace(/'/g, "'\\''"); + // Use crypt overlay with the S3 remote as backend + // filename_encryption=off keeps original filenames (only content is encrypted) + const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=off:`; + return { + remote: cryptRemote, + envVars: `RCLONE_CRYPT_PASSWORD='${escapedKey}'`, + }; } -}; -export const getEncryptionConfigFromDestination = ( - destination: Destination, -): EncryptionConfig => { return { - enabled: destination.encryptionEnabled ?? false, - method: destination.encryptionMethod as EncryptionMethod | null, - key: destination.encryptionKey, + remote: `${s3Remote}:${bucket}`, + envVars: "", }; }; @@ -276,10 +274,7 @@ export const getBackupCommand = ( ) => { const containerSearch = getContainerSearchCommand(backup); const backupCommand = generateBackupCommand(backup); - const encryptionCommand = encryptionConfig - ? getEncryptionCommand(encryptionConfig) - : ""; - const isEncrypted = encryptionConfig?.enabled && encryptionCommand; + const isEncrypted = encryptionConfig?.enabled && encryptionConfig?.key; logger.info( { @@ -292,12 +287,11 @@ export const getBackupCommand = ( `Executing backup command: ${backup.databaseType} ${backup.backupType}`, ); - const pipelineCommand = isEncrypted - ? `${backupCommand} | ${encryptionCommand} | ${rcloneCommand}` - : `${backupCommand} | ${rcloneCommand}`; + // With rclone crypt, encryption is handled by the rclone remote itself + const pipelineCommand = `${backupCommand} | ${rcloneCommand}`; const encryptionLogMessage = isEncrypted - ? `echo "[$(date)] 🔐 Encryption enabled (${encryptionConfig?.method})" >> ${logPath};` + ? `echo "[$(date)] 🔐 Encryption enabled (rclone crypt)" >> ${logPath};` : ""; return ` diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index cf31da73ec..1849ab63d8 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -11,9 +11,8 @@ import { import { findDestinationById } from "@dokploy/server/services/destination"; import { execAsync } from "../process/execAsync"; import { - getEncryptionCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -32,15 +31,12 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { try { const destination = await findDestinationById(backup.destinationId); const encryptionConfig = getEncryptionConfigFromDestination(destination); - const rcloneFlags = getS3Credentials(destination); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); - const baseBackupFileName = `webserver-backup-${timestamp}.zip`; - const backupFileName = encryptionConfig.enabled - ? `${baseBackupFileName}.enc` - : baseBackupFileName; - const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`; + const backupFileName = `webserver-backup-${timestamp}.zip`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const s3Path = `${remote}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { await execAsync(`mkdir -p ${tempDir}/filesystem`); @@ -83,22 +79,19 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { await execAsync( // Zip all .sql files since we created more than one - `cd ${tempDir} && zip -r ${baseBackupFileName} *.sql filesystem/ > /dev/null 2>&1`, + `cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`, ); writeStream.write("Zipped database and filesystem\n"); - // Encrypt the backup if encryption is enabled - if (encryptionConfig.enabled && encryptionConfig.method && encryptionConfig.key) { - const encryptionCommand = getEncryptionCommand(encryptionConfig); - writeStream.write(`🔐 Encrypting backup with ${encryptionConfig.method}...\n`); - await execAsync( - `cd ${tempDir} && cat "${baseBackupFileName}" | ${encryptionCommand} > "${backupFileName}"`, - ); - writeStream.write("Backup encrypted successfully\n"); + if (encryptionConfig.enabled) { + writeStream.write("🔐 Encryption enabled (rclone crypt)\n"); } - const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`; + // With rclone crypt, encryption happens transparently through the remote + const uploadCommand = envVars + ? `${envVars} rclone copyto "${tempDir}/${backupFileName}" "${s3Path}"` + : `rclone copyto "${tempDir}/${backupFileName}" "${s3Path}"`; writeStream.write("Running command to upload backup to S3\n"); await execAsync(uploadCommand); writeStream.write(`Uploaded backup to S3${encryptionConfig.enabled ? " (encrypted)" : ""} ✅\n`); diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index c0c23e9fba..98ac96f535 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -4,10 +4,10 @@ import type { Destination } from "@dokploy/server/services/destination"; import type { z } from "zod"; import { getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand, isEncryptedBackup } from "./utils"; +import { getRestoreCommand } from "./utils"; interface DatabaseCredentials { databaseUser?: string; @@ -26,21 +26,21 @@ export const restoreComposeBackup = async ( } const { serverId, appName, composeType } = compose; - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupInput.backupFile); - const backupPath = `${bucketPath}/${backupInput.backupFile}`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupInput.backupFile}`; let rcloneCommand: string; if (backupInput.metadata?.mongo) { - // Mongo uses rclone copy regardless of encryption - rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; - } else if (isEncrypted) { - // For encrypted non-mongo, don't decompress - getRestoreCommand handles it - rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`; + // Mongo uses rclone copy + rcloneCommand = envVars + ? `${envVars} rclone copy "${backupPath}"` + : `rclone copy "${backupPath}"`; } else { - rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote + rcloneCommand = envVars + ? `${envVars} rclone cat "${backupPath}" | gunzip` + : `rclone cat "${backupPath}" | gunzip`; } let credentials: DatabaseCredentials; @@ -81,13 +81,12 @@ export const restoreComposeBackup = async ( restoreType: composeType, rcloneCommand, backupFile: backupInput.backupFile, - encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); - emit(`Backup path: ${backupPath}`); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt during restore"); + emit(`Backup file: ${backupInput.backupFile}`); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); } emit(`Executing command: ${restoreCommand}`); diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts index 860d06acef..5e6a8342c8 100644 --- a/packages/server/src/utils/restore/mariadb.ts +++ b/packages/server/src/utils/restore/mariadb.ts @@ -4,10 +4,10 @@ import type { Mariadb } from "@dokploy/server/services/mariadb"; import type { z } from "zod"; import { getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand, isEncryptedBackup } from "./utils"; +import { getRestoreCommand } from "./utils"; export const restoreMariadbBackup = async ( mariadb: Mariadb, @@ -18,16 +18,14 @@ export const restoreMariadbBackup = async ( try { const { appName, serverId, databaseUser, databasePassword } = mariadb; - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupInput.backupFile); - const backupPath = `${bucketPath}/${backupInput.backupFile}`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupInput.backupFile}`; - // For encrypted files, we don't decompress here - getRestoreCommand handles decryption - const rcloneCommand = isEncrypted - ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` - : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote + const rcloneCommand = envVars + ? `${envVars} rclone cat "${backupPath}" | gunzip` + : `rclone cat "${backupPath}" | gunzip`; const command = getRestoreCommand({ appName, @@ -40,12 +38,11 @@ export const restoreMariadbBackup = async ( rcloneCommand, restoreType: "database", backupFile: backupInput.backupFile, - encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt during restore"); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts index 6abb363ce4..bb98f9b5fb 100644 --- a/packages/server/src/utils/restore/mongo.ts +++ b/packages/server/src/utils/restore/mongo.ts @@ -4,10 +4,10 @@ import type { Mongo } from "@dokploy/server/services/mongo"; import type { z } from "zod"; import { getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand, isEncryptedBackup } from "./utils"; +import { getRestoreCommand } from "./utils"; export const restoreMongoBackup = async ( mongo: Mongo, @@ -18,12 +18,13 @@ export const restoreMongoBackup = async ( try { const { appName, databasePassword, databaseUser, serverId } = mongo; - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupInput.backupFile); - const backupPath = `${bucketPath}/${backupInput.backupFile}`; - const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupInput.backupFile}`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote + const rcloneCommand = envVars + ? `${envVars} rclone copy "${backupPath}"` + : `rclone copy "${backupPath}"`; const command = getRestoreCommand({ appName, @@ -36,12 +37,11 @@ export const restoreMongoBackup = async ( restoreType: "database", rcloneCommand, backupFile: backupInput.backupFile, - encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt during restore"); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts index 3152386301..8608ef1468 100644 --- a/packages/server/src/utils/restore/mysql.ts +++ b/packages/server/src/utils/restore/mysql.ts @@ -4,10 +4,10 @@ import type { MySql } from "@dokploy/server/services/mysql"; import type { z } from "zod"; import { getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand, isEncryptedBackup } from "./utils"; +import { getRestoreCommand } from "./utils"; export const restoreMySqlBackup = async ( mysql: MySql, @@ -18,16 +18,14 @@ export const restoreMySqlBackup = async ( try { const { appName, databaseRootPassword, serverId } = mysql; - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupInput.backupFile); - const backupPath = `${bucketPath}/${backupInput.backupFile}`; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupInput.backupFile}`; - // For encrypted files, we don't decompress here - getRestoreCommand handles decryption - const rcloneCommand = isEncrypted - ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` - : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote + const rcloneCommand = envVars + ? `${envVars} rclone cat "${backupPath}" | gunzip` + : `rclone cat "${backupPath}" | gunzip`; const command = getRestoreCommand({ appName, @@ -39,12 +37,11 @@ export const restoreMySqlBackup = async ( restoreType: "database", rcloneCommand, backupFile: backupInput.backupFile, - encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit("Starting restore..."); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt during restore"); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); } emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 70925f452b..6cb7cb4c11 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -4,10 +4,10 @@ import type { Postgres } from "@dokploy/server/services/postgres"; import type { z } from "zod"; import { getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getRestoreCommand, isEncryptedBackup } from "./utils"; +import { getRestoreCommand } from "./utils"; export const restorePostgresBackup = async ( postgres: Postgres, @@ -18,22 +18,19 @@ export const restorePostgresBackup = async ( try { const { appName, databaseUser, serverId } = postgres; - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupInput.backupFile); + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupInput.backupFile}`; - const backupPath = `${bucketPath}/${backupInput.backupFile}`; - - // For encrypted files, we don't decompress here - getRestoreCommand handles decryption - const rcloneCommand = isEncrypted - ? `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"` - : `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote + const rcloneCommand = envVars + ? `${envVars} rclone cat "${backupPath}" | gunzip` + : `rclone cat "${backupPath}" | gunzip`; emit("Starting restore..."); - emit(`Backup path: ${backupPath}`); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt during restore"); + emit(`Backup file: ${backupInput.backupFile}`); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); } const command = getRestoreCommand({ @@ -46,7 +43,6 @@ export const restorePostgresBackup = async ( rcloneCommand, restoreType: "database", backupFile: backupInput.backupFile, - encryptionConfig: isEncrypted ? encryptionConfig : undefined, }); emit(`Executing command: ${command}`); diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 6e29e6af08..4690bb5ac1 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -1,14 +1,8 @@ import { - type EncryptionConfig, getComposeContainerCommand, - getDecryptionCommand, getServiceContainerCommand, } from "../backups/utils"; -export const isEncryptedBackup = (backupFile: string): boolean => { - return backupFile.endsWith(".enc"); -}; - export const getPostgresRestoreCommand = ( database: string, databaseUser: string, @@ -85,32 +79,10 @@ const getMongoSpecificCommand = ( rcloneCommand: string, restoreCommand: string, backupFile: string, - encryptionConfig?: EncryptionConfig, ): string => { const tempDir = "/tmp/dokploy-restore"; - const fileName = backupFile.split("/").pop() || "backup.sql.gz"; - const isEncrypted = isEncryptedBackup(backupFile); - const decryptionCommand = encryptionConfig - ? getDecryptionCommand(encryptionConfig) - : ""; - - if (isEncrypted && decryptionCommand) { - // For encrypted mongo backups: download -> decrypt -> decompress -> restore - const decryptedName = fileName.replace(".enc", ""); - const decompressedName = decryptedName.replace(".gz", ""); - return ` -rm -rf ${tempDir} && \ -mkdir -p ${tempDir} && \ -${rcloneCommand} ${tempDir} && \ -cd ${tempDir} && \ -cat "${fileName}" | ${decryptionCommand} > "${decryptedName}" && \ -gunzip -f "${decryptedName}" && \ -${restoreCommand} < "${decompressedName}" && \ -rm -rf ${tempDir} - `; - } - - // Original non-encrypted flow + const fileName = backupFile.split("/").pop() || "backup.archive.gz"; + // With rclone crypt, decryption happens automatically when reading from the crypt remote const decompressedName = fileName.replace(".gz", ""); return ` rm -rf ${tempDir} && \ @@ -131,7 +103,6 @@ interface RestoreOptions { serviceName?: string; rcloneCommand: string; backupFile?: string; - encryptionConfig?: EncryptionConfig; } export const getRestoreCommand = ({ @@ -142,7 +113,6 @@ export const getRestoreCommand = ({ serviceName, rcloneCommand, backupFile, - encryptionConfig, }: RestoreOptions) => { const containerSearch = getComposeSearchCommand( appName, @@ -152,24 +122,12 @@ export const getRestoreCommand = ({ const restoreCommand = generateRestoreCommand(type, credentials); let cmd = `CONTAINER_ID=$(${containerSearch})`; - // Detect if backup is encrypted based on file extension - const isEncrypted = backupFile ? isEncryptedBackup(backupFile) : false; - const decryptionCommand = - isEncrypted && encryptionConfig - ? getDecryptionCommand(encryptionConfig) - : ""; - + // With rclone crypt, decryption happens automatically when reading from the crypt remote if (type !== "mongo") { - // For non-mongo databases: rclone cat | [decrypt | gunzip |] restore - // Note: for encrypted backups, rcloneCommand doesn't include gunzip, - // so we need to add it after decryption - if (isEncrypted && decryptionCommand) { - cmd += ` && ${rcloneCommand} | ${decryptionCommand} | gunzip | ${restoreCommand}`; - } else { - cmd += ` && ${rcloneCommand} | ${restoreCommand}`; - } + // For non-mongo databases: rclone cat | gunzip | restore + cmd += ` && ${rcloneCommand} | ${restoreCommand}`; } else { - cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "", encryptionConfig)}`; + cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`; } return cmd; diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index 1e8e7b3908..4b0cb91243 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -4,12 +4,10 @@ import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Destination } from "@dokploy/server/services/destination"; import { - getDecryptionCommand, getEncryptionConfigFromDestination, - getS3Credentials, + getRcloneS3Remote, } from "../backups/utils"; import { execAsync } from "../process/execAsync"; -import { isEncryptedBackup } from "./utils"; export const restoreWebServerBackup = async ( destination: Destination, @@ -20,60 +18,42 @@ export const restoreWebServerBackup = async ( return; } try { - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; - const backupPath = `${bucketPath}/${backupFile}`; - const { BASE_PATH } = paths(); const encryptionConfig = getEncryptionConfigFromDestination(destination); - const isEncrypted = isEncryptedBackup(backupFile); - const decryptedFileName = isEncrypted - ? backupFile.replace(".enc", "") - : backupFile; + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}/${backupFile}`; + const { BASE_PATH } = paths(); // Create a temporary directory outside of BASE_PATH const tempDir = await mkdtemp(join(tmpdir(), "dokploy-restore-")); try { emit("Starting restore..."); - emit(`Backup path: ${backupPath}`); + emit(`Backup file: ${backupFile}`); emit(`Temp directory: ${tempDir}`); - if (isEncrypted) { - emit("🔐 Encrypted backup detected - will decrypt before extraction"); + if (encryptionConfig.enabled) { + emit("🔐 Encryption enabled - will decrypt during download (rclone crypt)"); } // Create temp directory emit("Creating temporary directory..."); await execAsync(`mkdir -p ${tempDir}`); - // Download backup from S3 + // Download backup from S3 (with rclone crypt, decryption happens automatically) emit("Downloading backup from S3..."); - await execAsync( - `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`, - ); - - // Decrypt if necessary - if (isEncrypted && encryptionConfig.enabled) { - emit("Decrypting backup..."); - const decryptionCommand = getDecryptionCommand(encryptionConfig); - if (decryptionCommand) { - await execAsync( - `cd ${tempDir} && cat "${backupFile}" | ${decryptionCommand} > "${decryptedFileName}"`, - ); - // Remove the encrypted file - await execAsync(`rm -f "${tempDir}/${backupFile}"`); - emit("Backup decrypted successfully"); - } - } + const downloadCommand = envVars + ? `${envVars} rclone copyto "${backupPath}" "${tempDir}/${backupFile}"` + : `rclone copyto "${backupPath}" "${tempDir}/${backupFile}"`; + await execAsync(downloadCommand); // List files before extraction emit("Listing files before extraction..."); const { stdout: beforeFiles } = await execAsync(`ls -la ${tempDir}`); emit(`Files before extraction: ${beforeFiles}`); - // Extract backup (use decrypted filename) + // Extract backup emit("Extracting backup..."); await execAsync( - `cd ${tempDir} && unzip ${decryptedFileName} > /dev/null 2>&1`, + `cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`, ); // Restore filesystem first From 879661721f5c3173f58f0fcc490e41d2fa4132f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Dec 2025 21:38:23 +0000 Subject: [PATCH 03/11] feat: add full rclone crypt options for S3 backup encryption Add all rclone crypt configuration options to give users complete control over backup encryption settings: New options: - password2: Optional salt password for additional security (recommended) - filenameEncryption: "standard", "obfuscate", or "off" (default: off) - directoryNameEncryption: Encrypt directory names when filename encryption is enabled UI improvements: - Added link to rclone crypt documentation - Password and salt password fields with generate buttons - Filename encryption dropdown with descriptions - Directory name encryption toggle (shown when filename encryption is enabled) Encryption details: - Uses NaCl SecretBox (XSalsa20 cipher + Poly1305) - Filename encryption uses EME for "standard" mode - All passwords should be stored securely as they cannot be recovered See: https://rclone.org/crypt/ --- .../destination/handle-destinations.tsx | 249 ++++++++++++++---- .../0130_add_destination_encryption.sql | 5 +- packages/server/src/db/schema/destination.ts | 22 ++ packages/server/src/utils/backups/utils.ts | 38 ++- 4 files changed, 265 insertions(+), 49 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index f3af5b0f50..e49a7f3e34 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -1,5 +1,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRound, PenBoxIcon, PlusIcon, RefreshCw, Shield } from "lucide-react"; +import { + ExternalLink, + KeyRound, + PenBoxIcon, + PlusIcon, + RefreshCw, + Shield, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -39,6 +46,25 @@ import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { S3_PROVIDERS } from "./constants"; +// Rclone crypt filename encryption options +const FILENAME_ENCRYPTION_OPTIONS = [ + { + key: "off", + name: "Off", + description: "Don't encrypt filenames (recommended for easier management)", + }, + { + key: "standard", + name: "Standard", + description: "Encrypt filenames using EME encryption", + }, + { + key: "obfuscate", + name: "Obfuscate", + description: "Simple filename obfuscation (not secure, but hides names)", + }, +] as const; + const addDestination = z .object({ name: z.string().min(1, "Name is required"), @@ -49,8 +75,12 @@ const addDestination = z region: z.string(), endpoint: z.string().min(1, "Endpoint is required"), serverId: z.string().optional(), + // Encryption settings (rclone crypt) encryptionEnabled: z.boolean().optional(), encryptionKey: z.string().optional(), + encryptionPassword2: z.string().optional(), + filenameEncryption: z.enum(["standard", "obfuscate", "off"]).optional(), + directoryNameEncryption: z.boolean().optional(), }) .refine( (data) => { @@ -116,11 +146,15 @@ export const HandleDestinations = ({ destinationId }: Props) => { endpoint: "", encryptionEnabled: false, encryptionKey: "", + encryptionPassword2: "", + filenameEncryption: "off", + directoryNameEncryption: false, }, resolver: zodResolver(addDestination), }); const encryptionEnabled = form.watch("encryptionEnabled"); + const filenameEncryption = form.watch("filenameEncryption"); useEffect(() => { if (destination) { @@ -134,6 +168,11 @@ export const HandleDestinations = ({ destinationId }: Props) => { endpoint: destination.endpoint, encryptionEnabled: destination.encryptionEnabled ?? false, encryptionKey: destination.encryptionKey ?? "", + encryptionPassword2: destination.encryptionPassword2 ?? "", + filenameEncryption: + (destination.filenameEncryption as "standard" | "obfuscate" | "off") ?? + "off", + directoryNameEncryption: destination.directoryNameEncryption ?? false, }); } else { form.reset(); @@ -152,6 +191,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { destinationId: destinationId || "", encryptionEnabled: data.encryptionEnabled, encryptionKey: data.encryptionKey, + encryptionPassword2: data.encryptionPassword2, + filenameEncryption: data.filenameEncryption, + directoryNameEncryption: data.directoryNameEncryption, }) .then(async () => { toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); @@ -390,13 +432,24 @@ export const HandleDestinations = ({ destinationId }: Props) => { )} /> - {/* Encryption Settings */} + {/* Encryption Settings - Rclone Crypt */}
-
- - - Backup Encryption (At Rest) - +
+
+ + + Backup Encryption (At Rest) + +
+ + + Rclone Crypt Docs +
{
Enable Encryption - Encrypt backups before uploading to S3 + Encrypt backups using NaCl SecretBox (XSalsa20 + + Poly1305)
@@ -421,45 +475,152 @@ export const HandleDestinations = ({ destinationId }: Props) => { /> {encryptionEnabled && ( - ( - - Encryption Key -
+ <> + {/* Main Password */} + ( + + Password (Required) +
+ + + + +
+ + + Main encryption password. Store securely - lost + passwords cannot be recovered. + + +
+ )} + /> + + {/* Salt Password (Password2) */} + ( + + Salt Password (Recommended) +
+ + + + +
+ + Additional salt for extra security. Should be + different from the main password. + + +
+ )} + /> + + {/* Filename Encryption */} + ( + + Filename Encryption - + - -
- - - Store this key securely. Lost keys cannot be - recovered and encrypted backups will be unrecoverable. - - -
+ + Choose how backup filenames should be encrypted. + + + + )} + /> + + {/* Directory Name Encryption (only shown when filename encryption is not off) */} + {filenameEncryption && filenameEncryption !== "off" && ( + ( + +
+ Encrypt Directory Names + + Also encrypt directory/folder names + +
+ + + +
+ )} + /> )} - /> + )}
diff --git a/apps/dokploy/drizzle/0130_add_destination_encryption.sql b/apps/dokploy/drizzle/0130_add_destination_encryption.sql index ac5fc464b2..a0ba5b4aa8 100644 --- a/apps/dokploy/drizzle/0130_add_destination_encryption.sql +++ b/apps/dokploy/drizzle/0130_add_destination_encryption.sql @@ -1,2 +1,5 @@ ALTER TABLE "destination" ADD COLUMN "encryptionEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint -ALTER TABLE "destination" ADD COLUMN "encryptionKey" text; +ALTER TABLE "destination" ADD COLUMN "encryptionKey" text;--> statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "encryptionPassword2" text;--> statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "filenameEncryption" text DEFAULT 'off' NOT NULL;--> statement-breakpoint +ALTER TABLE "destination" ADD COLUMN "directoryNameEncryption" boolean DEFAULT false NOT NULL; diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index c5d2252de4..0f8f64b5be 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -22,8 +22,15 @@ export const destinations = pgTable("destination", { .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp("createdAt").notNull().defaultNow(), + // Encryption settings (rclone crypt) encryptionEnabled: boolean("encryptionEnabled").notNull().default(false), encryptionKey: text("encryptionKey"), + // Optional salt password for additional security (recommended by rclone) + encryptionPassword2: text("encryptionPassword2"), + // Filename encryption: "standard" (encrypt), "obfuscate", or "off" + filenameEncryption: text("filenameEncryption").notNull().default("off"), + // Whether to encrypt directory names (only applies if filenameEncryption is not "off") + directoryNameEncryption: boolean("directoryNameEncryption").notNull().default(false), }); export const destinationsRelations = relations( @@ -48,6 +55,9 @@ const createSchema = createInsertSchema(destinations, { region: z.string(), encryptionEnabled: z.boolean().optional(), encryptionKey: z.string().optional(), + encryptionPassword2: z.string().optional(), + filenameEncryption: z.enum(["standard", "obfuscate", "off"]).optional(), + directoryNameEncryption: z.boolean().optional(), }); export const apiCreateDestination = createSchema @@ -61,12 +71,18 @@ export const apiCreateDestination = createSchema secretAccessKey: true, encryptionEnabled: true, encryptionKey: true, + encryptionPassword2: true, + filenameEncryption: true, + directoryNameEncryption: true, }) .required() .extend({ serverId: z.string().optional(), encryptionEnabled: z.boolean().optional(), encryptionKey: z.string().optional(), + encryptionPassword2: z.string().optional(), + filenameEncryption: z.enum(["standard", "obfuscate", "off"]).optional(), + directoryNameEncryption: z.boolean().optional(), }); export const apiFindOneDestination = createSchema @@ -93,10 +109,16 @@ export const apiUpdateDestination = createSchema provider: true, encryptionEnabled: true, encryptionKey: true, + encryptionPassword2: true, + filenameEncryption: true, + directoryNameEncryption: true, }) .required() .extend({ serverId: z.string().optional(), encryptionEnabled: z.boolean().optional(), encryptionKey: z.string().optional(), + encryptionPassword2: z.string().optional(), + filenameEncryption: z.enum(["standard", "obfuscate", "off"]).optional(), + directoryNameEncryption: z.boolean().optional(), }); diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index e13d672e3a..dfb5f9057c 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -10,9 +10,14 @@ import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; import { runWebServerBackup } from "./web-server"; +export type FilenameEncryption = "standard" | "obfuscate" | "off"; + export interface EncryptionConfig { enabled: boolean; key?: string | null; + password2?: string | null; + filenameEncryption?: FilenameEncryption | null; + directoryNameEncryption?: boolean | null; } export const getEncryptionConfigFromDestination = ( @@ -21,6 +26,9 @@ export const getEncryptionConfigFromDestination = ( return { enabled: destination.encryptionEnabled ?? false, key: destination.encryptionKey, + password2: destination.encryptionPassword2, + filenameEncryption: (destination.filenameEncryption as FilenameEncryption) ?? "off", + directoryNameEncryption: destination.directoryNameEncryption ?? false, }; }; @@ -28,6 +36,14 @@ export const getEncryptionConfigFromDestination = ( * Get rclone flags for S3 credentials with optional crypt encryption overlay. * When encryption is enabled, uses rclone's native crypt backend which provides * file name and content encryption using NaCl SecretBox (XSalsa20 cipher + Poly1305). + * + * Rclone crypt options: + * - password: Main encryption password (required) + * - password2: Salt password for additional security (optional but recommended) + * - filename_encryption: "standard" (encrypt), "obfuscate", or "off" + * - directory_name_encryption: true/false (only applies if filename_encryption is not "off") + * + * @see https://rclone.org/crypt/ */ export const getRcloneS3Remote = ( destination: Destination, @@ -45,12 +61,26 @@ export const getRcloneS3Remote = ( // If encryption is enabled, wrap the S3 remote with crypt if (encryptionConfig?.enabled && encryptionConfig?.key) { const escapedKey = encryptionConfig.key.replace(/'/g, "'\\''"); - // Use crypt overlay with the S3 remote as backend - // filename_encryption=off keeps original filenames (only content is encrypted) - const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=off:`; + + // Build crypt options + const filenameEncryption = encryptionConfig.filenameEncryption || "off"; + const directoryNameEncryption = encryptionConfig.directoryNameEncryption ?? false; + + // Build the crypt remote string with all options + const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=${filenameEncryption},directory_name_encryption=${directoryNameEncryption}:`; + + // Build environment variables for passwords + let envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}'`; + + // Add password2 (salt) if provided + if (encryptionConfig.password2) { + const escapedPassword2 = encryptionConfig.password2.replace(/'/g, "'\\''"); + envVars += ` RCLONE_CRYPT_PASSWORD2='${escapedPassword2}'`; + } + return { remote: cryptRemote, - envVars: `RCLONE_CRYPT_PASSWORD='${escapedKey}'`, + envVars, }; } From 04a32cae3c43e5e6d2bab5a3b813dc32332eeee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Dec 2025 21:53:55 +0000 Subject: [PATCH 04/11] test: add unit tests for S3 backup encryption utilities Add comprehensive tests for getEncryptionConfigFromDestination and getRcloneS3Remote functions covering all encryption options including password2 (salt), filename encryption, and directory name encryption. --- .../dokploy/__test__/utils/encryption.test.ts | 392 ++++++++++++++++++ .../destination/handle-destinations.tsx | 11 +- packages/server/src/db/schema/destination.ts | 4 +- packages/server/src/utils/backups/utils.ts | 11 +- 4 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/__test__/utils/encryption.test.ts diff --git a/apps/dokploy/__test__/utils/encryption.test.ts b/apps/dokploy/__test__/utils/encryption.test.ts new file mode 100644 index 0000000000..481d0dd2dd --- /dev/null +++ b/apps/dokploy/__test__/utils/encryption.test.ts @@ -0,0 +1,392 @@ +import type { Destination } from "@dokploy/server/services/destination"; +import { + type EncryptionConfig, + getEncryptionConfigFromDestination, + getRcloneS3Remote, +} from "@dokploy/server/utils/backups/utils"; +import { describe, expect, test } from "vitest"; + +// Mock destination factory for testing +const createMockDestination = ( + overrides: Partial = {}, +): Destination => ({ + destinationId: "test-dest-id", + name: "Test Destination", + provider: "aws", + accessKey: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + bucket: "my-backup-bucket", + region: "us-east-1", + endpoint: "https://s3.amazonaws.com", + organizationId: "org-123", + createdAt: new Date(), + encryptionEnabled: false, + encryptionKey: null, + encryptionPassword2: null, + filenameEncryption: "off", + directoryNameEncryption: false, + ...overrides, +}); + +describe("getEncryptionConfigFromDestination", () => { + test("should return disabled config when encryption is not enabled", () => { + const destination = createMockDestination({ + encryptionEnabled: false, + encryptionKey: null, + }); + + const config = getEncryptionConfigFromDestination(destination); + + expect(config.enabled).toBe(false); + expect(config.key).toBeNull(); + expect(config.password2).toBeNull(); + expect(config.filenameEncryption).toBe("off"); + expect(config.directoryNameEncryption).toBe(false); + }); + + test("should return enabled config with all encryption options", () => { + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-secret-encryption-key", + encryptionPassword2: "my-salt-password", + filenameEncryption: "standard", + directoryNameEncryption: true, + }); + + const config = getEncryptionConfigFromDestination(destination); + + expect(config.enabled).toBe(true); + expect(config.key).toBe("my-secret-encryption-key"); + expect(config.password2).toBe("my-salt-password"); + expect(config.filenameEncryption).toBe("standard"); + expect(config.directoryNameEncryption).toBe(true); + }); + + test("should handle obfuscate filename encryption", () => { + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + filenameEncryption: "obfuscate", + }); + + const config = getEncryptionConfigFromDestination(destination); + + expect(config.filenameEncryption).toBe("obfuscate"); + }); + + test("should handle null/undefined values with defaults", () => { + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + encryptionPassword2: null, + filenameEncryption: null as unknown as string, + directoryNameEncryption: null as unknown as boolean, + }); + + const config = getEncryptionConfigFromDestination(destination); + + expect(config.password2).toBeNull(); + expect(config.filenameEncryption).toBe("off"); + expect(config.directoryNameEncryption).toBe(false); + }); + + test("should handle undefined encryptionEnabled as false", () => { + const destination = createMockDestination(); + // @ts-expect-error Testing undefined value + destination.encryptionEnabled = undefined; + + const config = getEncryptionConfigFromDestination(destination); + + expect(config.enabled).toBe(false); + }); +}); + +describe("getRcloneS3Remote", () => { + describe("without encryption", () => { + test("should return basic S3 remote without provider", () => { + const destination = createMockDestination({ + provider: null, + }); + + const result = getRcloneS3Remote(destination); + + expect(result.envVars).toBe(""); + expect(result.remote).toContain(":s3,"); + expect(result.remote).toContain( + `access_key_id="${destination.accessKey}"`, + ); + expect(result.remote).toContain( + `secret_access_key="${destination.secretAccessKey}"`, + ); + expect(result.remote).toContain(`region="${destination.region}"`); + expect(result.remote).toContain(`endpoint="${destination.endpoint}"`); + expect(result.remote).toContain("no_check_bucket=true"); + expect(result.remote).toContain("force_path_style=true"); + expect(result.remote).toContain(`:${destination.bucket}`); + expect(result.remote).not.toContain("provider="); + }); + + test("should return S3 remote with provider when specified", () => { + const destination = createMockDestination({ + provider: "aws", + }); + + const result = getRcloneS3Remote(destination); + + expect(result.envVars).toBe(""); + expect(result.remote).toContain(`provider="${destination.provider}"`); + }); + + test("should return S3 remote when encryption config is disabled", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: false, + key: "some-key", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.envVars).toBe(""); + expect(result.remote).not.toContain(":crypt,"); + }); + + test("should return S3 remote when encryption enabled but no key", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: null, + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.envVars).toBe(""); + expect(result.remote).not.toContain(":crypt,"); + }); + }); + + describe("with encryption", () => { + test("should return crypt-wrapped remote with basic encryption", () => { + const destination = createMockDestination({ + provider: "aws", + }); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-encryption-key", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain(":crypt,"); + expect(result.remote).toContain("filename_encryption=off"); + expect(result.remote).toContain("directory_name_encryption=false"); + expect(result.envVars).toBe("RCLONE_CRYPT_PASSWORD='my-encryption-key'"); + }); + + test("should include password2 when provided", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-encryption-key", + password2: "my-salt-password", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.envVars).toContain( + "RCLONE_CRYPT_PASSWORD='my-encryption-key'", + ); + expect(result.envVars).toContain( + "RCLONE_CRYPT_PASSWORD2='my-salt-password'", + ); + }); + + test("should handle standard filename encryption", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + filenameEncryption: "standard", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain("filename_encryption=standard"); + }); + + test("should handle obfuscate filename encryption", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + filenameEncryption: "obfuscate", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain("filename_encryption=obfuscate"); + }); + + test("should handle directory name encryption", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + directoryNameEncryption: true, + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain("directory_name_encryption=true"); + }); + + test("should handle all encryption options together", () => { + const destination = createMockDestination({ + provider: "aws", + }); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "encryption-key", + password2: "salt-password", + filenameEncryption: "standard", + directoryNameEncryption: true, + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain(":crypt,"); + expect(result.remote).toContain("filename_encryption=standard"); + expect(result.remote).toContain("directory_name_encryption=true"); + expect(result.envVars).toContain( + "RCLONE_CRYPT_PASSWORD='encryption-key'", + ); + expect(result.envVars).toContain( + "RCLONE_CRYPT_PASSWORD2='salt-password'", + ); + }); + + test("should escape single quotes in encryption key", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "key'with'quotes", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.envVars).toContain("key'\\''with'\\''quotes"); + }); + + test("should escape single quotes in password2", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + password2: "salt'with'quotes", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.envVars).toContain( + "RCLONE_CRYPT_PASSWORD2='salt'\\''with'\\''quotes'", + ); + }); + + test("should wrap S3 remote correctly in crypt remote", () => { + const destination = createMockDestination({ + bucket: "test-bucket", + provider: "aws", + }); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + // The crypt remote should contain the S3 remote and bucket + expect(result.remote).toMatch(/:crypt,remote=":s3,.*:test-bucket",/); + // Should end with a colon for the path + expect(result.remote).toMatch(/:$/); + }); + }); + + describe("edge cases", () => { + test("should handle special characters in access keys", () => { + const destination = createMockDestination({ + accessKey: "AKIA+/=EXAMPLE", + secretAccessKey: "secret+/=key", + }); + + const result = getRcloneS3Remote(destination); + + expect(result.remote).toContain( + `access_key_id="${destination.accessKey}"`, + ); + expect(result.remote).toContain( + `secret_access_key="${destination.secretAccessKey}"`, + ); + }); + + test("should handle custom endpoints", () => { + const destination = createMockDestination({ + endpoint: "https://s3.custom-region.example.com:9000", + provider: "minio", + }); + + const result = getRcloneS3Remote(destination); + + expect(result.remote).toContain(`endpoint="${destination.endpoint}"`); + expect(result.remote).toContain(`provider="${destination.provider}"`); + }); + + test("should handle empty password2", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + password2: "", + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + // Empty string is falsy, so password2 should not be included + expect(result.envVars).toBe("RCLONE_CRYPT_PASSWORD='my-key'"); + expect(result.envVars).not.toContain("RCLONE_CRYPT_PASSWORD2"); + }); + + test("should handle undefined encryptionConfig", () => { + const destination = createMockDestination(); + + const result = getRcloneS3Remote(destination, undefined); + + expect(result.envVars).toBe(""); + expect(result.remote).not.toContain(":crypt,"); + }); + + test("should handle null filenameEncryption with default", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + filenameEncryption: null, + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain("filename_encryption=off"); + }); + + test("should handle null directoryNameEncryption with default", () => { + const destination = createMockDestination(); + const encryptionConfig: EncryptionConfig = { + enabled: true, + key: "my-key", + directoryNameEncryption: null, + }; + + const result = getRcloneS3Remote(destination, encryptionConfig); + + expect(result.remote).toContain("directory_name_encryption=false"); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index e49a7f3e34..690ab4bf6f 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -170,8 +170,10 @@ export const HandleDestinations = ({ destinationId }: Props) => { encryptionKey: destination.encryptionKey ?? "", encryptionPassword2: destination.encryptionPassword2 ?? "", filenameEncryption: - (destination.filenameEncryption as "standard" | "obfuscate" | "off") ?? - "off", + (destination.filenameEncryption as + | "standard" + | "obfuscate" + | "off") ?? "off", directoryNameEncryption: destination.directoryNameEncryption ?? false, }); } else { @@ -574,10 +576,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { {FILENAME_ENCRYPTION_OPTIONS.map((option) => ( - +
{option.name} diff --git a/packages/server/src/db/schema/destination.ts b/packages/server/src/db/schema/destination.ts index 0f8f64b5be..d9268ac1a4 100644 --- a/packages/server/src/db/schema/destination.ts +++ b/packages/server/src/db/schema/destination.ts @@ -30,7 +30,9 @@ export const destinations = pgTable("destination", { // Filename encryption: "standard" (encrypt), "obfuscate", or "off" filenameEncryption: text("filenameEncryption").notNull().default("off"), // Whether to encrypt directory names (only applies if filenameEncryption is not "off") - directoryNameEncryption: boolean("directoryNameEncryption").notNull().default(false), + directoryNameEncryption: boolean("directoryNameEncryption") + .notNull() + .default(false), }); export const destinationsRelations = relations( diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index dfb5f9057c..7539d3be17 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -27,7 +27,8 @@ export const getEncryptionConfigFromDestination = ( enabled: destination.encryptionEnabled ?? false, key: destination.encryptionKey, password2: destination.encryptionPassword2, - filenameEncryption: (destination.filenameEncryption as FilenameEncryption) ?? "off", + filenameEncryption: + (destination.filenameEncryption as FilenameEncryption) ?? "off", directoryNameEncryption: destination.directoryNameEncryption ?? false, }; }; @@ -64,7 +65,8 @@ export const getRcloneS3Remote = ( // Build crypt options const filenameEncryption = encryptionConfig.filenameEncryption || "off"; - const directoryNameEncryption = encryptionConfig.directoryNameEncryption ?? false; + const directoryNameEncryption = + encryptionConfig.directoryNameEncryption ?? false; // Build the crypt remote string with all options const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=${filenameEncryption},directory_name_encryption=${directoryNameEncryption}:`; @@ -74,7 +76,10 @@ export const getRcloneS3Remote = ( // Add password2 (salt) if provided if (encryptionConfig.password2) { - const escapedPassword2 = encryptionConfig.password2.replace(/'/g, "'\\''"); + const escapedPassword2 = encryptionConfig.password2.replace( + /'/g, + "'\\''", + ); envVars += ` RCLONE_CRYPT_PASSWORD2='${escapedPassword2}'`; } From bc177c8c1ce52661c04cb0c95c4b17e31b94873f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Dec 2025 15:03:58 +0000 Subject: [PATCH 05/11] feat: add encryption support to volume backups Update volume backup, restore, and cleanup functions to use getRcloneS3Remote with encryption support. Volume backups now respect the destination's encryption settings (encryptionEnabled, encryptionKey, password2, filenameEncryption, directoryNameEncryption) just like database backups. --- .../server/src/utils/volume-backups/backup.ts | 21 +++++++++++++----- .../src/utils/volume-backups/restore.ts | 21 ++++++++++++------ .../server/src/utils/volume-backups/utils.ts | 22 ++++++++++++++----- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index cc613ffa91..968f0a02a4 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -2,7 +2,11 @@ import path from "node:path"; import { paths } from "@dokploy/server/constants"; import { findComposeById } from "@dokploy/server/services/compose"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; -import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getRcloneS3Remote, + normalizeS3Path, +} from "../backups/utils"; export const backupVolume = async ( volumeBackup: Awaited>, @@ -14,18 +18,23 @@ export const backupVolume = async ( const destination = volumeBackup.destination; const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; - const rcloneFlags = getS3Credentials(volumeBackup.destination); - const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; + + // Get encryption config and build rclone remote with optional crypt overlay + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const rcloneDestination = `${remote}${bucketDestination}`; const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); - const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`; + const rcloneCommand = `${envVars ? `${envVars} ` : ""}rclone copyto "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`; + const isEncrypted = encryptionConfig.enabled && encryptionConfig.key; const baseCommand = ` set -e echo "Volume name: ${volumeName}" echo "Backup file name: ${backupFileName}" echo "Turning off volume backup: ${turnOff ? "Yes" : "No"}" - echo "Starting volume backup" + ${isEncrypted ? 'echo "🔐 Encryption enabled (rclone crypt)"' : ""} + echo "Starting volume backup" echo "Dir: ${volumeBackupPath}" docker run --rm \ -v ${volumeName}:/volume_data \ @@ -33,7 +42,7 @@ export const backupVolume = async ( ubuntu \ bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ." echo "Volume backup done ✅" - echo "Starting upload to S3..." + echo "Starting upload to S3${isEncrypted ? " (encrypted)" : ""}..." ${rcloneCommand} echo "Upload to S3 done ✅" echo "Cleaning up local backup file..." diff --git a/packages/server/src/utils/volume-backups/restore.ts b/packages/server/src/utils/volume-backups/restore.ts index 6f6068cafc..0d0b82156f 100644 --- a/packages/server/src/utils/volume-backups/restore.ts +++ b/packages/server/src/utils/volume-backups/restore.ts @@ -3,9 +3,12 @@ import { findApplicationById, findComposeById, findDestinationById, - getS3Credentials, paths, } from "../.."; +import { + getEncryptionConfigFromDestination, + getRcloneS3Remote, +} from "../backups/utils"; export const restoreVolume = async ( id: string, @@ -18,12 +21,15 @@ export const restoreVolume = async ( const destination = await findDestinationById(destinationId); const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeName); - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; - const backupPath = `${bucketPath}/${backupFileName}`; - // Command to download backup file from S3 - const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${volumeBackupPath}/${backupFileName}"`; + // Get encryption config and build rclone remote with optional crypt overlay + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const backupPath = `${remote}${backupFileName}`; + const isEncrypted = encryptionConfig.enabled && encryptionConfig.key; + + // Command to download backup file from S3 (decryption happens automatically with rclone crypt) + const downloadCommand = `${envVars ? `${envVars} ` : ""}rclone copyto "${backupPath}" "${volumeBackupPath}/${backupFileName}"`; // Base restore command that creates the volume and restores data const baseRestoreCommand = ` @@ -31,7 +37,8 @@ export const restoreVolume = async ( echo "Volume name: ${volumeName}" echo "Backup file name: ${backupFileName}" echo "Volume backup path: ${volumeBackupPath}" - echo "Downloading backup from S3..." + ${isEncrypted ? 'echo "🔐 Decryption enabled (rclone crypt)"' : ""} + echo "Downloading backup from S3${isEncrypted ? " (decrypting)" : ""}..." mkdir -p ${volumeBackupPath} ${downloadCommand} echo "Download completed ✅" diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index b508c6b882..1b0197ab6d 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -10,7 +10,11 @@ import { execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import { scheduledJobs, scheduleJob } from "node-schedule"; -import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +import { + getEncryptionConfigFromDestination, + getRcloneS3Remote, + normalizeS3Path, +} from "../backups/utils"; import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; import { backupVolume } from "./backup"; @@ -80,12 +84,20 @@ const cleanupOldVolumeBackups = async ( if (!keepLatestCount) return; try { - const rcloneFlags = getS3Credentials(destination); + // Get encryption config and build rclone remote with optional crypt overlay + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const { remote, envVars } = getRcloneS3Remote( + destination, + encryptionConfig, + ); const normalizedPrefix = normalizeS3Path(prefix); - const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`; - const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`; + const backupFilesPath = `${remote}${normalizedPrefix}`; + + // When using rclone crypt, filenames are automatically decrypted during listing + const envPrefix = envVars ? `${envVars} ` : ""; + const listCommand = `${envPrefix}rclone lsf --include "${volumeName}-*.tar" "${backupFilesPath}"`; const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; - const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; + const deleteCommand = `${envPrefix}rclone delete "${backupFilesPath}{}"`; const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`; if (serverId) { From 59a8c7e03e4e023f69a57450085ca8fcd6c60602 Mon Sep 17 00:00:00 2001 From: Amir Moradi <1281163+amirhmoradi@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:11:44 +0100 Subject: [PATCH 06/11] Support encrypted backup maintenance --- README.md | 4 + .../__test__/backup/encryption-config.test.ts | 80 +++++++++++++++++++ apps/dokploy/server/api/routers/backup.ts | 44 ++++++---- packages/server/src/utils/backups/index.ts | 38 ++++++--- packages/server/src/utils/backups/utils.ts | 23 ++++-- 5 files changed, 152 insertions(+), 37 deletions(-) create mode 100644 apps/dokploy/__test__/backup/encryption-config.test.ts diff --git a/README.md b/README.md index 23fcd0c9d8..9aea327b9a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Dokploy includes multiple features to make your life easier. - **Multi Server**: Deploy and manage your applications remotely to external servers. - **Self-Hosted**: Self-host Dokploy on your VPS. +### 🔐 Encrypted Backups + +Backups can be encrypted at rest using [rclone crypt](https://rclone.org/crypt/). Configure encryption when creating an S3 Destination in **Dashboard → Settings → S3 Destinations** by enabling **Backup Encryption** and providing the primary password (and optional salt/password2). When enabled, Dokploy will automatically encrypt backup uploads and decrypt during restores. + ## 🚀 Getting Started To get started, run the following command on a VPS: diff --git a/apps/dokploy/__test__/backup/encryption-config.test.ts b/apps/dokploy/__test__/backup/encryption-config.test.ts new file mode 100644 index 0000000000..6c6ded89fe --- /dev/null +++ b/apps/dokploy/__test__/backup/encryption-config.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + buildRcloneCommand, + getBackupRemotePath, + getEncryptionConfigFromDestination, + getRcloneS3Remote, +} from "@dokploy/server/utils/backups/utils"; +import type { Destination } from "@dokploy/server/services/destination"; + +const createDestination = (overrides: Partial = {}): Destination => ({ + destinationId: "dest-1", + name: "Encrypted bucket", + provider: "", + accessKey: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + bucket: "my-bucket", + region: "us-east-1", + endpoint: "https://s3.example.com", + organizationId: "org-1", + createdAt: new Date("2024-01-01T00:00:00Z"), + encryptionEnabled: false, + encryptionKey: null, + encryptionPassword2: null, + filenameEncryption: "off", + directoryNameEncryption: false, + ...overrides, +}); + +describe("rclone encryption helpers", () => { + it("builds a plain S3 remote without encryption", () => { + const destination = createDestination(); + const encryptionConfig = getEncryptionConfigFromDestination(destination); + + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + + expect(envVars).toBe(""); + expect(remote).toContain(":s3,"); + expect(remote).toContain("my-bucket"); + }); + + it("builds a crypt remote and env vars when encryption is enabled", () => { + const destination = createDestination({ + encryptionEnabled: true, + encryptionKey: "primary-pass", + encryptionPassword2: "salt-pass", + filenameEncryption: "standard", + directoryNameEncryption: true, + }); + const encryptionConfig = getEncryptionConfigFromDestination(destination); + + const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + + expect(remote.startsWith(":crypt")).toBe(true); + expect(remote).toContain("remote=\":s3,"); + expect(remote.endsWith(":")).toBe(true); + expect(envVars).toContain("RCLONE_CRYPT_PASSWORD='primary-pass'"); + expect(envVars).toContain("RCLONE_CRYPT_PASSWORD2='salt-pass'"); + }); + + it("returns the correct remote path for nested prefixes", () => { + const destination = createDestination(); + const encryptionConfig = getEncryptionConfigFromDestination(destination); + const { remote } = getRcloneS3Remote(destination, encryptionConfig); + + const remotePath = getBackupRemotePath(remote, "daily/db"); + + expect(remotePath).toBe(`${remote}/daily/db/`); + }); + + it("adds encryption env vars to commands only when provided", () => { + expect(buildRcloneCommand("rclone lsf remote")).toBe("rclone lsf remote"); + + expect( + buildRcloneCommand( + "rclone lsf remote", + "RCLONE_CRYPT_PASSWORD='secret'", + ), + ).toBe("RCLONE_CRYPT_PASSWORD='secret' rclone lsf remote"); + }); +}); diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 68067f9df4..ca4f53b271 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -27,8 +27,11 @@ import { import { findDestinationById } from "@dokploy/server/services/destination"; import { runComposeBackup } from "@dokploy/server/utils/backups/compose"; import { - getS3Credentials, - normalizeS3Path, + buildRcloneCommand, + getBackupRemotePath, + getEncryptionConfigFromDestination, + getRcloneS3Remote, + normalizeS3Path, } from "@dokploy/server/utils/backups/utils"; import { execAsync, @@ -296,23 +299,30 @@ export const backupRouter = createTRPCRouter({ }), ) .query(async ({ input }) => { - try { - const destination = await findDestinationById(input.destinationId); - const rcloneFlags = getS3Credentials(destination); - const bucketPath = `:s3:${destination.bucket}`; + try { + const destination = await findDestinationById(input.destinationId); + const encryptionConfig = + getEncryptionConfigFromDestination(destination); + const { remote, envVars } = getRcloneS3Remote( + destination, + encryptionConfig, + ); - const lastSlashIndex = input.search.lastIndexOf("/"); - const baseDir = - lastSlashIndex !== -1 - ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1)) - : ""; - const searchTerm = - lastSlashIndex !== -1 - ? input.search.slice(lastSlashIndex + 1) - : input.search; + const lastSlashIndex = input.search.lastIndexOf("/"); + const baseDir = + lastSlashIndex !== -1 + ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1)) + : ""; + const searchTerm = + lastSlashIndex !== -1 + ? input.search.slice(lastSlashIndex + 1) + : input.search; - const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath; - const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`; + const searchPath = getBackupRemotePath(remote, baseDir); + const listCommand = buildRcloneCommand( + `rclone lsjson "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`, + envVars, + ); let stdout = ""; diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index dfdcd2cac9..6e513b9b75 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { member } from "@dokploy/server/db/schema"; import type { BackupSchedule } from "@dokploy/server/services/backup"; import { getAllServers } from "@dokploy/server/services/server"; @@ -9,7 +8,13 @@ import { startLogCleanup } from "../access-log/handler"; import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials, scheduleBackup } from "./utils"; +import { + buildRcloneCommand, + getBackupRemotePath, + getEncryptionConfigFromDestination, + getRcloneS3Remote, + scheduleBackup, +} from "./utils"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); @@ -89,29 +94,36 @@ export const initCronJobs = async () => { }; export const keepLatestNBackups = async ( - backup: BackupSchedule, - serverId?: string | null, + backup: BackupSchedule, + serverId?: string | null, ) => { // 0 also immediately returns which is good as the empty "keep latest" field in the UI // is saved as 0 in the database if (!backup.keepLatestCount) return; - try { - const rcloneFlags = getS3Credentials(backup.destination); - const backupFilesPath = path.join( - `:s3:${backup.destination.bucket}`, - backup.prefix, - ); + try { + const encryptionConfig = getEncryptionConfigFromDestination(backup.destination); + const { remote, envVars } = getRcloneS3Remote( + backup.destination, + encryptionConfig, + ); + const backupFilesPath = getBackupRemotePath(remote, backup.prefix); // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone - const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`; + const rcloneList = buildRcloneCommand( + `rclone lsf --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`, + envVars, + ); // when we pipe the above command with this one, we only get the list of files we want to delete const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; // this command deletes the files // to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{} - const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`; + const rcloneDelete = buildRcloneCommand( + `rclone delete ${backupFilesPath}/{}`, + envVars, + ); - const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; + const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; if (serverId) { await execAsyncRemote(serverId, rcloneCommand); diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 7539d3be17..a5f39465ed 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -21,16 +21,20 @@ export interface EncryptionConfig { } export const getEncryptionConfigFromDestination = ( - destination: Destination, + destination: Destination, ): EncryptionConfig => { - return { + return { enabled: destination.encryptionEnabled ?? false, key: destination.encryptionKey, password2: destination.encryptionPassword2, filenameEncryption: (destination.filenameEncryption as FilenameEncryption) ?? "off", directoryNameEncryption: destination.directoryNameEncryption ?? false, - }; + }; +}; + +export const buildRcloneCommand = (command: string, envVars?: string) => { + return envVars ? `${envVars} ${command}` : command; }; /** @@ -137,10 +141,15 @@ export const removeScheduleBackup = (backupId: string) => { }; export const normalizeS3Path = (prefix: string) => { - // Trim whitespace and remove leading/trailing slashes - const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, ""); - // Return empty string if prefix is empty, otherwise append trailing slash - return normalizedPrefix ? `${normalizedPrefix}/` : ""; + // Trim whitespace and remove leading/trailing slashes + const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, ""); + // Return empty string if prefix is empty, otherwise append trailing slash + return normalizedPrefix ? `${normalizedPrefix}/` : ""; +}; + +export const getBackupRemotePath = (remote: string, prefix?: string | null) => { + const normalizedPrefix = normalizeS3Path(prefix || ""); + return normalizedPrefix ? `${remote}/${normalizedPrefix}` : remote; }; export const getS3Credentials = (destination: Destination) => { From b419133036f4f833e85e809dfb23a0394cac99dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Dec 2025 23:35:51 +0000 Subject: [PATCH 07/11] fix: correct S3 encryption migration numbering and journal registration The encryption migration file (0131_add_destination_encryption.sql) had a conflicting number with an existing migration (0131_volatile_beast.sql) and was not registered in the drizzle journal, preventing it from being applied. Changes: - Rename 0131_add_destination_encryption.sql -> 0133_add_destination_encryption.sql - Update _journal.json entry from "0133_add_create_env_file" to "0133_add_destination_encryption" to match the actual migration file This ensures the encryption columns (encryptionEnabled, encryptionKey, encryptionPassword2, filenameEncryption, directoryNameEncryption) are properly added to the destination table when migrations run. --- ...ation_encryption.sql => 0133_add_destination_encryption.sql} | 0 apps/dokploy/drizzle/meta/_journal.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/dokploy/drizzle/{0131_add_destination_encryption.sql => 0133_add_destination_encryption.sql} (100%) diff --git a/apps/dokploy/drizzle/0131_add_destination_encryption.sql b/apps/dokploy/drizzle/0133_add_destination_encryption.sql similarity index 100% rename from apps/dokploy/drizzle/0131_add_destination_encryption.sql rename to apps/dokploy/drizzle/0133_add_destination_encryption.sql diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index f50786a496..262f2a9492 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -937,7 +937,7 @@ "idx": 133, "version": "7", "when": 1765348573500, - "tag": "0133_add_create_env_file", + "tag": "0133_add_destination_encryption", "breakpoints": true } ] From 46fa176611347ab4a8ef93ae24796826b42d09fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Jan 2026 22:44:03 +0000 Subject: [PATCH 08/11] refactor: simplify S3 backup encryption to transparent rclone crypt layer The rclone crypt encryption is now handled transparently at the rclone remote configuration level, eliminating the need for application-layer encryption handling throughout the backup/restore codebase. Key changes: - Simplified getRcloneS3Remote() to take only destination parameter (encryption settings are read directly from destination object) - Removed getEncryptionConfigFromDestination() and EncryptionConfig type - Added buildRcloneCommand() helper for cleaner command construction - Removed encryption config passing from all backup/restore functions: - mariadb.ts, postgres.ts, mysql.ts, mongo.ts, compose.ts, web-server.ts - All restore files in utils/restore/ - All volume backup files in utils/volume-backups/ - backup.ts router - Updated tests to use simplified API The encryption is now completely transparent - callers just use the remote returned by getRcloneS3Remote() and rclone handles encryption/ decryption automatically based on the destination's encryption settings. This follows the DRY principle by centralizing encryption logic in one place (getRcloneS3Remote) instead of spreading it across the codebase. --- .../__test__/backup/encryption-config.test.ts | 10 +- .../dokploy/__test__/utils/encryption.test.ts | 251 ++++++------------ apps/dokploy/server/api/routers/backup.ts | 9 +- packages/server/src/utils/backups/compose.ts | 14 +- packages/server/src/utils/backups/index.ts | 42 ++- packages/server/src/utils/backups/mariadb.ts | 14 +- packages/server/src/utils/backups/mongo.ts | 14 +- packages/server/src/utils/backups/mysql.ts | 15 +- packages/server/src/utils/backups/postgres.ts | 15 +- packages/server/src/utils/backups/utils.ts | 92 +++---- .../server/src/utils/backups/web-server.ts | 25 +- packages/server/src/utils/restore/compose.ts | 27 +- packages/server/src/utils/restore/mariadb.ts | 20 +- packages/server/src/utils/restore/mongo.ts | 21 +- packages/server/src/utils/restore/mysql.ts | 20 +- packages/server/src/utils/restore/postgres.ts | 19 +- .../server/src/utils/restore/web-server.ts | 19 +- .../server/src/utils/volume-backups/backup.ts | 16 +- .../src/utils/volume-backups/restore.ts | 33 ++- .../server/src/utils/volume-backups/utils.ts | 21 +- 20 files changed, 253 insertions(+), 444 deletions(-) diff --git a/apps/dokploy/__test__/backup/encryption-config.test.ts b/apps/dokploy/__test__/backup/encryption-config.test.ts index 6c6ded89fe..b1ada555cf 100644 --- a/apps/dokploy/__test__/backup/encryption-config.test.ts +++ b/apps/dokploy/__test__/backup/encryption-config.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { buildRcloneCommand, getBackupRemotePath, - getEncryptionConfigFromDestination, getRcloneS3Remote, } from "@dokploy/server/utils/backups/utils"; import type { Destination } from "@dokploy/server/services/destination"; @@ -29,9 +28,8 @@ const createDestination = (overrides: Partial = {}): Destination => describe("rclone encryption helpers", () => { it("builds a plain S3 remote without encryption", () => { const destination = createDestination(); - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const { remote, envVars } = getRcloneS3Remote(destination); expect(envVars).toBe(""); expect(remote).toContain(":s3,"); @@ -46,9 +44,8 @@ describe("rclone encryption helpers", () => { filenameEncryption: "standard", directoryNameEncryption: true, }); - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + const { remote, envVars } = getRcloneS3Remote(destination); expect(remote.startsWith(":crypt")).toBe(true); expect(remote).toContain("remote=\":s3,"); @@ -59,8 +56,7 @@ describe("rclone encryption helpers", () => { it("returns the correct remote path for nested prefixes", () => { const destination = createDestination(); - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote } = getRcloneS3Remote(destination, encryptionConfig); + const { remote } = getRcloneS3Remote(destination); const remotePath = getBackupRemotePath(remote, "daily/db"); diff --git a/apps/dokploy/__test__/utils/encryption.test.ts b/apps/dokploy/__test__/utils/encryption.test.ts index 481d0dd2dd..286342d1d3 100644 --- a/apps/dokploy/__test__/utils/encryption.test.ts +++ b/apps/dokploy/__test__/utils/encryption.test.ts @@ -1,9 +1,5 @@ import type { Destination } from "@dokploy/server/services/destination"; -import { - type EncryptionConfig, - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "@dokploy/server/utils/backups/utils"; +import { getRcloneS3Remote } from "@dokploy/server/utils/backups/utils"; import { describe, expect, test } from "vitest"; // Mock destination factory for testing @@ -28,79 +24,6 @@ const createMockDestination = ( ...overrides, }); -describe("getEncryptionConfigFromDestination", () => { - test("should return disabled config when encryption is not enabled", () => { - const destination = createMockDestination({ - encryptionEnabled: false, - encryptionKey: null, - }); - - const config = getEncryptionConfigFromDestination(destination); - - expect(config.enabled).toBe(false); - expect(config.key).toBeNull(); - expect(config.password2).toBeNull(); - expect(config.filenameEncryption).toBe("off"); - expect(config.directoryNameEncryption).toBe(false); - }); - - test("should return enabled config with all encryption options", () => { - const destination = createMockDestination({ - encryptionEnabled: true, - encryptionKey: "my-secret-encryption-key", - encryptionPassword2: "my-salt-password", - filenameEncryption: "standard", - directoryNameEncryption: true, - }); - - const config = getEncryptionConfigFromDestination(destination); - - expect(config.enabled).toBe(true); - expect(config.key).toBe("my-secret-encryption-key"); - expect(config.password2).toBe("my-salt-password"); - expect(config.filenameEncryption).toBe("standard"); - expect(config.directoryNameEncryption).toBe(true); - }); - - test("should handle obfuscate filename encryption", () => { - const destination = createMockDestination({ - encryptionEnabled: true, - encryptionKey: "my-key", - filenameEncryption: "obfuscate", - }); - - const config = getEncryptionConfigFromDestination(destination); - - expect(config.filenameEncryption).toBe("obfuscate"); - }); - - test("should handle null/undefined values with defaults", () => { - const destination = createMockDestination({ - encryptionEnabled: true, - encryptionKey: "my-key", - encryptionPassword2: null, - filenameEncryption: null as unknown as string, - directoryNameEncryption: null as unknown as boolean, - }); - - const config = getEncryptionConfigFromDestination(destination); - - expect(config.password2).toBeNull(); - expect(config.filenameEncryption).toBe("off"); - expect(config.directoryNameEncryption).toBe(false); - }); - - test("should handle undefined encryptionEnabled as false", () => { - const destination = createMockDestination(); - // @ts-expect-error Testing undefined value - destination.encryptionEnabled = undefined; - - const config = getEncryptionConfigFromDestination(destination); - - expect(config.enabled).toBe(false); - }); -}); - describe("getRcloneS3Remote", () => { describe("without encryption", () => { test("should return basic S3 remote without provider", () => { @@ -137,27 +60,25 @@ describe("getRcloneS3Remote", () => { expect(result.remote).toContain(`provider="${destination.provider}"`); }); - test("should return S3 remote when encryption config is disabled", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: false, - key: "some-key", - }; + test("should return S3 remote when encryption is disabled", () => { + const destination = createMockDestination({ + encryptionEnabled: false, + encryptionKey: "some-key", + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.envVars).toBe(""); expect(result.remote).not.toContain(":crypt,"); }); test("should return S3 remote when encryption enabled but no key", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: null, - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: null, + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.envVars).toBe(""); expect(result.remote).not.toContain(":crypt,"); @@ -168,13 +89,11 @@ describe("getRcloneS3Remote", () => { test("should return crypt-wrapped remote with basic encryption", () => { const destination = createMockDestination({ provider: "aws", + encryptionEnabled: true, + encryptionKey: "my-encryption-key", }); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-encryption-key", - }; - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain(":crypt,"); expect(result.remote).toContain("filename_encryption=off"); @@ -183,14 +102,13 @@ describe("getRcloneS3Remote", () => { }); test("should include password2 when provided", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-encryption-key", - password2: "my-salt-password", - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-encryption-key", + encryptionPassword2: "my-salt-password", + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.envVars).toContain( "RCLONE_CRYPT_PASSWORD='my-encryption-key'", @@ -201,40 +119,37 @@ describe("getRcloneS3Remote", () => { }); test("should handle standard filename encryption", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", filenameEncryption: "standard", - }; + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain("filename_encryption=standard"); }); test("should handle obfuscate filename encryption", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", filenameEncryption: "obfuscate", - }; + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain("filename_encryption=obfuscate"); }); test("should handle directory name encryption", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", directoryNameEncryption: true, - }; + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain("directory_name_encryption=true"); }); @@ -242,16 +157,14 @@ describe("getRcloneS3Remote", () => { test("should handle all encryption options together", () => { const destination = createMockDestination({ provider: "aws", - }); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "encryption-key", - password2: "salt-password", + encryptionEnabled: true, + encryptionKey: "encryption-key", + encryptionPassword2: "salt-password", filenameEncryption: "standard", directoryNameEncryption: true, - }; + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain(":crypt,"); expect(result.remote).toContain("filename_encryption=standard"); @@ -265,26 +178,24 @@ describe("getRcloneS3Remote", () => { }); test("should escape single quotes in encryption key", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "key'with'quotes", - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "key'with'quotes", + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.envVars).toContain("key'\\''with'\\''quotes"); }); test("should escape single quotes in password2", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", - password2: "salt'with'quotes", - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + encryptionPassword2: "salt'with'quotes", + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.envVars).toContain( "RCLONE_CRYPT_PASSWORD2='salt'\\''with'\\''quotes'", @@ -295,13 +206,11 @@ describe("getRcloneS3Remote", () => { const destination = createMockDestination({ bucket: "test-bucket", provider: "aws", + encryptionEnabled: true, + encryptionKey: "my-key", }); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", - }; - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); // The crypt remote should contain the S3 remote and bucket expect(result.remote).toMatch(/:crypt,remote=":s3,.*:test-bucket",/); @@ -340,51 +249,39 @@ describe("getRcloneS3Remote", () => { }); test("should handle empty password2", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", - password2: "", - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + encryptionPassword2: "", + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); // Empty string is falsy, so password2 should not be included expect(result.envVars).toBe("RCLONE_CRYPT_PASSWORD='my-key'"); expect(result.envVars).not.toContain("RCLONE_CRYPT_PASSWORD2"); }); - test("should handle undefined encryptionConfig", () => { - const destination = createMockDestination(); - - const result = getRcloneS3Remote(destination, undefined); - - expect(result.envVars).toBe(""); - expect(result.remote).not.toContain(":crypt,"); - }); - test("should handle null filenameEncryption with default", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", - filenameEncryption: null, - }; + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + filenameEncryption: null as unknown as string, + }); - const result = getRcloneS3Remote(destination, encryptionConfig); + const result = getRcloneS3Remote(destination); expect(result.remote).toContain("filename_encryption=off"); }); test("should handle null directoryNameEncryption with default", () => { - const destination = createMockDestination(); - const encryptionConfig: EncryptionConfig = { - enabled: true, - key: "my-key", - directoryNameEncryption: null, - }; - - const result = getRcloneS3Remote(destination, encryptionConfig); + const destination = createMockDestination({ + encryptionEnabled: true, + encryptionKey: "my-key", + directoryNameEncryption: null as unknown as boolean, + }); + + const result = getRcloneS3Remote(destination); expect(result.remote).toContain("directory_name_encryption=false"); }); diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index ca4f53b271..85323af6d8 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -29,7 +29,6 @@ import { runComposeBackup } from "@dokploy/server/utils/backups/compose"; import { buildRcloneCommand, getBackupRemotePath, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "@dokploy/server/utils/backups/utils"; @@ -301,12 +300,8 @@ export const backupRouter = createTRPCRouter({ .query(async ({ input }) => { try { const destination = await findDestinationById(input.destinationId); - const encryptionConfig = - getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote( - destination, - encryptionConfig, - ); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const lastSlashIndex = input.search.lastIndexOf("/"); const baseDir = diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 954f115302..09abee4bde 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -9,8 +9,8 @@ import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { + buildRcloneCommand, getBackupCommand, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -24,7 +24,6 @@ export const runComposeBackup = async ( const project = await findProjectById(environment.projectId); const { prefix, databaseType } = backup; const destination = backup.destination; - const encryptionConfig = getEncryptionConfigFromDestination(destination); const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ @@ -34,17 +33,18 @@ export const runComposeBackup = async ( }); try { - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}/${bucketDestination}`; - const rcloneCommand = envVars - ? `${envVars} rclone rcat "${rcloneDestination}"` - : `rclone rcat "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone rcat "${rcloneDestination}"`, + envVars, + ); const backupCommand = getBackupCommand( backup, rcloneCommand, deployment.logPath, - encryptionConfig, ); if (compose.serverId) { await execAsyncRemote(compose.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index 5618550674..3fbeda7de4 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -10,11 +10,10 @@ import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { - buildRcloneCommand, - getBackupRemotePath, - getEncryptionConfigFromDestination, - getRcloneS3Remote, - scheduleBackup, + buildRcloneCommand, + getBackupRemotePath, + getRcloneS3Remote, + scheduleBackup, } from "./utils"; export const initCronJobs = async () => { @@ -100,36 +99,33 @@ export const initCronJobs = async () => { }; export const keepLatestNBackups = async ( - backup: BackupSchedule, - serverId?: string | null, + backup: BackupSchedule, + serverId?: string | null, ) => { // 0 also immediately returns which is good as the empty "keep latest" field in the UI // is saved as 0 in the database if (!backup.keepLatestCount) return; - try { - const encryptionConfig = getEncryptionConfigFromDestination(backup.destination); - const { remote, envVars } = getRcloneS3Remote( - backup.destination, - encryptionConfig, - ); - const backupFilesPath = getBackupRemotePath(remote, backup.prefix); + try { + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(backup.destination); + const backupFilesPath = getBackupRemotePath(remote, backup.prefix); // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone - const rcloneList = buildRcloneCommand( - `rclone lsf --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`, - envVars, - ); + const rcloneList = buildRcloneCommand( + `rclone lsf --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`, + envVars, + ); // when we pipe the above command with this one, we only get the list of files we want to delete const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; // this command deletes the files // to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{} - const rcloneDelete = buildRcloneCommand( - `rclone delete ${backupFilesPath}/{}`, - envVars, - ); + const rcloneDelete = buildRcloneCommand( + `rclone delete ${backupFilesPath}/{}`, + envVars, + ); - const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; + const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; if (serverId) { await execAsyncRemote(serverId, rcloneCommand); diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index b477420a2b..02a8421eb9 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -9,8 +9,8 @@ import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { + buildRcloneCommand, getBackupCommand, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -24,7 +24,6 @@ export const runMariadbBackup = async ( const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const encryptionConfig = getEncryptionConfigFromDestination(destination); const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ @@ -33,17 +32,18 @@ export const runMariadbBackup = async ( description: "MariaDB Backup", }); try { - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}/${bucketDestination}`; - const rcloneCommand = envVars - ? `${envVars} rclone rcat "${rcloneDestination}"` - : `rclone rcat "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone rcat "${rcloneDestination}"`, + envVars, + ); const backupCommand = getBackupCommand( backup, rcloneCommand, deployment.logPath, - encryptionConfig, ); if (mariadb.serverId) { await execAsyncRemote(mariadb.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 183f27dfab..1303a509eb 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -9,8 +9,8 @@ import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { + buildRcloneCommand, getBackupCommand, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -21,7 +21,6 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const encryptionConfig = getEncryptionConfigFromDestination(destination); const backupFileName = `${new Date().toISOString()}.archive.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ @@ -30,17 +29,18 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { description: "MongoDB Backup", }); try { - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}/${bucketDestination}`; - const rcloneCommand = envVars - ? `${envVars} rclone rcat "${rcloneDestination}"` - : `rclone rcat "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone rcat "${rcloneDestination}"`, + envVars, + ); const backupCommand = getBackupCommand( backup, rcloneCommand, deployment.logPath, - encryptionConfig, ); if (mongo.serverId) { diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 3af7e73e55..65cb8fa213 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -9,8 +9,8 @@ import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { + buildRcloneCommand, getBackupCommand, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -21,7 +21,6 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; - const encryptionConfig = getEncryptionConfigFromDestination(destination); const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ @@ -31,18 +30,18 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { }); try { - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}/${bucketDestination}`; - - const rcloneCommand = envVars - ? `${envVars} rclone rcat "${rcloneDestination}"` - : `rclone rcat "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone rcat "${rcloneDestination}"`, + envVars, + ); const backupCommand = getBackupCommand( backup, rcloneCommand, deployment.logPath, - encryptionConfig, ); if (mysql.serverId) { diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 5b44809afe..0e23327635 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -9,8 +9,8 @@ import { findProjectById } from "@dokploy/server/services/project"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { + buildRcloneCommand, getBackupCommand, - getEncryptionConfigFromDestination, getRcloneS3Remote, normalizeS3Path, } from "./utils"; @@ -30,22 +30,21 @@ export const runPostgresBackup = async ( }); const { prefix } = backup; const destination = backup.destination; - const encryptionConfig = getEncryptionConfigFromDestination(destination); const backupFileName = `${new Date().toISOString()}.sql.gz`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; try { - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}/${bucketDestination}`; - - const rcloneCommand = envVars - ? `${envVars} rclone rcat "${rcloneDestination}"` - : `rclone rcat "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone rcat "${rcloneDestination}"`, + envVars, + ); const backupCommand = getBackupCommand( backup, rcloneCommand, deployment.logPath, - encryptionConfig, ); if (postgres.serverId) { await execAsyncRemote(postgres.serverId, backupCommand); diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index a5f39465ed..081a98cd07 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -10,49 +10,18 @@ import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; import { runWebServerBackup } from "./web-server"; -export type FilenameEncryption = "standard" | "obfuscate" | "off"; - -export interface EncryptionConfig { - enabled: boolean; - key?: string | null; - password2?: string | null; - filenameEncryption?: FilenameEncryption | null; - directoryNameEncryption?: boolean | null; -} - -export const getEncryptionConfigFromDestination = ( - destination: Destination, -): EncryptionConfig => { - return { - enabled: destination.encryptionEnabled ?? false, - key: destination.encryptionKey, - password2: destination.encryptionPassword2, - filenameEncryption: - (destination.filenameEncryption as FilenameEncryption) ?? "off", - directoryNameEncryption: destination.directoryNameEncryption ?? false, - }; -}; - -export const buildRcloneCommand = (command: string, envVars?: string) => { - return envVars ? `${envVars} ${command}` : command; -}; - /** - * Get rclone flags for S3 credentials with optional crypt encryption overlay. - * When encryption is enabled, uses rclone's native crypt backend which provides + * Get rclone remote configuration for S3 with optional transparent crypt encryption overlay. + * When encryption is enabled on the destination, uses rclone's native crypt backend which provides * file name and content encryption using NaCl SecretBox (XSalsa20 cipher + Poly1305). * - * Rclone crypt options: - * - password: Main encryption password (required) - * - password2: Salt password for additional security (optional but recommended) - * - filename_encryption: "standard" (encrypt), "obfuscate", or "off" - * - directory_name_encryption: true/false (only applies if filename_encryption is not "off") + * The encryption is completely transparent to the caller - they just use the returned remote + * and rclone handles encryption/decryption automatically. * * @see https://rclone.org/crypt/ */ export const getRcloneS3Remote = ( destination: Destination, - encryptionConfig?: EncryptionConfig, ): { remote: string; envVars: string } => { const { accessKey, secretAccessKey, region, endpoint, provider, bucket } = destination; @@ -63,24 +32,24 @@ export const getRcloneS3Remote = ( s3Remote = `:s3,provider="${provider}",access_key_id="${accessKey}",secret_access_key="${secretAccessKey}",region="${region}",endpoint="${endpoint}",no_check_bucket=true,force_path_style=true`; } - // If encryption is enabled, wrap the S3 remote with crypt - if (encryptionConfig?.enabled && encryptionConfig?.key) { - const escapedKey = encryptionConfig.key.replace(/'/g, "'\\''"); + // If encryption is enabled, wrap the S3 remote with crypt (transparent encryption) + if (destination.encryptionEnabled && destination.encryptionKey) { + const escapedKey = destination.encryptionKey.replace(/'/g, "'\\''"); - // Build crypt options - const filenameEncryption = encryptionConfig.filenameEncryption || "off"; + // Build crypt options from destination settings + const filenameEncryption = destination.filenameEncryption || "off"; const directoryNameEncryption = - encryptionConfig.directoryNameEncryption ?? false; + destination.directoryNameEncryption ?? false; - // Build the crypt remote string with all options + // Build the crypt remote string - this wraps the S3 remote transparently const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=${filenameEncryption},directory_name_encryption=${directoryNameEncryption}:`; - // Build environment variables for passwords + // Build environment variables for passwords (more secure than command line args) let envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}'`; - // Add password2 (salt) if provided - if (encryptionConfig.password2) { - const escapedPassword2 = encryptionConfig.password2.replace( + // Add password2 (salt) if provided for additional security + if (destination.encryptionPassword2) { + const escapedPassword2 = destination.encryptionPassword2.replace( /'/g, "'\\''", ); @@ -93,12 +62,20 @@ export const getRcloneS3Remote = ( }; } + // No encryption - return plain S3 remote return { remote: `${s3Remote}:${bucket}`, envVars: "", }; }; +/** + * Helper to build rclone command with optional environment variables prefix. + */ +export const buildRcloneCommand = (command: string, envVars?: string) => { + return envVars ? `${envVars} ${command}` : command; +}; + export const scheduleBackup = (backup: BackupSchedule) => { const { schedule, @@ -141,15 +118,15 @@ export const removeScheduleBackup = (backupId: string) => { }; export const normalizeS3Path = (prefix: string) => { - // Trim whitespace and remove leading/trailing slashes - const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, ""); - // Return empty string if prefix is empty, otherwise append trailing slash - return normalizedPrefix ? `${normalizedPrefix}/` : ""; + // Trim whitespace and remove leading/trailing slashes + const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, ""); + // Return empty string if prefix is empty, otherwise append trailing slash + return normalizedPrefix ? `${normalizedPrefix}/` : ""; }; export const getBackupRemotePath = (remote: string, prefix?: string | null) => { - const normalizedPrefix = normalizeS3Path(prefix || ""); - return normalizedPrefix ? `${remote}/${normalizedPrefix}` : remote; + const normalizedPrefix = normalizeS3Path(prefix || ""); + return normalizedPrefix ? `${remote}/${normalizedPrefix}` : remote; }; export const getS3Credentials = (destination: Destination) => { @@ -314,11 +291,9 @@ export const getBackupCommand = ( backup: BackupSchedule, rcloneCommand: string, logPath: string, - encryptionConfig?: EncryptionConfig, ) => { const containerSearch = getContainerSearchCommand(backup); const backupCommand = generateBackupCommand(backup); - const isEncrypted = encryptionConfig?.enabled && encryptionConfig?.key; logger.info( { @@ -326,22 +301,15 @@ export const getBackupCommand = ( backupCommand, rcloneCommand, logPath, - encryptionEnabled: isEncrypted, }, `Executing backup command: ${backup.databaseType} ${backup.backupType}`, ); - // With rclone crypt, encryption is handled by the rclone remote itself const pipelineCommand = `${backupCommand} | ${rcloneCommand}`; - const encryptionLogMessage = isEncrypted - ? `echo "[$(date)] 🔐 Encryption enabled (rclone crypt)" >> ${logPath};` - : ""; - return ` set -eo pipefail; echo "[$(date)] Starting backup process..." >> ${logPath}; - ${encryptionLogMessage} echo "[$(date)] Executing backup command..." >> ${logPath}; CONTAINER_ID=$(${containerSearch}) @@ -360,7 +328,7 @@ export const getBackupCommand = ( } echo "[$(date)] ✅ backup completed successfully" >> ${logPath}; - echo "[$(date)] Starting upload to S3${isEncrypted ? " (encrypted)" : ""}..." >> ${logPath}; + echo "[$(date)] Starting upload to S3..." >> ${logPath}; # Run the upload command and capture the exit status UPLOAD_OUTPUT=$(${pipelineCommand} 2>&1 >/dev/null) || { diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 1849ab63d8..9272a37a0b 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -10,11 +10,7 @@ import { } from "@dokploy/server/services/deployment"; import { findDestinationById } from "@dokploy/server/services/destination"; import { execAsync } from "../process/execAsync"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, - normalizeS3Path, -} from "./utils"; +import { buildRcloneCommand, getRcloneS3Remote, normalizeS3Path } from "./utils"; export const runWebServerBackup = async (backup: BackupSchedule) => { if (IS_CLOUD) { @@ -30,12 +26,12 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { try { const destination = await findDestinationById(backup.destinationId); - const encryptionConfig = getEncryptionConfigFromDestination(destination); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const backupFileName = `webserver-backup-${timestamp}.zip`; - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const s3Path = `${remote}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { @@ -84,17 +80,14 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { writeStream.write("Zipped database and filesystem\n"); - if (encryptionConfig.enabled) { - writeStream.write("🔐 Encryption enabled (rclone crypt)\n"); - } - - // With rclone crypt, encryption happens transparently through the remote - const uploadCommand = envVars - ? `${envVars} rclone copyto "${tempDir}/${backupFileName}" "${s3Path}"` - : `rclone copyto "${tempDir}/${backupFileName}" "${s3Path}"`; + // Upload with rclone (encryption is handled transparently by the crypt remote if enabled) + const uploadCommand = buildRcloneCommand( + `rclone copyto "${tempDir}/${backupFileName}" "${s3Path}"`, + envVars, + ); writeStream.write("Running command to upload backup to S3\n"); await execAsync(uploadCommand); - writeStream.write(`Uploaded backup to S3${encryptionConfig.enabled ? " (encrypted)" : ""} ✅\n`); + writeStream.write("Uploaded backup to S3 ✅\n"); writeStream.end(); await updateDeploymentStatus(deployment.deploymentId, "done"); return true; diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index 98ac96f535..d013362eed 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -2,10 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Compose } from "@dokploy/server/services/compose"; import type { Destination } from "@dokploy/server/services/destination"; import type { z } from "zod"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -26,21 +23,23 @@ export const restoreComposeBackup = async ( } const { serverId, appName, composeType } = compose; - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupInput.backupFile}`; let rcloneCommand: string; if (backupInput.metadata?.mongo) { // Mongo uses rclone copy - rcloneCommand = envVars - ? `${envVars} rclone copy "${backupPath}"` - : `rclone copy "${backupPath}"`; + rcloneCommand = buildRcloneCommand( + `rclone copy "${backupPath}"`, + envVars, + ); } else { // With rclone crypt, decryption happens automatically when reading from the crypt remote - rcloneCommand = envVars - ? `${envVars} rclone cat "${backupPath}" | gunzip` - : `rclone cat "${backupPath}" | gunzip`; + rcloneCommand = buildRcloneCommand( + `rclone cat "${backupPath}" | gunzip`, + envVars, + ); } let credentials: DatabaseCredentials; @@ -85,10 +84,6 @@ export const restoreComposeBackup = async ( emit("Starting restore..."); emit(`Backup file: ${backupInput.backupFile}`); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); - } - emit(`Executing command: ${restoreCommand}`); if (serverId) { diff --git a/packages/server/src/utils/restore/mariadb.ts b/packages/server/src/utils/restore/mariadb.ts index 5e6a8342c8..5f593bf08a 100644 --- a/packages/server/src/utils/restore/mariadb.ts +++ b/packages/server/src/utils/restore/mariadb.ts @@ -2,10 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mariadb } from "@dokploy/server/services/mariadb"; import type { z } from "zod"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -18,14 +15,15 @@ export const restoreMariadbBackup = async ( try { const { appName, serverId, databaseUser, databasePassword } = mariadb; - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupInput.backupFile}`; // With rclone crypt, decryption happens automatically when reading from the crypt remote - const rcloneCommand = envVars - ? `${envVars} rclone cat "${backupPath}" | gunzip` - : `rclone cat "${backupPath}" | gunzip`; + const rcloneCommand = buildRcloneCommand( + `rclone cat "${backupPath}" | gunzip`, + envVars, + ); const command = getRestoreCommand({ appName, @@ -41,10 +39,6 @@ export const restoreMariadbBackup = async ( }); emit("Starting restore..."); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); - } - emit(`Executing command: ${command}`); if (serverId) { diff --git a/packages/server/src/utils/restore/mongo.ts b/packages/server/src/utils/restore/mongo.ts index bb98f9b5fb..cc18ba01c7 100644 --- a/packages/server/src/utils/restore/mongo.ts +++ b/packages/server/src/utils/restore/mongo.ts @@ -2,10 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Mongo } from "@dokploy/server/services/mongo"; import type { z } from "zod"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -18,13 +15,15 @@ export const restoreMongoBackup = async ( try { const { appName, databasePassword, databaseUser, serverId } = mongo; - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupInput.backupFile}`; + // With rclone crypt, decryption happens automatically when reading from the crypt remote - const rcloneCommand = envVars - ? `${envVars} rclone copy "${backupPath}"` - : `rclone copy "${backupPath}"`; + const rcloneCommand = buildRcloneCommand( + `rclone copy "${backupPath}"`, + envVars, + ); const command = getRestoreCommand({ appName, @@ -40,10 +39,6 @@ export const restoreMongoBackup = async ( }); emit("Starting restore..."); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); - } - emit(`Executing command: ${command}`); if (serverId) { diff --git a/packages/server/src/utils/restore/mysql.ts b/packages/server/src/utils/restore/mysql.ts index 8608ef1468..04f0bfa57f 100644 --- a/packages/server/src/utils/restore/mysql.ts +++ b/packages/server/src/utils/restore/mysql.ts @@ -2,10 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { MySql } from "@dokploy/server/services/mysql"; import type { z } from "zod"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -18,14 +15,15 @@ export const restoreMySqlBackup = async ( try { const { appName, databaseRootPassword, serverId } = mysql; - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupInput.backupFile}`; // With rclone crypt, decryption happens automatically when reading from the crypt remote - const rcloneCommand = envVars - ? `${envVars} rclone cat "${backupPath}" | gunzip` - : `rclone cat "${backupPath}" | gunzip`; + const rcloneCommand = buildRcloneCommand( + `rclone cat "${backupPath}" | gunzip`, + envVars, + ); const command = getRestoreCommand({ appName, @@ -40,10 +38,6 @@ export const restoreMySqlBackup = async ( }); emit("Starting restore..."); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); - } - emit(`Executing command: ${command}`); if (serverId) { diff --git a/packages/server/src/utils/restore/postgres.ts b/packages/server/src/utils/restore/postgres.ts index 6cb7cb4c11..9cd3758d57 100644 --- a/packages/server/src/utils/restore/postgres.ts +++ b/packages/server/src/utils/restore/postgres.ts @@ -2,10 +2,7 @@ import type { apiRestoreBackup } from "@dokploy/server/db/schema"; import type { Destination } from "@dokploy/server/services/destination"; import type { Postgres } from "@dokploy/server/services/postgres"; import type { z } from "zod"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getRestoreCommand } from "./utils"; @@ -18,20 +15,18 @@ export const restorePostgresBackup = async ( try { const { appName, databaseUser, serverId } = postgres; - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupInput.backupFile}`; // With rclone crypt, decryption happens automatically when reading from the crypt remote - const rcloneCommand = envVars - ? `${envVars} rclone cat "${backupPath}" | gunzip` - : `rclone cat "${backupPath}" | gunzip`; + const rcloneCommand = buildRcloneCommand( + `rclone cat "${backupPath}" | gunzip`, + envVars, + ); emit("Starting restore..."); emit(`Backup file: ${backupInput.backupFile}`); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during restore (rclone crypt)"); - } const command = getRestoreCommand({ appName, diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index 4b0cb91243..b5e3fce571 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -3,10 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; import type { Destination } from "@dokploy/server/services/destination"; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; import { execAsync } from "../process/execAsync"; export const restoreWebServerBackup = async ( @@ -18,8 +15,8 @@ export const restoreWebServerBackup = async ( return; } try { - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}/${backupFile}`; const { BASE_PATH } = paths(); @@ -30,9 +27,6 @@ export const restoreWebServerBackup = async ( emit("Starting restore..."); emit(`Backup file: ${backupFile}`); emit(`Temp directory: ${tempDir}`); - if (encryptionConfig.enabled) { - emit("🔐 Encryption enabled - will decrypt during download (rclone crypt)"); - } // Create temp directory emit("Creating temporary directory..."); @@ -40,9 +34,10 @@ export const restoreWebServerBackup = async ( // Download backup from S3 (with rclone crypt, decryption happens automatically) emit("Downloading backup from S3..."); - const downloadCommand = envVars - ? `${envVars} rclone copyto "${backupPath}" "${tempDir}/${backupFile}"` - : `rclone copyto "${backupPath}" "${tempDir}/${backupFile}"`; + const downloadCommand = buildRcloneCommand( + `rclone copyto "${backupPath}" "${tempDir}/${backupFile}"`, + envVars, + ); await execAsync(downloadCommand); // List files before extraction diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index 968f0a02a4..7cb3ef0716 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -3,7 +3,7 @@ import { paths } from "@dokploy/server/constants"; import { findComposeById } from "@dokploy/server/services/compose"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import { - getEncryptionConfigFromDestination, + buildRcloneCommand, getRcloneS3Remote, normalizeS3Path, } from "../backups/utils"; @@ -19,21 +19,21 @@ export const backupVolume = async ( const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`; const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; - // Get encryption config and build rclone remote with optional crypt overlay - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const rcloneDestination = `${remote}${bucketDestination}`; const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); - const rcloneCommand = `${envVars ? `${envVars} ` : ""}rclone copyto "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`; + const rcloneCommand = buildRcloneCommand( + `rclone copyto "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`, + envVars, + ); - const isEncrypted = encryptionConfig.enabled && encryptionConfig.key; const baseCommand = ` set -e echo "Volume name: ${volumeName}" echo "Backup file name: ${backupFileName}" echo "Turning off volume backup: ${turnOff ? "Yes" : "No"}" - ${isEncrypted ? 'echo "🔐 Encryption enabled (rclone crypt)"' : ""} echo "Starting volume backup" echo "Dir: ${volumeBackupPath}" docker run --rm \ @@ -42,7 +42,7 @@ export const backupVolume = async ( ubuntu \ bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ." echo "Volume backup done ✅" - echo "Starting upload to S3${isEncrypted ? " (encrypted)" : ""}..." + echo "Starting upload to S3..." ${rcloneCommand} echo "Upload to S3 done ✅" echo "Cleaning up local backup file..." diff --git a/packages/server/src/utils/volume-backups/restore.ts b/packages/server/src/utils/volume-backups/restore.ts index 0d0b82156f..7db34fd4a0 100644 --- a/packages/server/src/utils/volume-backups/restore.ts +++ b/packages/server/src/utils/volume-backups/restore.ts @@ -5,10 +5,7 @@ import { findDestinationById, paths, } from "../.."; -import { - getEncryptionConfigFromDestination, - getRcloneS3Remote, -} from "../backups/utils"; +import { buildRcloneCommand, getRcloneS3Remote } from "../backups/utils"; export const restoreVolume = async ( id: string, @@ -22,14 +19,15 @@ export const restoreVolume = async ( const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeName); - // Get encryption config and build rclone remote with optional crypt overlay - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote(destination, encryptionConfig); + // Get rclone remote (decryption is handled transparently if encryption is enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const backupPath = `${remote}${backupFileName}`; - const isEncrypted = encryptionConfig.enabled && encryptionConfig.key; // Command to download backup file from S3 (decryption happens automatically with rclone crypt) - const downloadCommand = `${envVars ? `${envVars} ` : ""}rclone copyto "${backupPath}" "${volumeBackupPath}/${backupFileName}"`; + const downloadCommand = buildRcloneCommand( + `rclone copyto "${backupPath}" "${volumeBackupPath}/${backupFileName}"`, + envVars, + ); // Base restore command that creates the volume and restores data const baseRestoreCommand = ` @@ -37,8 +35,7 @@ export const restoreVolume = async ( echo "Volume name: ${volumeName}" echo "Backup file name: ${backupFileName}" echo "Volume backup path: ${volumeBackupPath}" - ${isEncrypted ? 'echo "🔐 Decryption enabled (rclone crypt)"' : ""} - echo "Downloading backup from S3${isEncrypted ? " (decrypting)" : ""}..." + echo "Downloading backup from S3..." mkdir -p ${volumeBackupPath} ${downloadCommand} echo "Download completed ✅" @@ -56,16 +53,16 @@ export const restoreVolume = async ( # Check if volume exists VOLUME_EXISTS=$(docker volume ls -q --filter name="^${volumeName}$" | wc -l) echo "Volume exists: $VOLUME_EXISTS" - + if [ "$VOLUME_EXISTS" = "0" ]; then echo "Volume doesn't exist, proceeding with direct restore" ${baseRestoreCommand} else echo "Volume exists, checking for containers using it (including stopped ones)..." - + # Get ALL containers (running and stopped) using this volume - much simpler with native filter! CONTAINERS_USING_VOLUME=$(docker ps -a --filter "volume=${volumeName}" --format "{{.ID}}|{{.Names}}|{{.State}}|{{.Labels}}") - + if [ -z "$CONTAINERS_USING_VOLUME" ]; then echo "Volume exists but no containers are using it" echo "Removing existing volume and proceeding with restore" @@ -77,11 +74,11 @@ export const restoreVolume = async ( echo "" echo "📋 The following containers are using volume '${volumeName}':" echo "" - + echo "$CONTAINERS_USING_VOLUME" | while IFS='|' read container_id container_name container_state labels; do echo " 🐳 Container: $container_name ($container_id)" echo " Status: $container_state" - + # Determine container type if echo "$labels" | grep -q "com.docker.swarm.service.name="; then SERVICE_NAME=$(echo "$labels" | grep -o "com.docker.swarm.service.name=[^,]*" | cut -d'=' -f2) @@ -94,7 +91,7 @@ export const restoreVolume = async ( fi echo "" done - + echo "" echo "🔧 To restore this volume, please:" echo " 1. Stop all containers/services using this volume" @@ -102,7 +99,7 @@ export const restoreVolume = async ( echo " 3. Run the restore operation again" echo "" echo "❌ Volume restore aborted - volume is in use" - + exit 1 fi fi diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index 1b0197ab6d..6e8a250dda 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -11,7 +11,7 @@ import { } from "@dokploy/server/utils/process/execAsync"; import { scheduledJobs, scheduleJob } from "node-schedule"; import { - getEncryptionConfigFromDestination, + buildRcloneCommand, getRcloneS3Remote, normalizeS3Path, } from "../backups/utils"; @@ -84,20 +84,21 @@ const cleanupOldVolumeBackups = async ( if (!keepLatestCount) return; try { - // Get encryption config and build rclone remote with optional crypt overlay - const encryptionConfig = getEncryptionConfigFromDestination(destination); - const { remote, envVars } = getRcloneS3Remote( - destination, - encryptionConfig, - ); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); const normalizedPrefix = normalizeS3Path(prefix); const backupFilesPath = `${remote}${normalizedPrefix}`; // When using rclone crypt, filenames are automatically decrypted during listing - const envPrefix = envVars ? `${envVars} ` : ""; - const listCommand = `${envPrefix}rclone lsf --include "${volumeName}-*.tar" "${backupFilesPath}"`; + const listCommand = buildRcloneCommand( + `rclone lsf --include "${volumeName}-*.tar" "${backupFilesPath}"`, + envVars, + ); const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; - const deleteCommand = `${envPrefix}rclone delete "${backupFilesPath}{}"`; + const deleteCommand = buildRcloneCommand( + `rclone delete "${backupFilesPath}{}"`, + envVars, + ); const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`; if (serverId) { From 26072d343354dc6e69d136c460d76313ce3e6dd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Jan 2026 23:09:23 +0000 Subject: [PATCH 09/11] refactor: simplify S3 backup encryption to transparent rclone crypt layer - Remove duplicate getS3Credentials function - Improve documentation for getRcloneS3Remote - Add @deprecated notice to legacy getS3Credentials function - Clean up code organization in utils.ts The encryption is implemented as a transparent overlay using rclone's native crypt backend. When encryption is enabled on a destination, the S3 remote is automatically wrapped with a crypt remote. --- packages/server/src/utils/backups/utils.ts | 104 ++++++++++----------- 1 file changed, 49 insertions(+), 55 deletions(-) diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 081a98cd07..42ec647913 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -11,69 +11,82 @@ import { runPostgresBackup } from "./postgres"; import { runWebServerBackup } from "./web-server"; /** - * Get rclone remote configuration for S3 with optional transparent crypt encryption overlay. - * When encryption is enabled on the destination, uses rclone's native crypt backend which provides - * file name and content encryption using NaCl SecretBox (XSalsa20 cipher + Poly1305). + * Get S3 rclone remote with optional transparent crypt encryption. + * When encryption is enabled, returns a crypt-wrapped remote using rclone's + * native crypt backend (NaCl SecretBox: XSalsa20 + Poly1305). * - * The encryption is completely transparent to the caller - they just use the returned remote - * and rclone handles encryption/decryption automatically. + * Uses connection string syntax for inline remote configuration. + * Encryption passwords are passed via environment variables (more secure than CLI args). * * @see https://rclone.org/crypt/ + * @see https://rclone.org/docs/#connection-strings */ -export const getRcloneS3Remote = ( - destination: Destination, -): { remote: string; envVars: string } => { +export const getRcloneS3Remote = (destination: Destination): { + remote: string; + envVars: string; +} => { const { accessKey, secretAccessKey, region, endpoint, provider, bucket } = destination; - // Build the base S3 remote string + // Build S3 connection string (credentials embedded in remote) let s3Remote = `:s3,access_key_id="${accessKey}",secret_access_key="${secretAccessKey}",region="${region}",endpoint="${endpoint}",no_check_bucket=true,force_path_style=true`; if (provider) { s3Remote = `:s3,provider="${provider}",access_key_id="${accessKey}",secret_access_key="${secretAccessKey}",region="${region}",endpoint="${endpoint}",no_check_bucket=true,force_path_style=true`; } - // If encryption is enabled, wrap the S3 remote with crypt (transparent encryption) + // If encryption enabled, wrap S3 remote with crypt if (destination.encryptionEnabled && destination.encryptionKey) { - const escapedKey = destination.encryptionKey.replace(/'/g, "'\\''"); - - // Build crypt options from destination settings const filenameEncryption = destination.filenameEncryption || "off"; - const directoryNameEncryption = - destination.directoryNameEncryption ?? false; + const directoryNameEncryption = destination.directoryNameEncryption ?? false; - // Build the crypt remote string - this wraps the S3 remote transparently + // Crypt remote wraps S3 transparently const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=${filenameEncryption},directory_name_encryption=${directoryNameEncryption}:`; - // Build environment variables for passwords (more secure than command line args) + // Password via env var (more secure than CLI args) + const escapedKey = destination.encryptionKey.replace(/'/g, "'\\''"); let envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}'`; - - // Add password2 (salt) if provided for additional security if (destination.encryptionPassword2) { - const escapedPassword2 = destination.encryptionPassword2.replace( - /'/g, - "'\\''", - ); - envVars += ` RCLONE_CRYPT_PASSWORD2='${escapedPassword2}'`; + const escapedPassword2 = destination.encryptionPassword2.replace(/'/g, "'\\''"); + envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}' RCLONE_CRYPT_PASSWORD2='${escapedPassword2}'`; } - return { - remote: cryptRemote, - envVars, - }; + return { remote: cryptRemote, envVars }; } - // No encryption - return plain S3 remote - return { - remote: `${s3Remote}:${bucket}`, - envVars: "", - }; + // No encryption - use plain S3 remote with connection string + return { remote: `${s3Remote}:${bucket}`, envVars: "" }; +}; + +/** + * Build rclone command with optional encryption environment variables. + * Prepends env vars to the command when provided. + */ +export const buildRcloneCommand = (command: string, envVars?: string): string => { + if (envVars) { + return `${envVars} ${command}`; + } + return command; }; /** - * Helper to build rclone command with optional environment variables prefix. + * Legacy function for backwards compatibility. + * Returns rclone flags for S3 access (non-encrypted destinations only). + * @deprecated Use getRcloneS3Remote instead for encryption support. */ -export const buildRcloneCommand = (command: string, envVars?: string) => { - return envVars ? `${envVars} ${command}` : command; +export const getS3Credentials = (destination: Destination) => { + const { accessKey, secretAccessKey, region, endpoint, provider } = destination; + const rcloneFlags = [ + `--s3-access-key-id="${accessKey}"`, + `--s3-secret-access-key="${secretAccessKey}"`, + `--s3-region="${region}"`, + `--s3-endpoint="${endpoint}"`, + "--s3-no-check-bucket", + "--s3-force-path-style", + ]; + if (provider) { + rcloneFlags.unshift(`--s3-provider="${provider}"`); + } + return rcloneFlags; }; export const scheduleBackup = (backup: BackupSchedule) => { @@ -129,25 +142,6 @@ export const getBackupRemotePath = (remote: string, prefix?: string | null) => { return normalizedPrefix ? `${remote}/${normalizedPrefix}` : remote; }; -export const getS3Credentials = (destination: Destination) => { - const { accessKey, secretAccessKey, region, endpoint, provider } = - destination; - const rcloneFlags = [ - `--s3-access-key-id="${accessKey}"`, - `--s3-secret-access-key="${secretAccessKey}"`, - `--s3-region="${region}"`, - `--s3-endpoint="${endpoint}"`, - "--s3-no-check-bucket", - "--s3-force-path-style", - ]; - - if (provider) { - rcloneFlags.unshift(`--s3-provider="${provider}"`); - } - - return rcloneFlags; -}; - export const getPostgresBackupCommand = ( database: string, databaseUser: string, From c456f32b885a5f4485fca1ab1a597742d3bf0736 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 09:50:43 +0000 Subject: [PATCH 10/11] fix: correct indentation in backup.ts router Reset backup.ts to canary and re-applied encryption changes with proper tab indentation (was incorrectly using spaces). --- apps/dokploy/server/api/routers/backup.ts | 44 +++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 85323af6d8..e971444057 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -27,10 +27,10 @@ import { import { findDestinationById } from "@dokploy/server/services/destination"; import { runComposeBackup } from "@dokploy/server/utils/backups/compose"; import { - buildRcloneCommand, - getBackupRemotePath, - getRcloneS3Remote, - normalizeS3Path, + buildRcloneCommand, + getBackupRemotePath, + getRcloneS3Remote, + normalizeS3Path, } from "@dokploy/server/utils/backups/utils"; import { execAsync, @@ -298,26 +298,26 @@ export const backupRouter = createTRPCRouter({ }), ) .query(async ({ input }) => { - try { - const destination = await findDestinationById(input.destinationId); - // Get rclone remote (encryption is handled transparently if enabled) - const { remote, envVars } = getRcloneS3Remote(destination); + try { + const destination = await findDestinationById(input.destinationId); + // Get rclone remote (encryption is handled transparently if enabled) + const { remote, envVars } = getRcloneS3Remote(destination); - const lastSlashIndex = input.search.lastIndexOf("/"); - const baseDir = - lastSlashIndex !== -1 - ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1)) - : ""; - const searchTerm = - lastSlashIndex !== -1 - ? input.search.slice(lastSlashIndex + 1) - : input.search; + const lastSlashIndex = input.search.lastIndexOf("/"); + const baseDir = + lastSlashIndex !== -1 + ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1)) + : ""; + const searchTerm = + lastSlashIndex !== -1 + ? input.search.slice(lastSlashIndex + 1) + : input.search; - const searchPath = getBackupRemotePath(remote, baseDir); - const listCommand = buildRcloneCommand( - `rclone lsjson "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`, - envVars, - ); + const searchPath = getBackupRemotePath(remote, baseDir); + const listCommand = buildRcloneCommand( + `rclone lsjson "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`, + envVars, + ); let stdout = ""; From f0af03f6138f0f8b12153197e7d78ba388d35cb6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:36:48 +0000 Subject: [PATCH 11/11] [autofix.ci] apply automated fixes --- .../__test__/backup/encryption-config.test.ts | 113 +++++++++--------- packages/server/src/utils/backups/utils.ts | 20 +++- .../server/src/utils/backups/web-server.ts | 6 +- .../server/src/utils/restore/web-server.ts | 4 +- 4 files changed, 77 insertions(+), 66 deletions(-) diff --git a/apps/dokploy/__test__/backup/encryption-config.test.ts b/apps/dokploy/__test__/backup/encryption-config.test.ts index b1ada555cf..af40c24623 100644 --- a/apps/dokploy/__test__/backup/encryption-config.test.ts +++ b/apps/dokploy/__test__/backup/encryption-config.test.ts @@ -1,76 +1,75 @@ import { describe, expect, it } from "vitest"; import { - buildRcloneCommand, - getBackupRemotePath, - getRcloneS3Remote, + buildRcloneCommand, + getBackupRemotePath, + getRcloneS3Remote, } from "@dokploy/server/utils/backups/utils"; import type { Destination } from "@dokploy/server/services/destination"; -const createDestination = (overrides: Partial = {}): Destination => ({ - destinationId: "dest-1", - name: "Encrypted bucket", - provider: "", - accessKey: "ACCESS_KEY", - secretAccessKey: "SECRET_KEY", - bucket: "my-bucket", - region: "us-east-1", - endpoint: "https://s3.example.com", - organizationId: "org-1", - createdAt: new Date("2024-01-01T00:00:00Z"), - encryptionEnabled: false, - encryptionKey: null, - encryptionPassword2: null, - filenameEncryption: "off", - directoryNameEncryption: false, - ...overrides, +const createDestination = ( + overrides: Partial = {}, +): Destination => ({ + destinationId: "dest-1", + name: "Encrypted bucket", + provider: "", + accessKey: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + bucket: "my-bucket", + region: "us-east-1", + endpoint: "https://s3.example.com", + organizationId: "org-1", + createdAt: new Date("2024-01-01T00:00:00Z"), + encryptionEnabled: false, + encryptionKey: null, + encryptionPassword2: null, + filenameEncryption: "off", + directoryNameEncryption: false, + ...overrides, }); describe("rclone encryption helpers", () => { - it("builds a plain S3 remote without encryption", () => { - const destination = createDestination(); + it("builds a plain S3 remote without encryption", () => { + const destination = createDestination(); - const { remote, envVars } = getRcloneS3Remote(destination); + const { remote, envVars } = getRcloneS3Remote(destination); - expect(envVars).toBe(""); - expect(remote).toContain(":s3,"); - expect(remote).toContain("my-bucket"); - }); + expect(envVars).toBe(""); + expect(remote).toContain(":s3,"); + expect(remote).toContain("my-bucket"); + }); - it("builds a crypt remote and env vars when encryption is enabled", () => { - const destination = createDestination({ - encryptionEnabled: true, - encryptionKey: "primary-pass", - encryptionPassword2: "salt-pass", - filenameEncryption: "standard", - directoryNameEncryption: true, - }); + it("builds a crypt remote and env vars when encryption is enabled", () => { + const destination = createDestination({ + encryptionEnabled: true, + encryptionKey: "primary-pass", + encryptionPassword2: "salt-pass", + filenameEncryption: "standard", + directoryNameEncryption: true, + }); - const { remote, envVars } = getRcloneS3Remote(destination); + const { remote, envVars } = getRcloneS3Remote(destination); - expect(remote.startsWith(":crypt")).toBe(true); - expect(remote).toContain("remote=\":s3,"); - expect(remote.endsWith(":")).toBe(true); - expect(envVars).toContain("RCLONE_CRYPT_PASSWORD='primary-pass'"); - expect(envVars).toContain("RCLONE_CRYPT_PASSWORD2='salt-pass'"); - }); + expect(remote.startsWith(":crypt")).toBe(true); + expect(remote).toContain('remote=":s3,'); + expect(remote.endsWith(":")).toBe(true); + expect(envVars).toContain("RCLONE_CRYPT_PASSWORD='primary-pass'"); + expect(envVars).toContain("RCLONE_CRYPT_PASSWORD2='salt-pass'"); + }); - it("returns the correct remote path for nested prefixes", () => { - const destination = createDestination(); - const { remote } = getRcloneS3Remote(destination); + it("returns the correct remote path for nested prefixes", () => { + const destination = createDestination(); + const { remote } = getRcloneS3Remote(destination); - const remotePath = getBackupRemotePath(remote, "daily/db"); + const remotePath = getBackupRemotePath(remote, "daily/db"); - expect(remotePath).toBe(`${remote}/daily/db/`); - }); + expect(remotePath).toBe(`${remote}/daily/db/`); + }); - it("adds encryption env vars to commands only when provided", () => { - expect(buildRcloneCommand("rclone lsf remote")).toBe("rclone lsf remote"); + it("adds encryption env vars to commands only when provided", () => { + expect(buildRcloneCommand("rclone lsf remote")).toBe("rclone lsf remote"); - expect( - buildRcloneCommand( - "rclone lsf remote", - "RCLONE_CRYPT_PASSWORD='secret'", - ), - ).toBe("RCLONE_CRYPT_PASSWORD='secret' rclone lsf remote"); - }); + expect( + buildRcloneCommand("rclone lsf remote", "RCLONE_CRYPT_PASSWORD='secret'"), + ).toBe("RCLONE_CRYPT_PASSWORD='secret' rclone lsf remote"); + }); }); diff --git a/packages/server/src/utils/backups/utils.ts b/packages/server/src/utils/backups/utils.ts index 42ec647913..7c9284bfbe 100644 --- a/packages/server/src/utils/backups/utils.ts +++ b/packages/server/src/utils/backups/utils.ts @@ -21,7 +21,9 @@ import { runWebServerBackup } from "./web-server"; * @see https://rclone.org/crypt/ * @see https://rclone.org/docs/#connection-strings */ -export const getRcloneS3Remote = (destination: Destination): { +export const getRcloneS3Remote = ( + destination: Destination, +): { remote: string; envVars: string; } => { @@ -37,7 +39,8 @@ export const getRcloneS3Remote = (destination: Destination): { // If encryption enabled, wrap S3 remote with crypt if (destination.encryptionEnabled && destination.encryptionKey) { const filenameEncryption = destination.filenameEncryption || "off"; - const directoryNameEncryption = destination.directoryNameEncryption ?? false; + const directoryNameEncryption = + destination.directoryNameEncryption ?? false; // Crypt remote wraps S3 transparently const cryptRemote = `:crypt,remote="${s3Remote}:${bucket}",filename_encryption=${filenameEncryption},directory_name_encryption=${directoryNameEncryption}:`; @@ -46,7 +49,10 @@ export const getRcloneS3Remote = (destination: Destination): { const escapedKey = destination.encryptionKey.replace(/'/g, "'\\''"); let envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}'`; if (destination.encryptionPassword2) { - const escapedPassword2 = destination.encryptionPassword2.replace(/'/g, "'\\''"); + const escapedPassword2 = destination.encryptionPassword2.replace( + /'/g, + "'\\''", + ); envVars = `RCLONE_CRYPT_PASSWORD='${escapedKey}' RCLONE_CRYPT_PASSWORD2='${escapedPassword2}'`; } @@ -61,7 +67,10 @@ export const getRcloneS3Remote = (destination: Destination): { * Build rclone command with optional encryption environment variables. * Prepends env vars to the command when provided. */ -export const buildRcloneCommand = (command: string, envVars?: string): string => { +export const buildRcloneCommand = ( + command: string, + envVars?: string, +): string => { if (envVars) { return `${envVars} ${command}`; } @@ -74,7 +83,8 @@ export const buildRcloneCommand = (command: string, envVars?: string): string => * @deprecated Use getRcloneS3Remote instead for encryption support. */ export const getS3Credentials = (destination: Destination) => { - const { accessKey, secretAccessKey, region, endpoint, provider } = destination; + const { accessKey, secretAccessKey, region, endpoint, provider } = + destination; const rcloneFlags = [ `--s3-access-key-id="${accessKey}"`, `--s3-secret-access-key="${secretAccessKey}"`, diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 9272a37a0b..7665d656e7 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -10,7 +10,11 @@ import { } from "@dokploy/server/services/deployment"; import { findDestinationById } from "@dokploy/server/services/destination"; import { execAsync } from "../process/execAsync"; -import { buildRcloneCommand, getRcloneS3Remote, normalizeS3Path } from "./utils"; +import { + buildRcloneCommand, + getRcloneS3Remote, + normalizeS3Path, +} from "./utils"; export const runWebServerBackup = async (backup: BackupSchedule) => { if (IS_CLOUD) { diff --git a/packages/server/src/utils/restore/web-server.ts b/packages/server/src/utils/restore/web-server.ts index b5e3fce571..cde60857dd 100644 --- a/packages/server/src/utils/restore/web-server.ts +++ b/packages/server/src/utils/restore/web-server.ts @@ -47,9 +47,7 @@ export const restoreWebServerBackup = async ( // Extract backup emit("Extracting backup..."); - await execAsync( - `cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`, - ); + await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`); // Restore filesystem first emit("Restoring filesystem...");