diff --git a/app/server/modules/backends/backend.ts b/app/server/modules/backends/backend.ts index 25f51411..8ffa0880 100644 --- a/app/server/modules/backends/backend.ts +++ b/app/server/modules/backends/backend.ts @@ -1,5 +1,6 @@ -import type { BackendStatus } from "~/schemas/volumes"; +import type { BackendConfig, BackendStatus } from "~/schemas/volumes"; import type { Volume } from "../../db/schema"; +import { cryptoUtils } from "../../utils/crypto"; import { getVolumePath } from "../volumes/helpers"; import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeNfsBackend } from "./nfs/nfs-backend"; @@ -19,27 +20,33 @@ export type VolumeBackend = { checkHealth: () => Promise; }; -export const createVolumeBackend = (volume: Volume): VolumeBackend => { - const path = getVolumePath(volume); - - switch (volume.config.backend) { - case "nfs": { - return makeNfsBackend(volume.config, path); - } - case "smb": { - return makeSmbBackend(volume.config, path); - } - case "directory": { - return makeDirectoryBackend(volume.config, path); - } - case "webdav": { - return makeWebdavBackend(volume.config, path); - } - case "rclone": { - return makeRcloneBackend(volume.config, path); - } - case "sftp": { - return makeSftpBackend(volume.config, path); - } +const getBackendFactory = (backendType: BackendConfig["backend"]) => { + switch (backendType) { + case "nfs": + return makeNfsBackend; + case "smb": + return makeSmbBackend; + case "directory": + return makeDirectoryBackend; + case "webdav": + return makeWebdavBackend; + case "rclone": + return makeRcloneBackend; + case "sftp": + return makeSftpBackend; } }; + +export const createVolumeBackend = (volume: Volume): VolumeBackend => { + const path = getVolumePath(volume); + const makeBackend = getBackendFactory(volume.config.backend); + + return { + mount: async () => { + const resolvedConfig = await cryptoUtils.resolveSecretsDeep(volume.config); + return makeBackend(resolvedConfig, path).mount(); + }, + unmount: () => makeBackend(volume.config, path).unmount(), + checkHealth: () => makeBackend(volume.config, path).checkHealth(), + }; +}; diff --git a/app/server/modules/backends/sftp/sftp-backend.ts b/app/server/modules/backends/sftp/sftp-backend.ts index 82e0bc20..481d3e37 100644 --- a/app/server/modules/backends/sftp/sftp-backend.ts +++ b/app/server/modules/backends/sftp/sftp-backend.ts @@ -3,7 +3,6 @@ import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; import { OPERATION_TIMEOUT } from "../../../core/constants"; -import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -79,8 +78,7 @@ const mount = async (config: BackendConfig, mountPath: string) => { const keyPath = getPrivateKeyPath(mountPath); if (config.privateKey) { - const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); - let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); + let normalizedKey = config.privateKey.replace(/\r\n/g, "\n"); if (!normalizedKey.endsWith("\n")) { normalizedKey += "\n"; } @@ -95,10 +93,9 @@ const mount = async (config: BackendConfig, mountPath: string) => { let result: $.ShellOutput; if (config.password) { - const password = await cryptoUtils.resolveSecret(config.password); args.push("-o", "password_stdin"); logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`); - result = await $`echo ${password} | sshfs ${args}`.nothrow(); + result = await $`echo ${config.password} | sshfs ${args}`.nothrow(); } else { logger.info(`Executing sshfs: sshfs ${args.join(" ")}`); result = await $`sshfs ${args}`.nothrow(); diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts index d8811605..fd75c8cf 100644 --- a/app/server/modules/backends/smb/smb-backend.ts +++ b/app/server/modules/backends/smb/smb-backend.ts @@ -1,7 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import { OPERATION_TIMEOUT } from "../../../core/constants"; -import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -36,13 +35,11 @@ const mount = async (config: BackendConfig, path: string) => { const run = async () => { await fs.mkdir(path, { recursive: true }); - const password = await cryptoUtils.resolveSecret(config.password); - const source = `//${config.server}/${config.share}`; const { uid, gid } = os.userInfo(); const options = [ `user=${config.username}`, - `pass=${password}`, + `pass=${config.password}`, `vers=${config.vers}`, `port=${config.port}`, `uid=${uid}`, diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index f971ad90..76eee4cb 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -1,7 +1,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import { OPERATION_TIMEOUT } from "../../../core/constants"; -import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; import { logger } from "../../../utils/logger"; import { getMountForPath } from "../../../utils/mountinfo"; @@ -49,9 +48,8 @@ const mount = async (config: BackendConfig, path: string) => { : [`uid=${uid}`, `gid=${gid}`, "file_mode=0664", "dir_mode=0775"]; if (config.username && config.password) { - const password = await cryptoUtils.resolveSecret(config.password); const secretsFile = "/etc/davfs2/secrets"; - const secretsContent = `${source} ${config.username} ${password}\n`; + const secretsContent = `${source} ${config.username} ${config.password}\n`; await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); } diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 4992ba3e..524887e9 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -83,55 +83,6 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise { - switch (config.type) { - case "email": - return { - ...config, - password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined, - }; - case "slack": - return { - ...config, - webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl), - }; - case "discord": - return { - ...config, - webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl), - }; - case "gotify": - return { - ...config, - token: await cryptoUtils.resolveSecret(config.token), - }; - case "ntfy": - return { - ...config, - password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined, - }; - case "pushover": - return { - ...config, - apiToken: await cryptoUtils.resolveSecret(config.apiToken), - }; - case "telegram": - return { - ...config, - botToken: await cryptoUtils.resolveSecret(config.botToken), - }; - case "generic": - return config; - case "custom": - return { - ...config, - shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl), - }; - default: - return config; - } -} - const createDestination = async (name: string, config: NotificationConfig) => { const slug = slugify(name, { lower: true, strict: true }); @@ -225,9 +176,9 @@ const testDestination = async (id: number) => { throw new ConflictError("Cannot test disabled notification destination"); } - const decryptedConfig = await decryptSensitiveFields(destination.config); + const resolvedConfig = await cryptoUtils.resolveSecretsDeep(destination.config); - const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); + const shoutrrrUrl = buildShoutrrrUrl(resolvedConfig); console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl); @@ -327,8 +278,8 @@ const sendBackupNotification = async ( for (const assignment of relevantAssignments) { try { - const decryptedConfig = await decryptSensitiveFields(assignment.destination.config); - const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); + const resolvedConfig = await cryptoUtils.resolveSecretsDeep(assignment.destination.config); + const shoutrrrUrl = buildShoutrrrUrl(resolvedConfig); const result = await sendNotification({ shoutrrrUrl, diff --git a/app/server/utils/backend-compatibility.ts b/app/server/utils/backend-compatibility.ts index b9b61f63..6f8414bf 100644 --- a/app/server/utils/backend-compatibility.ts +++ b/app/server/utils/backend-compatibility.ts @@ -35,52 +35,54 @@ export const hasCompatibleCredentials = async ( return true; } + // Resolve secrets in both configs for comparison + const resolvedConfig1 = await cryptoUtils.resolveSecretsDeep(config1); + const resolvedConfig2 = await cryptoUtils.resolveSecretsDeep(config2); + switch (group1) { case "s3": { if ( - (config1.backend === "s3" || config1.backend === "r2") && - (config2.backend === "s3" || config2.backend === "r2") + (resolvedConfig1.backend === "s3" || resolvedConfig1.backend === "r2") && + (resolvedConfig2.backend === "s3" || resolvedConfig2.backend === "r2") ) { - const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId); - const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey); - - const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId); - const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey); - - return accessKey1 === accessKey2 && secretKey1 === secretKey2; + return ( + resolvedConfig1.accessKeyId === resolvedConfig2.accessKeyId && + resolvedConfig1.secretAccessKey === resolvedConfig2.secretAccessKey + ); } return false; } case "gcs": { - if (config1.backend === "gcs" && config2.backend === "gcs") { - const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson); - const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson); - - return credentials1 === credentials2 && config1.projectId === config2.projectId; + if (resolvedConfig1.backend === "gcs" && resolvedConfig2.backend === "gcs") { + return ( + resolvedConfig1.credentialsJson === resolvedConfig2.credentialsJson && + resolvedConfig1.projectId === resolvedConfig2.projectId + ); } return false; } case "azure": { - if (config1.backend === "azure" && config2.backend === "azure") { - const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey); - const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey); - - return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey; + if (resolvedConfig1.backend === "azure" && resolvedConfig2.backend === "azure") { + return ( + resolvedConfig1.accountName === resolvedConfig2.accountName && + resolvedConfig1.accountKey === resolvedConfig2.accountKey + ); } return false; } case "rest": { - if (config1.backend === "rest" && config2.backend === "rest") { - if (!config1.username && !config2.username && !config1.password && !config2.password) { + if (resolvedConfig1.backend === "rest" && resolvedConfig2.backend === "rest") { + if ( + !resolvedConfig1.username && + !resolvedConfig2.username && + !resolvedConfig1.password && + !resolvedConfig2.password + ) { return true; } - - const config1Username = await cryptoUtils.resolveSecret(config1.username || ""); - const config1Password = await cryptoUtils.resolveSecret(config1.password || ""); - const config2Username = await cryptoUtils.resolveSecret(config2.username || ""); - const config2Password = await cryptoUtils.resolveSecret(config2.password || ""); - - return config1Username === config2Username && config1Password === config2Password; + return ( + resolvedConfig1.username === resolvedConfig2.username && resolvedConfig1.password === resolvedConfig2.password + ); } return false; } diff --git a/app/server/utils/crypto.ts b/app/server/utils/crypto.ts index 34c3af98..b1df0f84 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -183,7 +183,53 @@ const sealSecret = async (value: string): Promise => { return encrypt(value); }; +/** + * Helper to check if a value is a plain object (not null, array, Date, etc.) + */ +const isPlainObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value) && value.constructor === Object; +}; + +/** + * Recursively resolves all secret references in an object or array. + * Handles nested objects, arrays, and circular references. + */ +const resolveSecretsDeep = async (input: T): Promise => { + const seen = new WeakMap(); + + const resolve = async (value: unknown): Promise => { + if (typeof value === "string") { + return resolveSecret(value); + } + + if (Array.isArray(value)) { + if (seen.has(value)) return seen.get(value); + const result: unknown[] = []; + seen.set(value, result); + for (const item of value) { + result.push(await resolve(item)); + } + return result; + } + + if (isPlainObject(value)) { + if (seen.has(value)) return seen.get(value); + const result: Record = {}; + seen.set(value, result); + for (const [key, val] of Object.entries(value)) { + result[key] = await resolve(val); + } + return result; + } + + return value; + }; + + return resolve(input) as Promise; +}; + export const cryptoUtils = { resolveSecret, sealSecret, + resolveSecretsDeep, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 17d05310..3d5fdfdc 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -112,10 +112,9 @@ export const buildEnv = async (config: RepositoryConfig) => { }; if (config.isExistingRepository && config.customPassword) { - const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); - await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); + await fs.writeFile(passwordFilePath, config.customPassword, { mode: 0o600 }); env.RESTIC_PASSWORD_FILE = passwordFilePath; } else { env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE; @@ -123,26 +122,25 @@ export const buildEnv = async (config: RepositoryConfig) => { switch (config.backend) { case "s3": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = config.accessKeyId; + env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey; break; case "r2": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = config.accessKeyId; + env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey; env.AWS_REGION = "auto"; env.AWS_S3_FORCE_PATH_STYLE = "true"; break; case "gcs": { - const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); - await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); + await fs.writeFile(credentialsPath, config.credentialsJson, { mode: 0o600 }); env.GOOGLE_PROJECT_ID = config.projectId; env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; break; } case "azure": { env.AZURE_ACCOUNT_NAME = config.accountName; - env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey); + env.AZURE_ACCOUNT_KEY = config.accountKey; if (config.endpointSuffix) { env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; } @@ -150,18 +148,17 @@ export const buildEnv = async (config: RepositoryConfig) => { } case "rest": { if (config.username) { - env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); + env.RESTIC_REST_USERNAME = config.username; } if (config.password) { - env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password); + env.RESTIC_REST_PASSWORD = config.password; } break; } case "sftp": { - const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); - let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); + let normalizedKey = config.privateKey.replace(/\r\n/g, "\n"); if (!normalizedKey.endsWith("\n")) { normalizedKey += "\n"; } @@ -206,9 +203,8 @@ export const buildEnv = async (config: RepositoryConfig) => { } if (config.cacert) { - const decryptedCert = await cryptoUtils.resolveSecret(config.cacert); const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); - await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); + await fs.writeFile(certPath, config.cacert, { mode: 0o600 }); env.RESTIC_CACERT = certPath; } @@ -219,15 +215,24 @@ export const buildEnv = async (config: RepositoryConfig) => { return env; }; +/** + * Resolves all secret placeholders in config and builds repo URL + env. + * Call this at the start of each restic operation. + */ +const resolveAndBuild = async (config: RepositoryConfig) => { + const resolved = await cryptoUtils.resolveSecretsDeep(config); + const repoUrl = buildRepoUrl(resolved); + const env = await buildEnv(resolved); + return { resolved, repoUrl, env }; +}; + const init = async (config: RepositoryConfig) => { await ensurePassfile(); - const repoUrl = buildRepoUrl(config); + const { repoUrl, env } = await resolveAndBuild(config); logger.info(`Initializing restic repository at ${repoUrl}...`); - const env = await buildEnv(config); - const args = ["init", "--repo", repoUrl]; addCommonArgs(args, env); @@ -270,8 +275,7 @@ const backup = async ( onProgress?: (progress: BackupProgress) => void; }, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"]; @@ -422,8 +426,7 @@ const restore = async ( overwrite?: OverwriteMode; }, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; @@ -505,8 +508,7 @@ const restore = async ( const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => { const { tags } = options; - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args = ["--repo", repoUrl, "snapshots"]; @@ -537,8 +539,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } }; const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; @@ -579,8 +580,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: }; const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for deletion."); @@ -609,8 +609,7 @@ const tagSnapshots = async ( snapshotIds: string[], tags: { add?: string[]; remove?: string[]; set?: string[] }, ) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); if (snapshotIds.length === 0) { throw new Error("No snapshot IDs provided for tagging."); @@ -677,8 +676,7 @@ const lsSnapshotInfoSchema = type({ }); const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; @@ -733,8 +731,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) = }; const unlock = async (config: RepositoryConfig) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args = ["unlock", "--repo", repoUrl, "--remove-all"]; addCommonArgs(args, env); @@ -752,8 +749,7 @@ const unlock = async (config: RepositoryConfig) => { }; const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args: string[] = ["--repo", repoUrl, "check"]; @@ -790,8 +786,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean }) }; const repairIndex = async (config: RepositoryConfig) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config); + const { repoUrl, env } = await resolveAndBuild(config); const args = ["repair", "index", "--repo", repoUrl]; addCommonArgs(args, env); @@ -822,11 +817,8 @@ const copy = async ( snapshotId?: string; }, ) => { - const sourceRepoUrl = buildRepoUrl(sourceConfig); - const destRepoUrl = buildRepoUrl(destConfig); - - const sourceEnv = await buildEnv(sourceConfig); - const destEnv = await buildEnv(destConfig); + const { resolved: resolvedSource, repoUrl: sourceRepoUrl, env: sourceEnv } = await resolveAndBuild(sourceConfig); + const { repoUrl: destRepoUrl, env: destEnv } = await resolveAndBuild(destConfig); const env: Record = { ...sourceEnv, @@ -848,7 +840,7 @@ const copy = async ( addCommonArgs(args, env); - if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { + if (resolvedSource.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`); }