mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-08 21:15:17 +00:00
feat: optionally include encryption key in web server backups
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
decryptValue,
|
decryptValue,
|
||||||
encryptValue,
|
encryptValue,
|
||||||
|
exportEncryptionKey,
|
||||||
isEncrypted,
|
isEncrypted,
|
||||||
} from "@dokploy/server/lib/encryption";
|
} from "@dokploy/server/lib/encryption";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -42,6 +43,10 @@ describe("encryptValue / decryptValue", () => {
|
|||||||
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
||||||
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
|
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports the primary key as 32-byte hex for backups", () => {
|
||||||
|
expect(exportEncryptionKey()).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dedicated ENCRYPTION_KEY", () => {
|
describe("dedicated ENCRYPTION_KEY", () => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const Schema = z
|
|||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
prefix: z.string().min(1, "Prefix required"),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
includeEncryptionKey: z.boolean(),
|
||||||
database: z.string().min(1, "Database required"),
|
database: z.string().min(1, "Database required"),
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
serviceName: z.string().nullable(),
|
serviceName: z.string().nullable(),
|
||||||
@@ -223,6 +224,7 @@ export const HandleBackup = ({
|
|||||||
: "",
|
: "",
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
includeEncryptionKey: true,
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
schedule: "",
|
schedule: "",
|
||||||
keepLatestCount: undefined,
|
keepLatestCount: undefined,
|
||||||
@@ -262,6 +264,7 @@ export const HandleBackup = ({
|
|||||||
: "",
|
: "",
|
||||||
destinationId: backup?.destinationId ?? "",
|
destinationId: backup?.destinationId ?? "",
|
||||||
enabled: backup?.enabled ?? true,
|
enabled: backup?.enabled ?? true,
|
||||||
|
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
|
||||||
prefix: backup?.prefix ?? "/",
|
prefix: backup?.prefix ?? "/",
|
||||||
schedule: backup?.schedule ?? "",
|
schedule: backup?.schedule ?? "",
|
||||||
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||||
@@ -309,6 +312,7 @@ export const HandleBackup = ({
|
|||||||
prefix: data.prefix,
|
prefix: data.prefix,
|
||||||
schedule: data.schedule,
|
schedule: data.schedule,
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
includeEncryptionKey: data.includeEncryptionKey,
|
||||||
database: data.database,
|
database: data.database,
|
||||||
keepLatestCount: data.keepLatestCount ?? null,
|
keepLatestCount: data.keepLatestCount ?? null,
|
||||||
databaseType: data.databaseType || databaseType,
|
databaseType: data.databaseType || databaseType,
|
||||||
@@ -665,6 +669,31 @@ export const HandleBackup = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{databaseType === "web-server" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeEncryptionKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Include encryption key</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Stores the encryption key inside the backup so
|
||||||
|
environment variables can be restored on a new server.
|
||||||
|
Anyone with access to the backup file can decrypt
|
||||||
|
them.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{backupType === "compose" && (
|
{backupType === "compose" && (
|
||||||
<>
|
<>
|
||||||
{form.watch("databaseType") === "postgres" && (
|
{form.watch("databaseType") === "postgres" && (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "backup" ADD COLUMN "includeEncryptionKey" boolean DEFAULT true NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1219,6 +1219,13 @@
|
|||||||
"when": 1783494977500,
|
"when": 1783494977500,
|
||||||
"tag": "0173_aspiring_annihilus",
|
"tag": "0173_aspiring_annihilus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 174,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783674181297,
|
||||||
|
"tag": "0174_great_naoko",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,7 @@ export const backups = pgTable("backup", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
||||||
keepLatestCount: integer("keepLatestCount"),
|
keepLatestCount: integer("keepLatestCount"),
|
||||||
|
includeEncryptionKey: boolean("includeEncryptionKey").notNull().default(true),
|
||||||
backupType: backupType("backupType").notNull().default("database"),
|
backupType: backupType("backupType").notNull().default("database"),
|
||||||
databaseType: databaseType("databaseType").notNull(),
|
databaseType: databaseType("databaseType").notNull(),
|
||||||
composeId: text("composeId").references(
|
composeId: text("composeId").references(
|
||||||
@@ -160,6 +161,7 @@ const createSchema = createInsertSchema(backups, {
|
|||||||
mongoId: z.string().optional(),
|
mongoId: z.string().optional(),
|
||||||
libsqlId: z.string().optional(),
|
libsqlId: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
|
includeEncryptionKey: z.boolean().optional(),
|
||||||
metadata: z.any().optional(),
|
metadata: z.any().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,6 +182,7 @@ export const apiCreateBackup = createSchema.pick({
|
|||||||
backupType: true,
|
backupType: true,
|
||||||
composeId: true,
|
composeId: true,
|
||||||
serviceName: true,
|
serviceName: true,
|
||||||
|
includeEncryptionKey: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,7 +209,10 @@ export const apiUpdateBackup = createSchema
|
|||||||
metadata: true,
|
metadata: true,
|
||||||
databaseType: true,
|
databaseType: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.extend({
|
||||||
|
includeEncryptionKey: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiRestoreBackup = z.object({
|
export const apiRestoreBackup = z.object({
|
||||||
databaseId: z.string(),
|
databaseId: z.string(),
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import {
|
|||||||
createHmac,
|
createHmac,
|
||||||
randomBytes,
|
randomBytes,
|
||||||
} from "node:crypto";
|
} from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
import { betterAuthSecret } from "./auth-secret";
|
import { betterAuthSecret } from "./auth-secret";
|
||||||
import { encryptionSecret } from "./encryption-secret";
|
import { encryptionSecret } from "./encryption-secret";
|
||||||
|
|
||||||
|
export const ENCRYPTION_KEY_BACKUP_FILE = "encryption.key";
|
||||||
|
|
||||||
const ENCRYPTION_PREFIX = "enc:v1:";
|
const ENCRYPTION_PREFIX = "enc:v1:";
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
@@ -23,6 +28,33 @@ const decryptionKeys = encryptionSecret
|
|||||||
? [primaryKey, deriveKey(betterAuthSecret)]
|
? [primaryKey, deriveKey(betterAuthSecret)]
|
||||||
: [primaryKey];
|
: [primaryKey];
|
||||||
|
|
||||||
|
export const exportEncryptionKey = () => primaryKey.toString("hex");
|
||||||
|
|
||||||
|
let restoredKey: Buffer | undefined;
|
||||||
|
|
||||||
|
// A backup created with "include encryption key" places the original
|
||||||
|
// server's key at BASE_PATH when restored; use it as a last-resort
|
||||||
|
// decryption fallback so restored values keep working on the new server.
|
||||||
|
const loadRestoredKey = (): Buffer | undefined => {
|
||||||
|
if (restoredKey) {
|
||||||
|
return restoredKey;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { BASE_PATH } = paths();
|
||||||
|
const hex = readFileSync(
|
||||||
|
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
|
||||||
|
"utf8",
|
||||||
|
).trim();
|
||||||
|
const key = Buffer.from(hex, "hex");
|
||||||
|
if (key.length === 32 && !key.equals(primaryKey)) {
|
||||||
|
restoredKey = key;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No restored key file present.
|
||||||
|
}
|
||||||
|
return restoredKey;
|
||||||
|
};
|
||||||
|
|
||||||
export const isEncrypted = (value: string) =>
|
export const isEncrypted = (value: string) =>
|
||||||
value.startsWith(ENCRYPTION_PREFIX);
|
value.startsWith(ENCRYPTION_PREFIX);
|
||||||
|
|
||||||
@@ -47,7 +79,9 @@ export const decryptValue = (value: string): string => {
|
|||||||
const iv = payload.subarray(0, IV_LENGTH);
|
const iv = payload.subarray(0, IV_LENGTH);
|
||||||
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
for (const key of decryptionKeys) {
|
const restored = loadRestoredKey();
|
||||||
|
const keys = restored ? [...decryptionKeys, restored] : decryptionKeys;
|
||||||
|
for (const key of keys) {
|
||||||
try {
|
try {
|
||||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||||
decipher.setAuthTag(authTag);
|
decipher.setAuthTag(authTag);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||||
|
import {
|
||||||
|
ENCRYPTION_KEY_BACKUP_FILE,
|
||||||
|
exportEncryptionKey,
|
||||||
|
} from "@dokploy/server/lib/encryption";
|
||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
import {
|
import {
|
||||||
createDeploymentBackup,
|
createDeploymentBackup,
|
||||||
@@ -78,11 +82,22 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
await execAsync(cleanupCommand);
|
await execAsync(cleanupCommand);
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' --exclude='${ENCRYPTION_KEY_BACKUP_FILE}' ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeStream.write("Copied filesystem to temp directory\n");
|
writeStream.write("Copied filesystem to temp directory\n");
|
||||||
|
|
||||||
|
if (backup.includeEncryptionKey) {
|
||||||
|
// Restoring the filesystem places this file at BASE_PATH, where
|
||||||
|
// the encryption keyring picks it up as a decryption fallback.
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, "filesystem", ENCRYPTION_KEY_BACKUP_FILE),
|
||||||
|
exportEncryptionKey(),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
writeStream.write("Included encryption key in backup\n");
|
||||||
|
}
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(
|
||||||
// Zip all .sql files since we created more than one
|
// 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 ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
|
||||||
|
|||||||
Reference in New Issue
Block a user