mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-13 09:55:29 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e867c5be1 | ||
|
|
e87a245cdc | ||
|
|
71bea42625 | ||
|
|
1cb9491013 | ||
|
|
01ac30974f |
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
Normal file
93
apps/dokploy/__test__/env/encryption.test.ts
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
decryptValue,
|
||||
encryptValue,
|
||||
exportEncryptionKey,
|
||||
isEncrypted,
|
||||
} from "@dokploy/server/lib/encryption";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("encryptValue / decryptValue", () => {
|
||||
it("round-trips a value", () => {
|
||||
const value =
|
||||
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
|
||||
const encrypted = encryptValue(value);
|
||||
|
||||
expect(encrypted).not.toBe(value);
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(encrypted).not.toContain("secret");
|
||||
expect(decryptValue(encrypted)).toBe(value);
|
||||
});
|
||||
|
||||
it("uses a random IV so equal inputs produce different ciphertexts", () => {
|
||||
const value = "KEY=value";
|
||||
expect(encryptValue(value)).not.toBe(encryptValue(value));
|
||||
});
|
||||
|
||||
it("passes legacy plaintext through on decrypt", () => {
|
||||
const plaintext = "KEY=legacy-plaintext-value";
|
||||
expect(decryptValue(plaintext)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("passes empty values through unchanged", () => {
|
||||
expect(encryptValue("")).toBe("");
|
||||
expect(decryptValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("does not double-encrypt an already encrypted value", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
expect(encryptValue(encrypted)).toBe(encrypted);
|
||||
});
|
||||
|
||||
it("throws a descriptive error on tampered ciphertext", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const loadWithEncryptionKey = async (key: string) => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", key);
|
||||
vi.resetModules();
|
||||
return await import("@dokploy/server/lib/encryption");
|
||||
};
|
||||
|
||||
it("encrypts with the dedicated key when set", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const encrypted = withKey.encryptValue("KEY=value");
|
||||
|
||||
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
|
||||
// The default (auth-secret derived) module cannot read it
|
||||
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it("still decrypts legacy values via the auth-secret fallback", async () => {
|
||||
// Encrypted before the install adopted a dedicated key
|
||||
const legacyEncrypted = encryptValue("KEY=legacy-value");
|
||||
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
|
||||
});
|
||||
|
||||
it("re-encrypts with the dedicated key on write", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const reEncrypted = withKey.encryptValue(
|
||||
withKey.decryptValue(encryptValue("KEY=migrated")),
|
||||
);
|
||||
|
||||
const other = await loadWithEncryptionKey("another-key");
|
||||
// Readable only by the dedicated key (or its own fallback), proving
|
||||
// the write used the primary key, not the legacy one
|
||||
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
|
||||
expect(() => other.decryptValue(reEncrypted)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ const Schema = z
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
includeEncryptionKey: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
serviceName: z.string().nullable(),
|
||||
@@ -223,6 +224,7 @@ export const HandleBackup = ({
|
||||
: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
includeEncryptionKey: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
@@ -262,6 +264,7 @@ export const HandleBackup = ({
|
||||
: "",
|
||||
destinationId: backup?.destinationId ?? "",
|
||||
enabled: backup?.enabled ?? true,
|
||||
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
schedule: backup?.schedule ?? "",
|
||||
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||
@@ -309,6 +312,7 @@ export const HandleBackup = ({
|
||||
prefix: data.prefix,
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
includeEncryptionKey: data.includeEncryptionKey,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount ?? null,
|
||||
databaseType: data.databaseType || databaseType,
|
||||
@@ -665,6 +669,31 @@ export const HandleBackup = ({
|
||||
</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" && (
|
||||
<>
|
||||
{form.watch("databaseType") === "postgres" && (
|
||||
|
||||
1
apps/dokploy/drizzle/0174_great_naoko.sql
Normal file
1
apps/dokploy/drizzle/0174_great_naoko.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backup" ADD COLUMN "includeEncryptionKey" boolean DEFAULT true NOT NULL;
|
||||
8551
apps/dokploy/drizzle/meta/0174_snapshot.json
Normal file
8551
apps/dokploy/drizzle/meta/0174_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1219,6 +1219,13 @@
|
||||
"when": 1783494977500,
|
||||
"tag": "0173_aspiring_annihilus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 174,
|
||||
"version": "7",
|
||||
"when": 1783674181297,
|
||||
"tag": "0174_great_naoko",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -310,7 +310,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
|
||||
@@ -51,7 +51,12 @@ import {
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
export const sourceType = pgEnum("sourceType", [
|
||||
"docker",
|
||||
"git",
|
||||
@@ -82,11 +87,11 @@ export const applications = pgTable("application", {
|
||||
.$defaultFn(() => generateAppName("app"))
|
||||
.unique(),
|
||||
description: text("description"),
|
||||
env: text("env"),
|
||||
previewEnv: text("previewEnv"),
|
||||
env: encryptedText("env"),
|
||||
previewEnv: encryptedText("previewEnv"),
|
||||
watchPaths: text("watchPaths").array(),
|
||||
previewBuildArgs: text("previewBuildArgs"),
|
||||
previewBuildSecrets: text("previewBuildSecrets"),
|
||||
previewBuildArgs: encryptedText("previewBuildArgs"),
|
||||
previewBuildSecrets: encryptedText("previewBuildSecrets"),
|
||||
previewLabels: text("previewLabels").array(),
|
||||
previewWildcard: text("previewWildcard"),
|
||||
previewPort: integer("previewPort").default(3000),
|
||||
@@ -105,8 +110,8 @@ export const applications = pgTable("application", {
|
||||
"previewRequireCollaboratorPermissions",
|
||||
).default(true),
|
||||
rollbackActive: boolean("rollbackActive").default(false),
|
||||
buildArgs: text("buildArgs"),
|
||||
buildSecrets: text("buildSecrets"),
|
||||
buildArgs: encryptedText("buildArgs"),
|
||||
buildSecrets: encryptedText("buildSecrets"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
|
||||
@@ -50,6 +50,7 @@ export const backups = pgTable("backup", {
|
||||
.notNull()
|
||||
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
||||
keepLatestCount: integer("keepLatestCount"),
|
||||
includeEncryptionKey: boolean("includeEncryptionKey").notNull().default(true),
|
||||
backupType: backupType("backupType").notNull().default("database"),
|
||||
databaseType: databaseType("databaseType").notNull(),
|
||||
composeId: text("composeId").references(
|
||||
@@ -160,6 +161,7 @@ const createSchema = createInsertSchema(backups, {
|
||||
mongoId: z.string().optional(),
|
||||
libsqlId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
includeEncryptionKey: z.boolean().optional(),
|
||||
metadata: z.any().optional(),
|
||||
});
|
||||
|
||||
@@ -180,6 +182,7 @@ export const apiCreateBackup = createSchema.pick({
|
||||
backupType: true,
|
||||
composeId: true,
|
||||
serviceName: true,
|
||||
includeEncryptionKey: true,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
@@ -206,7 +209,10 @@ export const apiUpdateBackup = createSchema
|
||||
metadata: true,
|
||||
databaseType: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
includeEncryptionKey: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiRestoreBackup = z.object({
|
||||
databaseId: z.string(),
|
||||
|
||||
@@ -17,7 +17,12 @@ import { schedules } from "./schedule";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||
"git",
|
||||
"github",
|
||||
@@ -39,7 +44,7 @@ export const compose = pgTable("compose", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("compose")),
|
||||
description: text("description"),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
composeFile: text("composeFile").notNull().default(""),
|
||||
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
|
||||
sourceType: sourceTypeCompose("sourceType").notNull().default("github"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { projects } from "./project";
|
||||
import { redis } from "./redis";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const environments = pgTable("environment", {
|
||||
environmentId: text("environmentId")
|
||||
@@ -22,7 +23,7 @@ export const environments = pgTable("environment", {
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
env: text("env").notNull().default(""),
|
||||
env: encryptedText("env").notNull().default(""),
|
||||
projectId: text("projectId")
|
||||
.notNull()
|
||||
.references(() => projects.projectId, { onDelete: "cascade" }),
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -58,7 +59,7 @@ export const libsql = pgTable("libsql", {
|
||||
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
// RESOURCES
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -54,7 +55,7 @@ export const mariadb = pgTable("mariadb", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
// RESOURCES
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -59,7 +60,7 @@ export const mongo = pgTable("mongo", {
|
||||
dockerImage: text("dockerImage").notNull().default("mongo:8"),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -54,7 +55,7 @@ export const mysql = pgTable("mysql", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -53,7 +54,7 @@ export const postgres = pgTable("postgres", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
externalPort: integer("externalPort"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { environments } from "./environment";
|
||||
import { projectTags } from "./tag";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const projects = pgTable("project", {
|
||||
projectId: text("projectId")
|
||||
@@ -21,7 +22,7 @@ export const projects = pgTable("project", {
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
env: text("env").notNull().default(""),
|
||||
env: encryptedText("env").notNull().default(""),
|
||||
});
|
||||
|
||||
export const projectRelations = relations(projects, ({ many, one }) => ({
|
||||
@@ -37,6 +38,7 @@ const createSchema = createInsertSchema(projects, {
|
||||
projectId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
env: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateProject = createSchema.pick({
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
export const redis = pgTable("redis", {
|
||||
redisId: text("redisId")
|
||||
@@ -44,7 +49,7 @@ export const redis = pgTable("redis", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import { decryptValue, encryptValue } from "@dokploy/server/lib/encryption";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { customType } from "drizzle-orm/pg-core";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
/**
|
||||
* Text column encrypted at rest (AES-256-GCM, key derived from
|
||||
* BETTER_AUTH_SECRET). Legacy plaintext values are passed through on read
|
||||
* and get encrypted the next time they are written.
|
||||
*/
|
||||
export const encryptedText = customType<{ data: string; driverData: string }>({
|
||||
dataType() {
|
||||
return "text";
|
||||
},
|
||||
toDriver(value) {
|
||||
return encryptValue(value);
|
||||
},
|
||||
fromDriver(value) {
|
||||
try {
|
||||
return decryptValue(value);
|
||||
} catch {
|
||||
// Fail open so a key mismatch (e.g. restoring a backup under a
|
||||
// different BETTER_AUTH_SECRET) degrades to showing ciphertext
|
||||
// instead of breaking every query that touches the row.
|
||||
console.error(
|
||||
"Failed to decrypt an encrypted column; returning the raw value. This usually means BETTER_AUTH_SECRET changed after the value was encrypted.",
|
||||
);
|
||||
return value;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz123456789";
|
||||
|
||||
const customNanoid = customAlphabet(alphabet, 6);
|
||||
|
||||
15
packages/server/src/lib/encryption-secret.ts
Normal file
15
packages/server/src/lib/encryption-secret.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readSecret } from "../db/constants";
|
||||
|
||||
const { ENCRYPTION_KEY, ENCRYPTION_KEY_FILE } = process.env;
|
||||
|
||||
function resolveEncryptionSecret(): string | undefined {
|
||||
if (ENCRYPTION_KEY) {
|
||||
return ENCRYPTION_KEY;
|
||||
}
|
||||
if (ENCRYPTION_KEY_FILE) {
|
||||
return readSecret(ENCRYPTION_KEY_FILE);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const encryptionSecret = resolveEncryptionSecret();
|
||||
99
packages/server/src/lib/encryption.ts
Normal file
99
packages/server/src/lib/encryption.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} 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 { encryptionSecret } from "./encryption-secret";
|
||||
|
||||
export const ENCRYPTION_KEY_BACKUP_FILE = "encryption.key";
|
||||
|
||||
const ENCRYPTION_PREFIX = "enc:v1:";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
const deriveKey = (secret: string) =>
|
||||
createHmac("sha256", secret).update("dokploy:db-encryption:v1").digest();
|
||||
|
||||
const primaryKey = deriveKey(encryptionSecret ?? betterAuthSecret);
|
||||
|
||||
// Installs that adopt a dedicated ENCRYPTION_KEY still hold values encrypted
|
||||
// with the auth-secret-derived key; keep it as a decrypt fallback so they
|
||||
// lazily re-encrypt on the next write instead of becoming unreadable.
|
||||
const decryptionKeys = encryptionSecret
|
||||
? [primaryKey, deriveKey(betterAuthSecret)]
|
||||
: [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) =>
|
||||
value.startsWith(ENCRYPTION_PREFIX);
|
||||
|
||||
export const encryptValue = (value: string): string => {
|
||||
if (!value || isEncrypted(value)) {
|
||||
return value;
|
||||
}
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv("aes-256-gcm", primaryKey, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return `${ENCRYPTION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64")}`;
|
||||
};
|
||||
|
||||
export const decryptValue = (value: string): string => {
|
||||
if (!value || !isEncrypted(value)) {
|
||||
return value;
|
||||
}
|
||||
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
||||
const iv = payload.subarray(0, IV_LENGTH);
|
||||
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const restored = loadRestoredKey();
|
||||
const keys = restored ? [...decryptionKeys, restored] : decryptionKeys;
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
} catch {
|
||||
// GCM auth failed for this key; try the next one.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"Failed to decrypt a stored secret. This usually means ENCRYPTION_KEY or BETTER_AUTH_SECRET changed after the value was encrypted.",
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
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 { join } from "node:path";
|
||||
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 {
|
||||
createDeploymentBackup,
|
||||
@@ -78,11 +82,22 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
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");
|
||||
|
||||
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(
|
||||
// Zip all .sql files since we created more than one
|
||||
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
|
||||
|
||||
Reference in New Issue
Block a user