feat: add deep secret resolution for all config fields
- Add resolveSecretsDeep() to recursively resolve secret placeholders (env://, file://, encv1:) in any config field - Refactor restic operations to resolve secrets once via resolveAndBuild() - Refactor volume backend dispatcher to resolve secrets at mount time - Remove redundant per-field resolveSecret() calls from SMB, WebDAV, SFTP backends - Simplify notifications service by replacing switch-based decryption - Update backend-compatibility to use deep resolution for credential comparison This allows users to use secret references in any configuration field, not just the designated sensitive fields.
This commit is contained in:
parent
21040d7d4c
commit
92ba5f9706
8 changed files with 150 additions and 160 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import type { BackendStatus } from "~/schemas/volumes";
|
import type { BackendConfig, BackendStatus } from "~/schemas/volumes";
|
||||||
import type { Volume } from "../../db/schema";
|
import type { Volume } from "../../db/schema";
|
||||||
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { makeDirectoryBackend } from "./directory/directory-backend";
|
import { makeDirectoryBackend } from "./directory/directory-backend";
|
||||||
import { makeNfsBackend } from "./nfs/nfs-backend";
|
import { makeNfsBackend } from "./nfs/nfs-backend";
|
||||||
|
|
@ -19,27 +20,33 @@ export type VolumeBackend = {
|
||||||
checkHealth: () => Promise<OperationResult>;
|
checkHealth: () => Promise<OperationResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
const getBackendFactory = (backendType: BackendConfig["backend"]) => {
|
||||||
const path = getVolumePath(volume);
|
switch (backendType) {
|
||||||
|
case "nfs":
|
||||||
switch (volume.config.backend) {
|
return makeNfsBackend;
|
||||||
case "nfs": {
|
case "smb":
|
||||||
return makeNfsBackend(volume.config, path);
|
return makeSmbBackend;
|
||||||
}
|
case "directory":
|
||||||
case "smb": {
|
return makeDirectoryBackend;
|
||||||
return makeSmbBackend(volume.config, path);
|
case "webdav":
|
||||||
}
|
return makeWebdavBackend;
|
||||||
case "directory": {
|
case "rclone":
|
||||||
return makeDirectoryBackend(volume.config, path);
|
return makeRcloneBackend;
|
||||||
}
|
case "sftp":
|
||||||
case "webdav": {
|
return makeSftpBackend;
|
||||||
return makeWebdavBackend(volume.config, path);
|
|
||||||
}
|
|
||||||
case "rclone": {
|
|
||||||
return makeRcloneBackend(volume.config, path);
|
|
||||||
}
|
|
||||||
case "sftp": {
|
|
||||||
return makeSftpBackend(volume.config, path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { cryptoUtils } from "../../../utils/crypto";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
|
|
@ -79,8 +78,7 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
||||||
|
|
||||||
const keyPath = getPrivateKeyPath(mountPath);
|
const keyPath = getPrivateKeyPath(mountPath);
|
||||||
if (config.privateKey) {
|
if (config.privateKey) {
|
||||||
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
|
let normalizedKey = config.privateKey.replace(/\r\n/g, "\n");
|
||||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
|
||||||
if (!normalizedKey.endsWith("\n")) {
|
if (!normalizedKey.endsWith("\n")) {
|
||||||
normalizedKey += "\n";
|
normalizedKey += "\n";
|
||||||
}
|
}
|
||||||
|
|
@ -95,10 +93,9 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
||||||
|
|
||||||
let result: $.ShellOutput;
|
let result: $.ShellOutput;
|
||||||
if (config.password) {
|
if (config.password) {
|
||||||
const password = await cryptoUtils.resolveSecret(config.password);
|
|
||||||
args.push("-o", "password_stdin");
|
args.push("-o", "password_stdin");
|
||||||
logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`);
|
logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`);
|
||||||
result = await $`echo ${password} | sshfs ${args}`.nothrow();
|
result = await $`echo ${config.password} | sshfs ${args}`.nothrow();
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
||||||
result = await $`sshfs ${args}`.nothrow();
|
result = await $`sshfs ${args}`.nothrow();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { cryptoUtils } from "../../../utils/crypto";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
|
|
@ -36,13 +35,11 @@ const mount = async (config: BackendConfig, path: string) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await fs.mkdir(path, { recursive: true });
|
await fs.mkdir(path, { recursive: true });
|
||||||
|
|
||||||
const password = await cryptoUtils.resolveSecret(config.password);
|
|
||||||
|
|
||||||
const source = `//${config.server}/${config.share}`;
|
const source = `//${config.server}/${config.share}`;
|
||||||
const { uid, gid } = os.userInfo();
|
const { uid, gid } = os.userInfo();
|
||||||
const options = [
|
const options = [
|
||||||
`user=${config.username}`,
|
`user=${config.username}`,
|
||||||
`pass=${password}`,
|
`pass=${config.password}`,
|
||||||
`vers=${config.vers}`,
|
`vers=${config.vers}`,
|
||||||
`port=${config.port}`,
|
`port=${config.port}`,
|
||||||
`uid=${uid}`,
|
`uid=${uid}`,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { cryptoUtils } from "../../../utils/crypto";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { logger } from "../../../utils/logger";
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
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"];
|
: [`uid=${uid}`, `gid=${gid}`, "file_mode=0664", "dir_mode=0775"];
|
||||||
|
|
||||||
if (config.username && config.password) {
|
if (config.username && config.password) {
|
||||||
const password = await cryptoUtils.resolveSecret(config.password);
|
|
||||||
const secretsFile = "/etc/davfs2/secrets";
|
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 });
|
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 createDestination = async (name: string, config: NotificationConfig) => {
|
||||||
const slug = slugify(name, { lower: true, strict: true });
|
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");
|
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);
|
console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl);
|
||||||
|
|
||||||
|
|
@ -327,8 +278,8 @@ const sendBackupNotification = async (
|
||||||
|
|
||||||
for (const assignment of relevantAssignments) {
|
for (const assignment of relevantAssignments) {
|
||||||
try {
|
try {
|
||||||
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config);
|
const resolvedConfig = await cryptoUtils.resolveSecretsDeep(assignment.destination.config);
|
||||||
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
const shoutrrrUrl = buildShoutrrrUrl(resolvedConfig);
|
||||||
|
|
||||||
const result = await sendNotification({
|
const result = await sendNotification({
|
||||||
shoutrrrUrl,
|
shoutrrrUrl,
|
||||||
|
|
|
||||||
|
|
@ -35,52 +35,54 @@ export const hasCompatibleCredentials = async (
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve secrets in both configs for comparison
|
||||||
|
const resolvedConfig1 = await cryptoUtils.resolveSecretsDeep(config1);
|
||||||
|
const resolvedConfig2 = await cryptoUtils.resolveSecretsDeep(config2);
|
||||||
|
|
||||||
switch (group1) {
|
switch (group1) {
|
||||||
case "s3": {
|
case "s3": {
|
||||||
if (
|
if (
|
||||||
(config1.backend === "s3" || config1.backend === "r2") &&
|
(resolvedConfig1.backend === "s3" || resolvedConfig1.backend === "r2") &&
|
||||||
(config2.backend === "s3" || config2.backend === "r2")
|
(resolvedConfig2.backend === "s3" || resolvedConfig2.backend === "r2")
|
||||||
) {
|
) {
|
||||||
const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId);
|
return (
|
||||||
const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey);
|
resolvedConfig1.accessKeyId === resolvedConfig2.accessKeyId &&
|
||||||
|
resolvedConfig1.secretAccessKey === resolvedConfig2.secretAccessKey
|
||||||
const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId);
|
);
|
||||||
const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey);
|
|
||||||
|
|
||||||
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
case "gcs": {
|
case "gcs": {
|
||||||
if (config1.backend === "gcs" && config2.backend === "gcs") {
|
if (resolvedConfig1.backend === "gcs" && resolvedConfig2.backend === "gcs") {
|
||||||
const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson);
|
return (
|
||||||
const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson);
|
resolvedConfig1.credentialsJson === resolvedConfig2.credentialsJson &&
|
||||||
|
resolvedConfig1.projectId === resolvedConfig2.projectId
|
||||||
return credentials1 === credentials2 && config1.projectId === config2.projectId;
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
case "azure": {
|
case "azure": {
|
||||||
if (config1.backend === "azure" && config2.backend === "azure") {
|
if (resolvedConfig1.backend === "azure" && resolvedConfig2.backend === "azure") {
|
||||||
const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey);
|
return (
|
||||||
const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey);
|
resolvedConfig1.accountName === resolvedConfig2.accountName &&
|
||||||
|
resolvedConfig1.accountKey === resolvedConfig2.accountKey
|
||||||
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
case "rest": {
|
case "rest": {
|
||||||
if (config1.backend === "rest" && config2.backend === "rest") {
|
if (resolvedConfig1.backend === "rest" && resolvedConfig2.backend === "rest") {
|
||||||
if (!config1.username && !config2.username && !config1.password && !config2.password) {
|
if (
|
||||||
|
!resolvedConfig1.username &&
|
||||||
|
!resolvedConfig2.username &&
|
||||||
|
!resolvedConfig1.password &&
|
||||||
|
!resolvedConfig2.password
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
const config1Username = await cryptoUtils.resolveSecret(config1.username || "");
|
resolvedConfig1.username === resolvedConfig2.username && resolvedConfig1.password === resolvedConfig2.password
|
||||||
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 false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,53 @@ const sealSecret = async (value: string): Promise<string> => {
|
||||||
return encrypt(value);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
};
|
||||||
|
|
||||||
export const cryptoUtils = {
|
export const cryptoUtils = {
|
||||||
resolveSecret,
|
resolveSecret,
|
||||||
sealSecret,
|
sealSecret,
|
||||||
|
resolveSecretsDeep,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,9 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.isExistingRepository && config.customPassword) {
|
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`);
|
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;
|
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||||
} else {
|
} else {
|
||||||
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
||||||
|
|
@ -123,26 +122,25 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "s3":
|
case "s3":
|
||||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
|
env.AWS_ACCESS_KEY_ID = config.accessKeyId;
|
||||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
|
env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey;
|
||||||
break;
|
break;
|
||||||
case "r2":
|
case "r2":
|
||||||
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId);
|
env.AWS_ACCESS_KEY_ID = config.accessKeyId;
|
||||||
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey);
|
env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey;
|
||||||
env.AWS_REGION = "auto";
|
env.AWS_REGION = "auto";
|
||||||
env.AWS_S3_FORCE_PATH_STYLE = "true";
|
env.AWS_S3_FORCE_PATH_STYLE = "true";
|
||||||
break;
|
break;
|
||||||
case "gcs": {
|
case "gcs": {
|
||||||
const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
|
|
||||||
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
|
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_PROJECT_ID = config.projectId;
|
||||||
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "azure": {
|
case "azure": {
|
||||||
env.AZURE_ACCOUNT_NAME = config.accountName;
|
env.AZURE_ACCOUNT_NAME = config.accountName;
|
||||||
env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey);
|
env.AZURE_ACCOUNT_KEY = config.accountKey;
|
||||||
if (config.endpointSuffix) {
|
if (config.endpointSuffix) {
|
||||||
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
|
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
|
||||||
}
|
}
|
||||||
|
|
@ -150,18 +148,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
}
|
}
|
||||||
case "rest": {
|
case "rest": {
|
||||||
if (config.username) {
|
if (config.username) {
|
||||||
env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username);
|
env.RESTIC_REST_USERNAME = config.username;
|
||||||
}
|
}
|
||||||
if (config.password) {
|
if (config.password) {
|
||||||
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password);
|
env.RESTIC_REST_PASSWORD = config.password;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "sftp": {
|
case "sftp": {
|
||||||
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
|
|
||||||
const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`);
|
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")) {
|
if (!normalizedKey.endsWith("\n")) {
|
||||||
normalizedKey += "\n";
|
normalizedKey += "\n";
|
||||||
}
|
}
|
||||||
|
|
@ -206,9 +203,8 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.cacert) {
|
if (config.cacert) {
|
||||||
const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
|
|
||||||
const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
|
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;
|
env.RESTIC_CACERT = certPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,15 +215,24 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
return env;
|
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) => {
|
const init = async (config: RepositoryConfig) => {
|
||||||
await ensurePassfile();
|
await ensurePassfile();
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
|
|
||||||
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||||
|
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args = ["init", "--repo", repoUrl];
|
const args = ["init", "--repo", repoUrl];
|
||||||
addCommonArgs(args, env);
|
addCommonArgs(args, env);
|
||||||
|
|
||||||
|
|
@ -270,8 +275,7 @@ const backup = async (
|
||||||
onProgress?: (progress: BackupProgress) => void;
|
onProgress?: (progress: BackupProgress) => void;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
|
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
|
||||||
|
|
||||||
|
|
@ -422,8 +426,7 @@ const restore = async (
|
||||||
overwrite?: OverwriteMode;
|
overwrite?: OverwriteMode;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
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 snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
|
||||||
const { tags } = options;
|
const { tags } = options;
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args = ["--repo", repoUrl, "snapshots"];
|
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 forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
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 deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
if (snapshotIds.length === 0) {
|
if (snapshotIds.length === 0) {
|
||||||
throw new Error("No snapshot IDs provided for deletion.");
|
throw new Error("No snapshot IDs provided for deletion.");
|
||||||
|
|
@ -609,8 +609,7 @@ const tagSnapshots = async (
|
||||||
snapshotIds: string[],
|
snapshotIds: string[],
|
||||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
if (snapshotIds.length === 0) {
|
if (snapshotIds.length === 0) {
|
||||||
throw new Error("No snapshot IDs provided for tagging.");
|
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 ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
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 unlock = async (config: RepositoryConfig) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||||
addCommonArgs(args, env);
|
addCommonArgs(args, env);
|
||||||
|
|
@ -752,8 +749,7 @@ const unlock = async (config: RepositoryConfig) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "check"];
|
const args: string[] = ["--repo", repoUrl, "check"];
|
||||||
|
|
||||||
|
|
@ -790,8 +786,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||||
};
|
};
|
||||||
|
|
||||||
const repairIndex = async (config: RepositoryConfig) => {
|
const repairIndex = async (config: RepositoryConfig) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const { repoUrl, env } = await resolveAndBuild(config);
|
||||||
const env = await buildEnv(config);
|
|
||||||
|
|
||||||
const args = ["repair", "index", "--repo", repoUrl];
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
addCommonArgs(args, env);
|
addCommonArgs(args, env);
|
||||||
|
|
@ -822,11 +817,8 @@ const copy = async (
|
||||||
snapshotId?: string;
|
snapshotId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
const { resolved: resolvedSource, repoUrl: sourceRepoUrl, env: sourceEnv } = await resolveAndBuild(sourceConfig);
|
||||||
const destRepoUrl = buildRepoUrl(destConfig);
|
const { repoUrl: destRepoUrl, env: destEnv } = await resolveAndBuild(destConfig);
|
||||||
|
|
||||||
const sourceEnv = await buildEnv(sourceConfig);
|
|
||||||
const destEnv = await buildEnv(destConfig);
|
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...sourceEnv,
|
...sourceEnv,
|
||||||
|
|
@ -848,7 +840,7 @@ const copy = async (
|
||||||
|
|
||||||
addCommonArgs(args, env);
|
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}`);
|
args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue