From 11dacd7c71c9d4a532186f18a554154204766fe8 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 17 May 2026 18:19:09 +0200 Subject: [PATCH] refactor(agent): store SFTP volume keys in tmp (#888) * refactor(agent): store SFTP volume keys in tmp * refactor: store temp keys in /run/zerobyte subfolders * fix(restic): clean temp secrets dir on env setup failure * fix: one secrets temp dir per env building --- app/server/core/constants.ts | 3 +- apps/agent/src/volume-host/backends/sftp.ts | 23 +- apps/agent/src/volume-host/constants.ts | 3 +- .../helpers/__tests__/build-env.test.ts | 112 +++++-- packages/core/src/restic/helpers/build-env.ts | 316 ++++++++++-------- .../restic/helpers/cleanup-temporary-keys.ts | 4 + 6 files changed, 284 insertions(+), 177 deletions(-) diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 2adc4108..2a7aa784 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -9,11 +9,10 @@ export const RESTIC_CACHE_DIR = process.env.RESTIC_CACHE_DIR || "/var/lib/zeroby export const DATABASE_URL = process.env.ZEROBYTE_DATABASE_URL || "/var/lib/zerobyte/data/zerobyte.db"; export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass"; -export const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh"; export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone"; export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf"); -export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, SSH_KEYS_DIR, RCLONE_CONFIG_DIR, "/app"]; +export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, RCLONE_CONFIG_DIR, "/app"]; export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE]; diff --git a/apps/agent/src/volume-host/backends/sftp.ts b/apps/agent/src/volume-host/backends/sftp.ts index 7940c67c..802f021c 100644 --- a/apps/agent/src/volume-host/backends/sftp.ts +++ b/apps/agent/src/volume-host/backends/sftp.ts @@ -16,6 +16,27 @@ const getMountPathHash = (mountPath: string) => createHash("sha256").update(moun const getPrivateKeyPath = (mountPath: string) => path.join(SSH_KEYS_DIR, `${getMountPathHash(mountPath)}.key`); const getKnownHostsPath = (mountPath: string) => path.join(SSH_KEYS_DIR, `${getMountPathHash(mountPath)}.known_hosts`); +const ensurePrivateSshKeysDir = async () => { + const { uid } = os.userInfo(); + + try { + await fs.mkdir(SSH_KEYS_DIR, { mode: 0o700 }); + } catch (error) { + if (!isNodeErrnoException(error) || error.code !== "EEXIST") throw error; + + const stats = await fs.lstat(SSH_KEYS_DIR); + if (!stats.isDirectory() || stats.isSymbolicLink() || stats.uid !== uid) { + throw new Error(`Refusing to use unsafe SSH keys directory: ${SSH_KEYS_DIR}`); + } + } + + await fs.chmod(SSH_KEYS_DIR, 0o700); +}; + +const isNodeErrnoException = (error: unknown): error is NodeJS.ErrnoException => { + return error instanceof Error && "code" in error; +}; + const runSshfs = async (args: string[], password?: string) => new Promise((resolve, reject) => { const child = spawn("sshfs", args, { stdio: ["pipe", "pipe", "pipe"] }); @@ -110,7 +131,7 @@ const mount = async (config: BackendConfig, mountPath: string) => { const run = async () => { await fs.mkdir(mountPath, { recursive: true }); - await fs.mkdir(SSH_KEYS_DIR, { recursive: true }); + await ensurePrivateSshKeysDir(); const { uid, gid } = os.userInfo(); const options = [ "reconnect", diff --git a/apps/agent/src/volume-host/constants.ts b/apps/agent/src/volume-host/constants.ts index c11d135d..3e127c44 100644 --- a/apps/agent/src/volume-host/constants.ts +++ b/apps/agent/src/volume-host/constants.ts @@ -1,8 +1,9 @@ import * as path from "node:path"; +import * as os from "node:os"; export const OPERATION_TIMEOUT = 5000; export const VOLUME_MOUNT_BASE = process.env.ZEROBYTE_VOLUMES_DIR || "/var/lib/zerobyte/volumes"; -export const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh"; +export const SSH_KEYS_DIR = path.join(os.tmpdir(), "zerobyte-ssh"); export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone"; export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf"); const serverIdleTimeout = Number(process.env.SERVER_IDLE_TIMEOUT ?? 60); diff --git a/packages/core/src/restic/helpers/__tests__/build-env.test.ts b/packages/core/src/restic/helpers/__tests__/build-env.test.ts index 9d9ac21f..273bd86c 100644 --- a/packages/core/src/restic/helpers/__tests__/build-env.test.ts +++ b/packages/core/src/restic/helpers/__tests__/build-env.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import { afterEach, describe, expect, test } from "vitest"; import { buildEnv } from "../build-env"; import type { ResticDeps } from "../../types"; @@ -24,14 +25,7 @@ const withCustomPassword = (config: T) => ({ const PLAIN_PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----"; - -const tempFiles = new Set(); - -const trackTempFile = (filePath: string | undefined) => { - if (filePath) { - tempFiles.add(filePath); - } -}; +const tempDirs = new Set(); const buildEnvForTest = async ( config: Parameters[0], @@ -39,36 +33,35 @@ const buildEnvForTest = async ( deps: ResticDeps = makeDeps(), ) => { const env = await buildEnv(config, organizationId, deps); - - // Automatically track all temp file paths created by buildEnv - trackTempFile(env.RESTIC_PASSWORD_FILE); - trackTempFile(env.GOOGLE_APPLICATION_CREDENTIALS); - trackTempFile(env._SFTP_KEY_PATH); - trackTempFile(env._SFTP_KNOWN_HOSTS_PATH); - trackTempFile(env.RESTIC_CACERT); - + if (env._RUNTIME_SECRETS_DIR) tempDirs.add(env._RUNTIME_SECRETS_DIR); return env; }; +const listResticTempDirs = async () => { + return new Set((await fs.readdir(os.tmpdir())).filter((name) => name.startsWith("zerobyte-restic-"))); +}; + afterEach(async () => { - await Promise.all( - [...tempFiles].map(async (filePath) => { - await fs.rm(filePath, { force: true }); - }), - ); - tempFiles.clear(); + await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true }))); + tempDirs.clear(); }); describe("buildEnv", () => { describe("base environment", () => { test("always sets RESTIC_CACHE_DIR", async () => { - const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + const env = await buildEnvForTest( + withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), + "org-1", + ); expect(env.RESTIC_CACHE_DIR).toBe(RESTIC_CACHE_DIR); }); test("always sets PATH", async () => { - const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + const env = await buildEnvForTest( + withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), + "org-1", + ); expect(env.PATH).toBeTruthy(); }); @@ -77,7 +70,12 @@ describe("buildEnv", () => { describe("password resolution", () => { test("writes a password file when using customPassword on an existing repository", async () => { const env = await buildEnvForTest( - { backend: "local" as const, path: "/tmp/repo", isExistingRepository: true, customPassword: "my-secret" }, + { + backend: "local" as const, + path: "/tmp/repo", + isExistingRepository: true, + customPassword: "my-secret", + }, "org-1", ); @@ -114,10 +112,39 @@ describe("buildEnv", () => { throw new Error("Organization non-existent-org not found"); }, }); + const before = await listResticTempDirs(); await expect( buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps), ).rejects.toThrow("Organization non-existent-org not found"); + + expect(await listResticTempDirs()).toEqual(before); + }); + + test("cleans the temp directory when setup fails after creating one", async () => { + const before = await listResticTempDirs(); + const deps = makeDeps({ + resolveSecret: async (secret) => { + if (secret === "my-access-key") throw new Error("failed to resolve access key"); + return secret; + }, + }); + + await expect( + buildEnv( + withCustomPassword({ + backend: "s3" as const, + endpoint: "https://s3.amazonaws.com", + bucket: "my-bucket", + accessKeyId: "my-access-key", + secretAccessKey: "my-secret-key", + }), + "org-1", + deps, + ), + ).rejects.toThrow("failed to resolve access key"); + + expect(await listResticTempDirs()).toEqual(before); }); test("throws when getOrganizationResticPassword throws (no password configured)", async () => { @@ -127,9 +154,9 @@ describe("buildEnv", () => { }, }); - await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "org-no-pass", deps)).rejects.toThrow( - "Restic password not configured for organization org-no-pass", - ); + await expect( + buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "org-no-pass", deps), + ).rejects.toThrow("Restic password not configured for organization org-no-pass"); }); }); @@ -150,7 +177,10 @@ describe("buildEnv", () => { }); test("sets AWS_S3_BUCKET_LOOKUP=dns for Huawei Cloud endpoints", async () => { - const env = await buildEnvForTest({ ...base, endpoint: "https://obs.ap-southeast-1.myhuaweicloud.com" }, "org-1"); + const env = await buildEnvForTest( + { ...base, endpoint: "https://obs.ap-southeast-1.myhuaweicloud.com" }, + "org-1", + ); expect(env.AWS_S3_BUCKET_LOOKUP).toBe("dns"); }); @@ -296,7 +326,8 @@ describe("buildEnv", () => { buildEnv( { ...baseSftpConfig, - privateKey: "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n-----END RSA PRIVATE KEY-----", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n-----END RSA PRIVATE KEY-----", }, "org-1", deps, @@ -319,7 +350,10 @@ describe("buildEnv", () => { }); test("uses StrictHostKeyChecking=yes when knownHosts is absent", async () => { - const env = await buildEnvForTest({ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, "org-1"); + const env = await buildEnvForTest( + { ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, + "org-1", + ); expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=yes"); expect(env._SFTP_SSH_ARGS).not.toContain("UserKnownHostsFile=/dev/null"); @@ -336,8 +370,8 @@ describe("buildEnv", () => { ); expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=yes"); - expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/tmp/zerobyte-known-hosts-"); - expect(env._SFTP_KNOWN_HOSTS_PATH).toMatch(/^\/tmp\/zerobyte-known-hosts-/); + expect(env._SFTP_SSH_ARGS).toContain("/zerobyte-known-hosts-"); + expect(env._SFTP_KNOWN_HOSTS_PATH).toMatch(/\/zerobyte-restic-[^/]+\/zerobyte-known-hosts-/); }); test("adds -p flag for non-default ports", async () => { @@ -355,7 +389,7 @@ describe("buildEnv", () => { test("sets the key path in both _SFTP_KEY_PATH and SSH args -i flag", async () => { const env = await buildEnvForTest(baseSftpConfig, "org-1"); - expect(env._SFTP_KEY_PATH).toMatch(/^\/tmp\/zerobyte-ssh-/); + expect(env._SFTP_KEY_PATH).toMatch(/\/zerobyte-restic-[^/]+\/zerobyte-ssh-/); expect(env._SFTP_SSH_ARGS).toContain(`-i ${env._SFTP_KEY_PATH}`); }); }); @@ -382,7 +416,10 @@ describe("buildEnv", () => { }); test("does not set RESTIC_CACERT when cacert is absent", async () => { - const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + const env = await buildEnvForTest( + withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), + "org-1", + ); expect(env.RESTIC_CACERT).toBeUndefined(); }); @@ -399,7 +436,10 @@ describe("buildEnv", () => { }); test("does not set _INSECURE_TLS when insecureTls is absent", async () => { - const env = await buildEnvForTest(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + const env = await buildEnvForTest( + withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), + "org-1", + ); expect(env._INSECURE_TLS).toBeUndefined(); }); diff --git a/packages/core/src/restic/helpers/build-env.ts b/packages/core/src/restic/helpers/build-env.ts index 4561f74a..1f6df4ea 100644 --- a/packages/core/src/restic/helpers/build-env.ts +++ b/packages/core/src/restic/helpers/build-env.ts @@ -1,10 +1,26 @@ import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import type { ResticDeps, ResticEnv } from "../types"; import type { RepositoryConfig } from "../schemas"; import { logger } from "../../node"; import { FILE_MODES, writeFileWithMode } from "../../node/fs.js"; +const createRuntimeSecretsDir = async () => { + const runtimeSecretsDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-")); + await fs.chmod(runtimeSecretsDir, 0o700); + return runtimeSecretsDir; +}; + +const runtimeSecretPath = (runtimeSecretsDir: string, filename: string) => { + return path.join(runtimeSecretsDir, filename); +}; + +const removeRuntimeSecretsDir = async (runtimeSecretsDir: string | undefined) => { + if (runtimeSecretsDir) await fs.rm(runtimeSecretsDir, { recursive: true, force: true }).catch(() => {}); +}; + export const buildEnv = async ( config: RepositoryConfig, organizationId: string, @@ -14,144 +30,170 @@ export const buildEnv = async ( RESTIC_CACHE_DIR: deps.resticCacheDir, PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", }; + let runtimeSecretsDir: string | undefined; + const getRuntimeSecretPath = async (filename: string) => { + if (!runtimeSecretsDir) { + runtimeSecretsDir = await createRuntimeSecretsDir(); + env._RUNTIME_SECRETS_DIR = runtimeSecretsDir; + } + return runtimeSecretPath(runtimeSecretsDir, filename); + }; - if (config.isExistingRepository && config.customPassword) { - const decryptedPassword = await deps.resolveSecret(config.customPassword); - const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); + try { + if (config.isExistingRepository && config.customPassword) { + const decryptedPassword = await deps.resolveSecret(config.customPassword); + const passwordFilePath = await getRuntimeSecretPath( + `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`, + ); - await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); - env.RESTIC_PASSWORD_FILE = passwordFilePath; - } else { - const encryptedPassword = await deps.getOrganizationResticPassword(organizationId); - const decryptedPassword = await deps.resolveSecret(encryptedPassword); - const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); - await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); - env.RESTIC_PASSWORD_FILE = passwordFilePath; + await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } else { + const encryptedPassword = await deps.getOrganizationResticPassword(organizationId); + const decryptedPassword = await deps.resolveSecret(encryptedPassword); + const passwordFilePath = await getRuntimeSecretPath( + `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`, + ); + await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } + + switch (config.backend) { + case "s3": + env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); + + if (config.endpoint.includes("myhuaweicloud")) { + env.AWS_S3_BUCKET_LOOKUP = "dns"; + } + break; + case "r2": + env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); + env.AWS_REGION = "auto"; + env.AWS_S3_FORCE_PATH_STYLE = "true"; + break; + case "gcs": { + const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); + const credentialsPath = await getRuntimeSecretPath( + `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`, + ); + await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite); + 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 deps.resolveSecret(config.accountKey); + if (config.endpointSuffix) { + env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; + } + break; + } + case "rest": { + if (config.username) { + env.RESTIC_REST_USERNAME = await deps.resolveSecret(config.username); + } + if (config.password) { + env.RESTIC_REST_PASSWORD = await deps.resolveSecret(config.password); + } + break; + } + case "rclone": + env.RCLONE_CONFIG = deps.rcloneConfigFile; + break; + case "sftp": { + const decryptedKey = await deps.resolveSecret(config.privateKey); + + let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); + if (!normalizedKey.endsWith("\n")) { + normalizedKey += "\n"; + } + + if (normalizedKey.includes("ENCRYPTED")) { + logger.error( + "SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.", + ); + throw new Error( + "Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.", + ); + } + + const keyPath = await getRuntimeSecretPath(`zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); + + await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite); + + env._SFTP_KEY_PATH = keyPath; + + const sshArgs = [ + "-o", + "LogLevel=ERROR", + "-o", + "BatchMode=yes", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "IdentitiesOnly=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ConnectionAttempts=1", + "-o", + "ServerAliveInterval=60", + "-o", + "ServerAliveCountMax=240", + "-i", + keyPath, + ]; + + if (config.skipHostKeyCheck) { + sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); + } else if (config.knownHosts) { + const knownHostsPath = await getRuntimeSecretPath( + `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`, + ); + await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); + env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; + sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); + } else { + sshArgs.push("-o", "StrictHostKeyChecking=yes"); + } + + if (config.port && config.port !== 22) { + sshArgs.push("-p", String(config.port)); + } + + env._SFTP_SSH_ARGS = sshArgs.join(" "); + logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`); + break; + } + } + + if (config.cacert) { + const decryptedCert = await deps.resolveSecret(config.cacert); + const certPath = await getRuntimeSecretPath(`zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); + await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite); + env.RESTIC_CACERT = certPath; + } + + if (config.insecureTls) { + env._INSECURE_TLS = "true"; + + if (config.backend === "rclone") { + env.RCLONE_NO_CHECK_CERTIFICATE = "true"; + } + } + + return env; + } catch (error) { + await removeRuntimeSecretsDir(runtimeSecretsDir); + throw error; } - - switch (config.backend) { - case "s3": - env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); - - if (config.endpoint.includes("myhuaweicloud")) { - env.AWS_S3_BUCKET_LOOKUP = "dns"; - } - break; - case "r2": - env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey); - env.AWS_REGION = "auto"; - env.AWS_S3_FORCE_PATH_STYLE = "true"; - break; - case "gcs": { - const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); - const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); - await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite); - 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 deps.resolveSecret(config.accountKey); - if (config.endpointSuffix) { - env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; - } - break; - } - case "rest": { - if (config.username) { - env.RESTIC_REST_USERNAME = await deps.resolveSecret(config.username); - } - if (config.password) { - env.RESTIC_REST_PASSWORD = await deps.resolveSecret(config.password); - } - break; - } - case "rclone": - env.RCLONE_CONFIG = deps.rcloneConfigFile; - break; - case "sftp": { - const decryptedKey = await deps.resolveSecret(config.privateKey); - const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); - - let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); - if (!normalizedKey.endsWith("\n")) { - normalizedKey += "\n"; - } - - if (normalizedKey.includes("ENCRYPTED")) { - logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key."); - throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key."); - } - - await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite); - - env._SFTP_KEY_PATH = keyPath; - - const sshArgs = [ - "-o", - "LogLevel=ERROR", - "-o", - "BatchMode=yes", - "-o", - "NumberOfPasswordPrompts=0", - "-o", - "PreferredAuthentications=publickey", - "-o", - "PasswordAuthentication=no", - "-o", - "KbdInteractiveAuthentication=no", - "-o", - "IdentitiesOnly=yes", - "-o", - "ConnectTimeout=10", - "-o", - "ConnectionAttempts=1", - "-o", - "ServerAliveInterval=60", - "-o", - "ServerAliveCountMax=240", - "-i", - keyPath, - ]; - - if (config.skipHostKeyCheck) { - sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); - } else if (config.knownHosts) { - const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`); - await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); - env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; - sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); - } else { - sshArgs.push("-o", "StrictHostKeyChecking=yes"); - } - - if (config.port && config.port !== 22) { - sshArgs.push("-p", String(config.port)); - } - - env._SFTP_SSH_ARGS = sshArgs.join(" "); - logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`); - break; - } - } - - if (config.cacert) { - const decryptedCert = await deps.resolveSecret(config.cacert); - const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); - await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite); - env.RESTIC_CACERT = certPath; - } - - if (config.insecureTls) { - env._INSECURE_TLS = "true"; - - if (config.backend === "rclone") { - env.RCLONE_NO_CHECK_CERTIFICATE = "true"; - } - } - - return env; }; diff --git a/packages/core/src/restic/helpers/cleanup-temporary-keys.ts b/packages/core/src/restic/helpers/cleanup-temporary-keys.ts index b0443f95..83b947dc 100644 --- a/packages/core/src/restic/helpers/cleanup-temporary-keys.ts +++ b/packages/core/src/restic/helpers/cleanup-temporary-keys.ts @@ -13,4 +13,8 @@ export const cleanupTemporaryKeys = async (env: ResticEnv, deps: ResticDeps) => if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== deps.resticPassFile) { await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); } + + if (env._RUNTIME_SECRETS_DIR) { + await fs.rm(env._RUNTIME_SECRETS_DIR, { recursive: true, force: true }).catch(() => {}); + } };