Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
49 changes: 28 additions & 21 deletions app/server/modules/backends/backend.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,27 +20,33 @@ export type VolumeBackend = {
checkHealth: () => Promise<OperationResult>;
};

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(),
};
};
7 changes: 2 additions & 5 deletions app/server/modules/backends/sftp/sftp-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
}
Expand All @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions app/server/modules/backends/smb/smb-backend.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`,
Expand Down
4 changes: 1 addition & 3 deletions app/server/modules/backends/webdav/webdav-backend.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}

Expand Down
57 changes: 4 additions & 53 deletions app/server/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,55 +83,6 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
}
}

async function decryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
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 });

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
58 changes: 30 additions & 28 deletions app/server/utils/backend-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
45 changes: 45 additions & 0 deletions app/server/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,50 @@ const sealSecret = async (value: string): Promise<string> => {
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<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value) && value.constructor === Object;
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isPlainObject check uses value.constructor === Object, which may fail for objects created with Object.create(null) or objects from different realms/contexts. Consider using a more robust check like Object.prototype.toString.call(value) === '[object Object]' or simply checking if the constructor exists before comparing it.

Suggested change
return typeof value === "object" && value !== null && !Array.isArray(value) && value.constructor === Object;
if (typeof value !== "object" || value === null) return false;
if (Object.prototype.toString.call(value) !== "[object Object]") return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;

Copilot uses AI. Check for mistakes.
};

/**
* Recursively resolves all secret references in an object or array.
* Handles nested objects, arrays, and circular references.
*/
const resolveSecretsDeep = async <T>(input: T): Promise<T> => {
const seen = new WeakMap<object, unknown>();

const resolve = async (value: unknown): Promise<unknown> => {
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<string, unknown> = {};
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<T>;
};
async function deriveSecret(label: string) {
const masterSecret = await Bun.file(RESTIC_PASS_FILE).text();

Expand All @@ -197,5 +241,6 @@ async function deriveSecret(label: string) {
export const cryptoUtils = {
resolveSecret,
sealSecret,
resolveSecretsDeep,
deriveSecret,
};
Loading
Loading