diff --git a/README.md b/README.md index c48914ce..a76b5c9c 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,12 @@ Zerobyte can be customized using environment variables. Below are the available ### Secret References -For enhanced security, Zerobyte supports dynamic secret resolution for sensitive fields (like passwords, access keys, etc.) in volume and repository configurations. Instead of storing the encrypted secret in the database, you can use one of the following prefixes: +Zerobyte supports dynamic secret resolution for **any configuration field** in volumes, repositories, and notifications. Instead of storing secrets directly, you can use: -- `env://VAR_NAME`: Reads the secret from the environment variable `VAR_NAME`. -- `file://SECRET_NAME`: Reads the secret from `/run/secrets/SECRET_NAME` (standard Docker Secrets path). +- `env://VAR_NAME`: Reads the value from environment variable `VAR_NAME` +- `file://SECRET_NAME`: Reads from `/run/secrets/SECRET_NAME` (Docker Secrets) + +This works for passwords, access keys, private keys, webhook URLs, or any other field. **Example:** When configuring an S3 repository, you can set the Secret Access Key to `env://S3_SECRET_KEY` and then provide that variable in your `docker-compose.yml`. 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; }; +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); - 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); - } - } + 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 39d97a29..94e2f9e8 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 864da6ca..775a758d 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 e927e9ac..2f8a22e5 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 fb86a7e3..89e991ca 100644 --- a/app/server/utils/crypto.ts +++ b/app/server/utils/crypto.ts @@ -186,6 +186,50 @@ 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; +}; async function deriveSecret(label: string) { const masterSecret = await Bun.file(RESTIC_PASS_FILE).text(); @@ -197,5 +241,6 @@ async function deriveSecret(label: string) { export const cryptoUtils = { resolveSecret, sealSecret, + resolveSecretsDeep, deriveSecret, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 1e2051ab..86699b78 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}`); } diff --git a/examples/secrets-placeholders/README.md b/examples/secrets-placeholders/README.md index dac55772..46fe604f 100644 --- a/examples/secrets-placeholders/README.md +++ b/examples/secrets-placeholders/README.md @@ -1,6 +1,6 @@ # Secret placeholders (env:// and file://) + Docker secrets -Zerobyte supports **secret placeholders** in many configuration fields (repositories, volumes, notifications). +Zerobyte supports **secret placeholders** in **any configuration field** (repositories, volumes, notifications). Instead of storing raw secrets in the database, you can store a reference that gets resolved at runtime. Supported formats: