diff --git a/.oxlintrc.json b/.oxlintrc.json index cde16021..ae1c8af2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -148,5 +148,13 @@ "builtin": true }, "globals": {}, - "ignorePatterns": ["**/api-client/**"] + "ignorePatterns": ["**/api-client/**"], + "overrides": [ + { + "files": ["**/*.test.ts", "**/*.test.tsx"], + "rules": { + "typescript/await-thenable": "off" + } + } + ] } diff --git a/app/server/core/__tests__/repository-mutex.test.ts b/app/server/core/__tests__/repository-mutex.test.ts index dc92ca54..4e968bdb 100644 --- a/app/server/core/__tests__/repository-mutex.test.ts +++ b/app/server/core/__tests__/repository-mutex.test.ts @@ -59,7 +59,7 @@ describe("RepositoryMutex", () => { controller.abort(); - expect(exclusivePromise).rejects.toThrow(); + await expect(exclusivePromise).rejects.toThrow(); expect(results).toEqual(["acquired-shared-1", "aborted-exclusive"]); releaseShared1(); @@ -83,10 +83,10 @@ describe("RepositoryMutex", () => { const p3 = repoMutex.acquireExclusive(repoId, "ex-3"); controller2.abort(); - expect(p2).rejects.toThrow(); + await expect(p2).rejects.toThrow(); controller1.abort(); - expect(p1).rejects.toThrow(); + await expect(p1).rejects.toThrow(); releaseShared1(); @@ -115,7 +115,7 @@ describe("RepositoryMutex", () => { // Trigger abort while it's waiting controller.abort(); - expect(abortedAcquisition).rejects.toThrow(); + await expect(abortedAcquisition).rejects.toThrow(); expect(removed).toBe(true); expect(repoMutex.isLocked(repoId)).toBe(true); @@ -252,8 +252,8 @@ describe("RepositoryMutex", () => { const controller = new AbortController(); controller.abort(new Error("pre-aborted")); - expect(repoMutex.acquireShared(repoId, "s1", controller.signal)).rejects.toThrow("pre-aborted"); - expect(repoMutex.acquireExclusive(repoId, "e1", controller.signal)).rejects.toThrow("pre-aborted"); + await expect(repoMutex.acquireShared(repoId, "s1", controller.signal)).rejects.toThrow("pre-aborted"); + await expect(repoMutex.acquireExclusive(repoId, "e1", controller.signal)).rejects.toThrow("pre-aborted"); expect(repoMutex.isLocked(repoId)).toBe(false); }); diff --git a/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts b/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts index 0eb75083..ffa6009c 100644 --- a/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts +++ b/app/server/lib/auth-middlewares/__tests__/convert-legacy-user.test.ts @@ -67,7 +67,7 @@ describe("convertLegacyUserOnFirstLogin", () => { password: "wrong-password", }); - expect(convertLegacyUserOnFirstLogin(ctx)).rejects.toThrow("Invalid credentials"); + await expect(convertLegacyUserOnFirstLogin(ctx)).rejects.toThrow("Invalid credentials"); // Verify user still exists (not migrated) const user = await db.query.usersTable.findFirst({ diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index 7cf3d504..b28c21fa 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -168,14 +168,14 @@ describe("stop backup", () => { }); // act & assert - expect(backupsExecutionService.stopBackup(schedule.id)).rejects.toThrow( + await expect(backupsExecutionService.stopBackup(schedule.id)).rejects.toThrow( "No backup is currently running for this schedule", ); }); test("should throw NotFoundError when schedule does not exist", async () => { // act & assert - expect(backupsExecutionService.stopBackup(99999)).rejects.toThrow("Backup schedule not found"); + await expect(backupsExecutionService.stopBackup(99999)).rejects.toThrow("Backup schedule not found"); }); }); @@ -223,14 +223,14 @@ describe("retention policy - runForget", () => { }); // act & assert - expect(backupsExecutionService.runForget(schedule.id)).rejects.toThrow( + await expect(backupsExecutionService.runForget(schedule.id)).rejects.toThrow( "No retention policy configured for this schedule", ); }); test("should throw NotFoundError when schedule does not exist", async () => { // act & assert - expect(backupsExecutionService.runForget(99999)).rejects.toThrow("Backup schedule not found"); + await expect(backupsExecutionService.runForget(99999)).rejects.toThrow("Backup schedule not found"); }); test("should throw NotFoundError when repository does not exist", async () => { @@ -242,7 +242,9 @@ describe("retention policy - runForget", () => { }); // act & assert - expect(backupsExecutionService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow("Repository not found"); + await expect(backupsExecutionService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow( + "Repository not found", + ); }); }); diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts index a9b999c6..ebeaf169 100644 --- a/app/server/modules/backups/__tests__/backups.service.test.ts +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -222,8 +222,10 @@ describe("getScheduleByIdOrShortId", () => { organizationId: otherOrgId, }); - expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow("Backup schedule not found"); - expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found"); + await expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow( + "Backup schedule not found", + ); + await expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found"); }); }); diff --git a/app/server/modules/volumes/__tests__/volumes.service.test.ts b/app/server/modules/volumes/__tests__/volumes.service.test.ts index 3eb8e50f..41b16254 100644 --- a/app/server/modules/volumes/__tests__/volumes.service.test.ts +++ b/app/server/modules/volumes/__tests__/volumes.service.test.ts @@ -85,7 +85,7 @@ describe("volumeService", () => { const { organizationId, user } = await createTestSession(); await withContext({ organizationId, userId: user.id }, async () => { - expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found"); + await expect(volumeService.getVolume(asShortId("nonexistent"))).rejects.toThrow("Volume not found"); }); }); }); @@ -120,7 +120,7 @@ describe("volumeService security", () => { await withContext({ organizationId, userId: user.id }, async () => { const traversalPath = `../${path.basename(secretPath)}`; - expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path"); + await expect(volumeService.listFiles(volume.shortId, traversalPath)).rejects.toThrow("Invalid path"); }); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); diff --git a/app/server/utils/restic.test.ts b/app/server/utils/restic.test.ts deleted file mode 100644 index 5f15b91e..00000000 --- a/app/server/utils/restic.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as spawnModule from "./spawn"; -import { buildRepoUrl, restic } from "./restic"; - -const successfulRestoreSummary = JSON.stringify({ - message_type: "summary", - files_restored: 1, - files_skipped: 0, - bytes_skipped: 0, -}); - -let lastSafeSpawnArgs: string[] = []; - -const safeSpawnMock = mock((params: spawnModule.SafeSpawnParams) => { - lastSafeSpawnArgs = params.args; - - return Promise.resolve({ - exitCode: 0, - summary: successfulRestoreSummary, - error: "", - }); -}); - -const getRestoreArg = (args: string[]): string => { - const restoreIndex = args.indexOf("restore"); - if (restoreIndex < 0) { - throw new Error("Expected restore command in restic arguments"); - } - - const restoreArg = args[restoreIndex + 1]; - if (!restoreArg) { - throw new Error("Expected restore argument after restore command"); - } - - return restoreArg; -}; - -const getOptionValues = (args: string[], option: string): string[] => { - const values: string[] = []; - for (let i = 0; i < args.length - 1; i++) { - if (args[i] === option) { - const value = args[i + 1]; - if (value) { - values.push(value); - } - } - } - - return values; -}; - -const getLastSafeSpawnArgs = (): string[] => { - if (lastSafeSpawnArgs.length === 0) { - throw new Error("Expected safeSpawn to be called"); - } - - return lastSafeSpawnArgs; -}; - -beforeEach(() => { - safeSpawnMock.mockClear(); - lastSafeSpawnArgs = []; - spyOn(spawnModule, "safeSpawn").mockImplementation(safeSpawnMock); -}); - -afterEach(() => { - mock.restore(); -}); - -describe("buildRepoUrl", () => { - describe("S3 backend", () => { - test("should build URL without trailing slash", () => { - const config = { - backend: "s3" as const, - endpoint: "https://s3.amazonaws.com", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:https://s3.amazonaws.com/my-bucket"); - }); - - test("should trim trailing slash from endpoint", () => { - const config = { - backend: "s3" as const, - endpoint: "https://s3.xxxxxxxxx.net/", - bucket: "backup", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:https://s3.xxxxxxxxx.net/backup"); - }); - - test("should trim trailing whitespace from endpoint", () => { - const config = { - backend: "s3" as const, - endpoint: "https://s3.amazonaws.com/ ", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:https://s3.amazonaws.com/my-bucket"); - }); - - test("should trim leading and trailing whitespace from endpoint", () => { - const config = { - backend: "s3" as const, - endpoint: " https://s3.amazonaws.com/ ", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:https://s3.amazonaws.com/my-bucket"); - }); - }); - - describe("R2 backend", () => { - test("should build URL without trailing slash", () => { - const config = { - backend: "r2" as const, - endpoint: "https://myaccount.r2.cloudflarestorage.com", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:myaccount.r2.cloudflarestorage.com/my-bucket"); - }); - - test("should trim trailing slash from endpoint", () => { - const config = { - backend: "r2" as const, - endpoint: "https://myaccount.r2.cloudflarestorage.com/", - bucket: "backup", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:myaccount.r2.cloudflarestorage.com/backup"); - }); - - test("should strip protocol and trailing slash", () => { - const config = { - backend: "r2" as const, - endpoint: "https://myaccount.r2.cloudflarestorage.com/", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:myaccount.r2.cloudflarestorage.com/my-bucket"); - }); - - test("should trim whitespace and strip protocol", () => { - const config = { - backend: "r2" as const, - endpoint: " https://myaccount.r2.cloudflarestorage.com/ ", - bucket: "my-bucket", - accessKeyId: "test", - secretAccessKey: "test", - }; - expect(buildRepoUrl(config)).toBe("s3:myaccount.r2.cloudflarestorage.com/my-bucket"); - }); - }); - - describe("other backends", () => { - test("should build local repository URL", () => { - const config = { - backend: "local" as const, - path: "/path/to/repo", - }; - expect(buildRepoUrl(config)).toBe("/path/to/repo"); - }); - }); -}); - -describe("restore", () => { - const config = { - backend: "local" as const, - path: "/tmp/restic-repo", - isExistingRepository: true, - customPassword: "custom-password", - }; - - test("keeps snapshot restore arg and absolute include paths when target is root", async () => { - await restic.restore(config, "snapshot-123", "/", { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }); - - const args = getLastSafeSpawnArgs(); - expect(getRestoreArg(args)).toBe("snapshot-123"); - expect(getOptionValues(args, "--include")).toEqual([ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ]); - }); - - test("restores from common ancestor and strips include paths for non-root targets", async () => { - await restic.restore(config, "snapshot-456", "/tmp/restore-target", { - organizationId: "org-1", - include: [ - "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", - "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", - ], - }); - - const args = getLastSafeSpawnArgs(); - expect(getRestoreArg(args)).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); - expect(getOptionValues(args, "--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); - }); - - test("uses base path for non-root restore when includes are omitted", async () => { - await restic.restore(config, "snapshot-789", "/tmp/restore-target", { - organizationId: "org-1", - basePath: "/var/lib/zerobyte/volumes/vol123/_data", - }); - - const args = getLastSafeSpawnArgs(); - expect(getRestoreArg(args)).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); - expect(getOptionValues(args, "--include")).toEqual([]); - }); - - test("does not pass an empty include when include equals restore root", async () => { - await restic.restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { - organizationId: "org-1", - include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], - overwrite: "always", - }); - - const args = getLastSafeSpawnArgs(); - expect(getRestoreArg(args)).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); - expect(getOptionValues(args, "--include")).toEqual([]); - expect(args).not.toContain(""); - }); -}); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index be8d3cf2..4fffcd87 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -1,1328 +1,3 @@ -import crypto from "node:crypto"; -import { normalizeAbsolutePath } from "~/utils/path"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { Readable } from "node:stream"; -import { type } from "arktype"; -import { throttle } from "es-toolkit"; -import type { BandwidthLimit, CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic"; -import { - type ResticBackupProgressDto, - type ResticRestoreOutputDto, - type ResticSnapshotSummaryDto, - resticBackupOutputSchema, - resticBackupProgressSchema, - resticRestoreOutputSchema, - resticSnapshotSummarySchema, - resticStatsSchema, -} from "~/schemas/restic-dto"; -import { config as appConfig } from "../core/config"; -import { DEFAULT_EXCLUDES, RESTIC_CACHE_DIR, RESTIC_PASS_FILE } from "../core/constants"; -import { db } from "../db/db"; -import type { RetentionPolicy } from "../modules/backups/backups.dto"; -import { cryptoUtils } from "./crypto"; -import { ResticError } from "./errors"; -import { safeJsonParse } from "./json"; -import { logger } from "./logger"; -import { safeExec, safeSpawn } from "./spawn"; -import { findCommonAncestor } from "~/utils/common-ancestor"; - -const snapshotInfoSchema = type({ - gid: "number?", - hostname: "string", - id: "string", - parent: "string?", - paths: "string[]", - program_version: "string?", - short_id: "string", - time: "string", - uid: "number?", - username: "string?", - tags: "string[]?", - summary: resticSnapshotSummarySchema.optional(), -}); - -export const buildRepoUrl = (config: RepositoryConfig): string => { - switch (config.backend) { - case "local": - return config.path; - case "s3": { - const endpoint = config.endpoint.trim().replace(/\/$/, ""); - return `s3:${endpoint}/${config.bucket}`; - } - case "r2": { - const endpoint = config.endpoint - .trim() - .replace(/^https?:\/\//, "") - .replace(/\/$/, ""); - return `s3:${endpoint}/${config.bucket}`; - } - case "gcs": - return `gs:${config.bucket}:/`; - case "azure": - return `azure:${config.container}:/`; - case "rclone": - return `rclone:${config.remote}:${config.path}`; - case "rest": { - const path = config.path ? `/${config.path}` : ""; - return `rest:${config.url}${path}`; - } - case "sftp": - return `sftp:${config.user}@${config.host}:${config.path}`; - default: { - throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); - } - } -}; - -export const buildEnv = async (config: RepositoryConfig, organizationId: string) => { - const env: Record = { - RESTIC_CACHE_DIR, - PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", - }; - - 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, - }); - env.RESTIC_PASSWORD_FILE = passwordFilePath; - } else { - const org = await db.query.organization.findFirst({ - where: { id: organizationId }, - }); - - if (!org) { - throw new Error(`Organization ${organizationId} not found`); - } - - const metadata = org.metadata; - if (!metadata?.resticPassword) { - throw new Error(`Restic password not configured for organization ${organizationId}`); - } else { - const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword); - const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); - await fs.writeFile(passwordFilePath, decryptedPassword, { - mode: 0o600, - }); - env.RESTIC_PASSWORD_FILE = passwordFilePath; - } - } - - 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); - - // Huawei OBS requires virtual-hosted style access - if (config.endpoint.includes("myhuaweicloud")) { - env.AWS_S3_BUCKET_LOOKUP = "dns"; - } - 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_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, - }); - 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); - if (config.endpointSuffix) { - env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; - } - break; - } - case "rest": { - if (config.username) { - env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); - } - if (config.password) { - env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(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"); - 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 fs.writeFile(keyPath, normalizedKey, { mode: 0o600 }); - - 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 || !config.knownHosts) { - 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 fs.writeFile(knownHostsPath, config.knownHosts, { - mode: 0o600, - }); - env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; - sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); - } - - 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 cryptoUtils.resolveSecret(config.cacert); - const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); - await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); - env.RESTIC_CACERT = certPath; - } - - if (config.insecureTls) { - env._INSECURE_TLS = "true"; - } - - return env; -}; - -const keyAdd = async ( - config: RepositoryConfig, - organizationId: string, - options: { host: string; timeoutMs?: number }, -) => { - const repoUrl = buildRepoUrl(config); - - logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`); - - const env = await buildEnv(config, organizationId); - - const args = [ - "key", - "add", - "--repo", - repoUrl, - "--host", - options.host, - "--new-password-file", - env.RESTIC_PASSWORD_FILE, - ]; - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic key add failed: ${res.stderr}`); - return { success: false, error: res.stderr }; - } - - logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`); - return { success: true, error: null }; -}; - -const init = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => { - const repoUrl = buildRepoUrl(config); - - logger.info(`Initializing restic repository at ${repoUrl}...`); - - const env = await buildEnv(config, organizationId); - - const args = ["init", "--repo", repoUrl]; - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic init failed: ${res.stderr}`); - return { success: false, error: res.stderr }; - } - - logger.info(`Restic repository initialized: ${repoUrl}`); - - if (appConfig.resticHostname) { - const keyResult = await keyAdd(config, organizationId, { - host: appConfig.resticHostname, - timeoutMs: options?.timeoutMs, - }); - - if (!keyResult.success) { - logger.warn(`Repository initialized but failed to add key with hostname: ${keyResult.error}`); - } - } - - return { success: true, error: null }; -}; - -const backup = async ( - config: RepositoryConfig, - source: string, - options: { - organizationId: string; - exclude?: string[]; - excludeIfPresent?: string[]; - include?: string[]; - tags?: string[]; - oneFileSystem?: boolean; - compressionMode?: CompressionMode; - signal?: AbortSignal; - onProgress?: (progress: ResticBackupProgressDto) => void; - }, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options?.organizationId); - - const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"]; - - if (options?.oneFileSystem) { - args.push("--one-file-system"); - } - - if (appConfig.resticHostname) { - args.push("--host", appConfig.resticHostname); - } - - if (options?.tags && options.tags.length > 0) { - for (const tag of options.tags) { - args.push("--tag", tag); - } - } - - let includeFile: string | null = null; - if (options?.include && options.include.length > 0) { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-")); - includeFile = path.join(tmp, `include.txt`); - - await fs.writeFile(includeFile, options.include.join("\n"), "utf-8"); - - args.push("--files-from", includeFile); - } else { - args.push(source); - } - - for (const exclude of DEFAULT_EXCLUDES) { - args.push("--exclude", exclude); - } - - let excludeFile: string | null = null; - if (options?.exclude && options.exclude.length > 0) { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-")); - excludeFile = path.join(tmp, `exclude.txt`); - - await fs.writeFile(excludeFile, options.exclude.join("\n"), "utf-8"); - - args.push("--exclude-file", excludeFile); - } - - if (options?.excludeIfPresent && options.excludeIfPresent.length > 0) { - for (const filename of options.excludeIfPresent) { - args.push("--exclude-if-present", filename); - } - } - - addCommonArgs(args, env, config); - - const logData = throttle((data: string) => { - logger.info(data.trim()); - }, 5000); - - const streamProgress = throttle((data: string) => { - if (options?.onProgress) { - try { - const jsonData = JSON.parse(data); - const progress = resticBackupProgressSchema(jsonData); - if (!(progress instanceof type.errors)) { - options.onProgress(progress); - } else { - logger.error(progress.summary); - } - } catch (_) { - // Ignore JSON parse errors for non-JSON lines - } - } - }, 1000); - - logger.debug(`Executing: restic ${args.join(" ")}`); - const res = await safeSpawn({ - command: "restic", - args, - env, - signal: options?.signal, - onStdout: (data) => { - logData(data); - if (options?.onProgress) { - streamProgress(data); - } - }, - }); - - if (includeFile) await fs.unlink(includeFile).catch(() => {}); - if (excludeFile) await fs.unlink(excludeFile).catch(() => {}); - await cleanupTemporaryKeys(env); - - if (options?.signal?.aborted) { - logger.warn("Restic backup was aborted by signal."); - return { result: null, exitCode: res.exitCode }; - } - - if (res.exitCode === 3) { - logger.error(`Restic backup encountered read errors: ${res.error}`); - } - - if (res.exitCode !== 0 && res.exitCode !== 3) { - logger.error(`Restic backup failed: ${res.error}`); - logger.error(`Command executed: restic ${args.join(" ")}`); - - throw new ResticError(res.exitCode, res.error); - } - - const lastLine = res.summary.trim(); - let summaryLine = ""; - try { - const resSummary = JSON.parse(lastLine ?? "{}"); - summaryLine = resSummary; - } catch (_) { - logger.warn("Failed to parse restic backup output JSON summary.", lastLine); - summaryLine = "{}"; - } - - logger.debug(`Restic backup output last line: ${summaryLine}`); - const result = resticBackupOutputSchema(summaryLine); - - if (result instanceof type.errors) { - logger.error(`Restic backup output validation failed: ${result.summary}`); - return { result: null, exitCode: res.exitCode }; - } - - return { result, exitCode: res.exitCode }; -}; - -const restoreProgressSchema = type({ - message_type: "'status' | 'summary'", - seconds_elapsed: "number", - percent_done: "number = 0", - total_files: "number", - files_restored: "number = 0", - total_bytes: "number = 0", - bytes_restored: "number = 0", -}); - -export type RestoreProgress = typeof restoreProgressSchema.infer; - -export interface ResticDumpStream { - stream: Readable; - completion: Promise; - abort: () => void; -} - -const restore = async ( - config: RepositoryConfig, - snapshotId: string, - target: string, - options: { - basePath?: string; - organizationId: string; - include?: string[]; - exclude?: string[]; - excludeXattr?: string[]; - delete?: boolean; - overwrite?: OverwriteMode; - onProgress?: (progress: RestoreProgress) => void; - signal?: AbortSignal; - }, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - - let restoreArg = snapshotId; - - const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; - const commonAncestor = findCommonAncestor(includes); - if (target !== "/") { - restoreArg = `${snapshotId}:${commonAncestor}`; - } - - const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target]; - - if (options?.overwrite) { - args.push("--overwrite", options.overwrite); - } - - if (options?.include?.length) { - if (target === "/") { - for (const pattern of options.include) { - args.push("--include", pattern); - } - } else { - const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern)); - const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); - - if (!includesCoverRestoreRoot) { - for (const pattern of strippedIncludes) { - if (pattern !== "" && pattern !== ".") { - args.push("--include", pattern); - } - } - } - } - } - - if (options?.exclude && options.exclude.length > 0) { - for (const pattern of options.exclude) { - args.push("--exclude", pattern); - } - } - - if (options?.excludeXattr && options.excludeXattr.length > 0) { - for (const xattr of options.excludeXattr) { - args.push("--exclude-xattr", xattr); - } - } - - addCommonArgs(args, env, config); - - const streamProgress = throttle((data: string) => { - if (options?.onProgress) { - try { - const jsonData = JSON.parse(data); - const progress = restoreProgressSchema(jsonData); - if (!(progress instanceof type.errors)) { - options.onProgress(progress); - } else { - logger.error(progress.summary); - } - } catch (_) { - // Ignore JSON parse errors for non-JSON lines - } - } - }, 1000); - - logger.debug(`Executing: restic ${args.join(" ")}`); - const res = await safeSpawn({ - command: "restic", - args, - env, - signal: options?.signal, - onStdout: (data) => { - if (options?.onProgress) { - streamProgress(data); - } - }, - }); - - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic restore failed: ${res.error}`); - throw new ResticError(res.exitCode, res.error); - } - - const lastLine = res.summary.trim(); - let summaryLine: unknown = {}; - try { - summaryLine = JSON.parse(lastLine ?? "{}"); - } catch (_) { - logger.warn("Failed to parse restic restore output JSON summary.", lastLine); - summaryLine = {}; - } - - logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`); - const result = resticRestoreOutputSchema(summaryLine); - - if (result instanceof type.errors) { - logger.warn(`Restic restore output validation failed: ${result.summary}`); - logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); - const fallback: ResticRestoreOutputDto = { - message_type: "summary" as const, - total_files: 0, - files_restored: 0, - files_skipped: 0, - bytes_skipped: 0, - }; - - return fallback; - } - - logger.info( - `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`, - ); - - return result; -}; - -const normalizeDumpPath = (pathToDump?: string): string => { - const trimmedPath = pathToDump?.trim(); - if (!trimmedPath) { - return "/"; - } - - return normalizeAbsolutePath(trimmedPath); -}; - -const dump = async ( - config: RepositoryConfig, - snapshotRef: string, - options: { - organizationId: string; - path?: string; - archive?: false; - }, -): Promise => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - const pathToDump = normalizeDumpPath(options.path); - - const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump]; - - if (options.archive !== false) { - args.push("--archive", "tar"); - } - - addCommonArgs(args, env, config, { includeJson: false }); - - logger.debug(`Executing: restic ${args.join(" ")}`); - - let didCleanup = false; - const cleanup = async () => { - if (didCleanup) { - return; - } - - didCleanup = true; - await cleanupTemporaryKeys(env); - }; - - let stream: Readable | null = null; - let abortController: AbortController | null = new AbortController(); - - const MAX_STDERR_CHARS = 64 * 1024; - let stderrTail = ""; - - const completion = safeSpawn({ - command: "restic", - args, - env, - signal: abortController.signal, - stdoutMode: "raw", - onSpawn: (child) => { - stream = child.stdout; - }, - onStderr: (line) => { - const chunk = line.trim(); - if (chunk) { - stderrTail += `${line}\n`; - if (stderrTail.length > MAX_STDERR_CHARS) { - stderrTail = stderrTail.slice(-MAX_STDERR_CHARS); - } - } - }, - }) - .then((result) => { - if (result.exitCode === 0) { - return; - } - - const stderr = stderrTail.trim() || result.error; - logger.error(`Restic dump failed: ${stderr}`); - throw new ResticError(result.exitCode, stderr); - }) - .finally(async () => { - abortController = null; - await cleanup(); - }); - - completion.catch(() => {}); - const completionPromise = new Promise((res, rej) => completion.then(res, rej)); - - if (!stream) { - await cleanup(); - throw new Error("Failed to initialize restic dump stream"); - } - - return { - stream, - completion: completionPromise, - abort: () => { - if (abortController) { - abortController.abort(); - } - }, - }; -}; - -const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => { - const { tags, organizationId } = options; - - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); - - const args = ["--repo", repoUrl, "snapshots"]; - - if (tags && tags.length > 0) { - for (const tag of tags) { - args.push("--tag", tag); - } - } - - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); - throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`); - } - - const result = snapshotInfoSchema.array()(JSON.parse(res.stdout)); - - if (result instanceof type.errors) { - logger.error(`Restic snapshots output validation failed: ${result.summary}`); - throw new Error(`Restic snapshots output validation failed: ${result.summary}`); - } - - return result; -}; - -const stats = async (config: RepositoryConfig, options: { organizationId: string }) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - - const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic stats retrieval failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - const parsedJson = safeJsonParse(res.stdout); - const result = resticStatsSchema(parsedJson); - - if (result instanceof type.errors) { - logger.error(`Restic stats output validation failed: ${result.summary}`); - throw new Error(`Restic stats output validation failed: ${result.summary}`); - } - - return result; -}; - -export type ResticForgetResponse = ForgetGroup[]; - -export interface ForgetGroup { - tags: string[] | null; - host: string; - paths: string[] | null; - keep: Snapshot[]; - remove: Snapshot[] | null; - reasons: ForgetReason[]; -} - -export interface Snapshot { - time: string; - parent?: string; - tree: string; - paths: string[]; - hostname: string; - username?: string; - uid?: number; - gid?: number; - excludes?: string[] | null; - tags?: string[] | null; - program_version?: string; - summary?: ResticSnapshotSummaryDto; - id: string; - short_id: string; -} - -export interface ForgetReason { - snapshot: Snapshot; - matches: string[]; -} - -const forget = async ( - config: RepositoryConfig, - options: RetentionPolicy, - extra: { tag: string; organizationId: string; dryRun?: boolean }, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, extra.organizationId); - - const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; - - if (extra.dryRun) { - args.push("--dry-run", "--no-lock"); - } - - if (options.keepLast) { - args.push("--keep-last", String(options.keepLast)); - } - if (options.keepHourly) { - args.push("--keep-hourly", String(options.keepHourly)); - } - if (options.keepDaily) { - args.push("--keep-daily", String(options.keepDaily)); - } - if (options.keepWeekly) { - args.push("--keep-weekly", String(options.keepWeekly)); - } - if (options.keepMonthly) { - args.push("--keep-monthly", String(options.keepMonthly)); - } - if (options.keepYearly) { - args.push("--keep-yearly", String(options.keepYearly)); - } - if (options.keepWithinDuration) { - args.push("--keep-within-duration", options.keepWithinDuration); - } - - if (!extra.dryRun) { - args.push("--prune"); - } - - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic forget failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - const lines = res.stdout.split("\n").filter((line) => line.trim()); - const result = extra.dryRun ? safeJsonParse(lines.at(-1) ?? "[]") : null; - - return { success: true, data: result }; -}; - -const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); - - if (snapshotIds.length === 0) { - throw new Error("No snapshot IDs provided for deletion."); - } - - const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"]; - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic snapshot deletion failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - return { success: true }; -}; - -const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => { - return deleteSnapshots(config, [snapshotId], organizationId); -}; - -const tagSnapshots = async ( - config: RepositoryConfig, - snapshotIds: string[], - tags: { add?: string[]; remove?: string[]; set?: string[] }, - organizationId: string, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); - - if (snapshotIds.length === 0) { - throw new Error("No snapshot IDs provided for tagging."); - } - - const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds]; - - if (tags.add) { - for (const tag of tags.add) { - args.push("--add", tag); - } - } - - if (tags.remove) { - for (const tag of tags.remove) { - args.push("--remove", tag); - } - } - - if (tags.set) { - for (const tag of tags.set) { - args.push("--set", tag); - } - } - - addCommonArgs(args, env, config); - - const res = await safeExec({ command: "restic", args, env }); - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic snapshot tagging failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - return { success: true }; -}; - -const lsNodeSchema = type({ - name: "string", - type: "string", - path: "string", - uid: "number?", - gid: "number?", - size: "number?", - mode: "number?", - mtime: "string?", - atime: "string?", - ctime: "string?", - struct_type: "'node'", -}); - -const lsSnapshotInfoSchema = type({ - time: "string", - parent: "string?", - tree: "string", - paths: "string[]", - hostname: "string", - username: "string?", - id: "string", - short_id: "string", - struct_type: "'snapshot'", - message_type: "'snapshot'", -}); - -const ls = async ( - config: RepositoryConfig, - snapshotId: string, - organizationId: string, - path?: string, - options?: { offset?: number; limit?: number }, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, organizationId); - - const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; - - if (path) { - args.push(path); - } - - addCommonArgs(args, env, config); - - let snapshot: typeof lsSnapshotInfoSchema.infer | null = null; - const nodes: Array = []; - let totalNodes = 0; - let isFirstLine = true; - let hasMore = false; - - const offset = Math.max(options?.offset ?? 0, 0); - const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500); - - const res = await safeSpawn({ - command: "restic", - args, - env, - onStdout: (line) => { - const trimmedLine = line.trim(); - if (!trimmedLine) return; - - try { - const data = JSON.parse(trimmedLine); - - if (isFirstLine) { - isFirstLine = false; - const snapshotValidation = lsSnapshotInfoSchema(data); - if (!(snapshotValidation instanceof type.errors)) { - snapshot = snapshotValidation; - } - return; - } - - const nodeValidation = lsNodeSchema(data); - if (nodeValidation instanceof type.errors) { - logger.warn(`Skipping invalid node: ${nodeValidation.summary}`); - return; - } - - if (totalNodes >= offset && totalNodes < offset + limit) { - nodes.push(nodeValidation); - } - totalNodes++; - - if (totalNodes >= offset + limit + 1) { - hasMore = true; - } - } catch (_) { - // Ignore JSON parse errors for non-JSON lines - } - }, - }); - - await cleanupTemporaryKeys(env); - - if (res.exitCode !== 0) { - logger.error(`Restic ls failed: ${res.error}`); - throw new ResticError(res.exitCode, res.error); - } - - if (totalNodes > offset + limit) { - hasMore = true; - } - - return { - snapshot: snapshot as typeof lsSnapshotInfoSchema.infer | null, - nodes, - pagination: { - offset, - limit, - total: totalNodes, - hasMore, - }, - }; -}; - -const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - - const args = ["unlock", "--repo", repoUrl, "--remove-all"]; - addCommonArgs(args, env, config); - - const res = await safeExec({ - command: "restic", - args, - env, - signal: options?.signal, - }); - await cleanupTemporaryKeys(env); - - if (options?.signal?.aborted) { - logger.warn("Restic unlock was aborted by signal."); - return { success: false, message: "Operation aborted" }; - } - - if (res.exitCode !== 0) { - logger.error(`Restic unlock failed: ${res.stderr}`); - throw new ResticError(res.exitCode, res.stderr); - } - - logger.info(`Restic unlock succeeded for repository: ${repoUrl}`); - return { success: true, message: "Repository unlocked successfully" }; -}; - -const check = async ( - config: RepositoryConfig, - options: { - readData?: boolean; - signal?: AbortSignal; - organizationId: string; - }, -) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - - const args: string[] = ["--repo", repoUrl, "check"]; - - if (options?.readData) { - args.push("--read-data"); - } - - addCommonArgs(args, env, config); - - const res = await safeExec({ - command: "restic", - args, - env, - signal: options?.signal, - }); - await cleanupTemporaryKeys(env); - - if (options?.signal?.aborted) { - logger.warn("Restic check was aborted by signal."); - return { - success: false, - hasErrors: true, - output: "", - error: "Operation aborted", - }; - } - - const { stdout, stderr } = res; - - if (res.exitCode !== 0) { - logger.error(`Restic check failed: ${stderr}`); - return { - success: false, - hasErrors: true, - output: stdout, - error: stderr, - }; - } - - const hasErrors = stdout.includes("Fatal"); - - logger.info(`Restic check completed for repository: ${repoUrl}`); - return { - success: !hasErrors, - hasErrors, - output: stdout, - error: hasErrors ? "Repository contains errors" : null, - }; -}; - -const repairIndex = async (config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }) => { - const repoUrl = buildRepoUrl(config); - const env = await buildEnv(config, options.organizationId); - - const args = ["repair", "index", "--repo", repoUrl]; - addCommonArgs(args, env, config); - - const res = await safeExec({ - command: "restic", - args, - env, - signal: options?.signal, - }); - await cleanupTemporaryKeys(env); - - if (options?.signal?.aborted) { - logger.warn("Restic repair index was aborted by signal."); - return { success: false, message: "Operation aborted", output: "" }; - } - - const { stdout, stderr } = res; - - if (res.exitCode !== 0) { - logger.error(`Restic repair index failed: ${stderr}`); - throw new ResticError(res.exitCode, stderr); - } - - logger.info(`Restic repair index completed for repository: ${repoUrl}`); - return { - success: true, - output: stdout, - message: "Index repaired successfully", - }; -}; - -const copy = async ( - sourceConfig: RepositoryConfig, - destConfig: RepositoryConfig, - options: { organizationId: string; tag?: string; snapshotId?: string }, -) => { - const sourceRepoUrl = buildRepoUrl(sourceConfig); - const destRepoUrl = buildRepoUrl(destConfig); - - const sourceEnv = await buildEnv(sourceConfig, options.organizationId); - const destEnv = await buildEnv(destConfig, options.organizationId); - - const env: Record = { - ...sourceEnv, - ...destEnv, - RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE, - }; - - const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; - - if (options.tag) { - args.push("--tag", options.tag); - } - - if (options.snapshotId) { - args.push(options.snapshotId); - } else { - args.push("latest"); - } - - addCommonArgs(args, env, destConfig, { skipBandwidth: true }); - - const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit); - const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit); - - if (sourceDownloadLimit) { - args.push("--limit-download", sourceDownloadLimit); - } - - // Apply upload limit to destination for all backends - if (destUploadLimit) { - args.push("--limit-upload", destUploadLimit); - } - - logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); - logger.debug(`Executing: restic ${args.join(" ")}`); - - const res = await safeExec({ command: "restic", args, env }); - - await cleanupTemporaryKeys(sourceEnv); - await cleanupTemporaryKeys(destEnv); - - const { stdout, stderr } = res; - - if (res.exitCode !== 0) { - logger.error(`Restic copy failed: ${stderr}`); - throw new ResticError(res.exitCode, stderr); - } - - logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); - return { - success: true, - output: stdout, - }; -}; - -const formatBandwidthLimit = (limit?: BandwidthLimit): string => { - if (!limit || !limit.enabled || limit.value <= 0) { - return ""; - } - - let kibibytesPerSecond: number; - switch (limit.unit) { - case "Kbps": - // Kilobits per second to KiB/s: kilobits→bits→bytes→KiB - kibibytesPerSecond = (limit.value * 1000) / 8 / 1024; - break; - case "Mbps": - // Megabits per second to KiB/s: multiply by 1000000 (Mb to bits), divide by 8 (bits to bytes), then by 1024 (bytes to KiB) - kibibytesPerSecond = (limit.value * 1000000) / (8 * 1024); - break; - case "Gbps": - // Gigabits per second to KiB/s: multiply by 1000000000 (Gb to bits), divide by 8 (bits to bytes), then by 1024 (bytes to KiB) - kibibytesPerSecond = (limit.value * 1000000000) / (8 * 1024); - break; - default: - return ""; - } - - const limitValue = Math.max(1, Math.floor(kibibytesPerSecond)); - return `${limitValue}`; -}; - -export const addCommonArgs = ( - args: string[], - env: Record, - config?: RepositoryConfig, - options?: { skipBandwidth?: boolean; includeJson?: boolean }, -) => { - if (options?.includeJson !== false) { - args.push("--json"); - } - - if (env._SFTP_SSH_ARGS) { - args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); - } - - if (env.AWS_S3_BUCKET_LOOKUP === "dns") { - args.push("-o", "s3.bucket-lookup=dns"); - } - - if (env._INSECURE_TLS === "true") { - args.push("--insecure-tls"); - } - - if (env.RESTIC_CACERT) { - args.push("--cacert", env.RESTIC_CACERT); - } - - if (config && !options?.skipBandwidth) { - const uploadLimit = formatBandwidthLimit(config.uploadLimit); - if (uploadLimit) { - args.push("--limit-upload", uploadLimit); - } - - const downloadLimit = formatBandwidthLimit(config.downloadLimit); - if (downloadLimit) { - args.push("--limit-download", downloadLimit); - } - } -}; - -export const restic = { - init, - keyAdd, - backup, - restore, - dump, - snapshots, - stats, - forget, - deleteSnapshot, - deleteSnapshots, - tagSnapshots, - unlock, - ls, - check, - repairIndex, - copy, -}; - -export const cleanupTemporaryKeys = async (env: Record) => { - const keysToClean = ["_SFTP_KEY_PATH", "_SFTP_KNOWN_HOSTS_PATH", "RESTIC_CACERT", "GOOGLE_APPLICATION_CREDENTIALS"]; - - for (const key of keysToClean) { - if (env[key]) { - await fs.unlink(env[key]).catch(() => {}); - } - } - - // Clean up custom password files - if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) { - await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); - } -}; +export { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "./restic/index"; +export type { RestoreProgress } from "./restic/index"; +export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./restic/index"; diff --git a/app/server/utils/restic/commands/__tests__/backup.test.ts b/app/server/utils/restic/commands/__tests__/backup.test.ts new file mode 100644 index 00000000..c0f733e5 --- /dev/null +++ b/app/server/utils/restic/commands/__tests__/backup.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as cleanupModule from "~/server/utils/restic/helpers/cleanup-temporary-keys"; +import * as spawnModule from "~/server/utils/spawn"; +import { ResticError } from "~/server/utils/errors"; +import { backup } from "../backup"; + +const VALID_SUMMARY = JSON.stringify({ + message_type: "summary", + files_new: 10, + files_changed: 5, + files_unmodified: 85, + dirs_new: 2, + dirs_changed: 1, + dirs_unmodified: 17, + data_blobs: 20, + tree_blobs: 5, + data_added: 1048576, + total_files_processed: 100, + total_bytes_processed: 2097152, + total_duration: 12.34, + snapshot_id: "abcd1234", +}); + +const VALID_PROGRESS_LINE = JSON.stringify({ + message_type: "status", + seconds_elapsed: 5, + percent_done: 0.5, + total_files: 100, + files_done: 50, + total_bytes: 2097152, + bytes_done: 1048576, +}); + +const config = { + backend: "local" as const, + path: "/tmp/restic-repo", + isExistingRepository: true, + customPassword: "custom-password", +}; + +type SetupOptions = { + spawnResult?: Partial; + onSpawnCall?: (params: spawnModule.SafeSpawnParams) => void; +}; + +/** + * Sets up mocks for safeSpawn and cleanupTemporaryKeys, captures spawn args, + * and returns helpers to inspect what was passed to restic. + */ +const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { + let capturedArgs: string[] = []; + + const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); + spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { + capturedArgs = params.args; + onSpawnCall?.(params); + return Promise.resolve({ exitCode: 0, summary: VALID_SUMMARY, error: "", ...spawnResult }); + }); + + return { + cleanupSpy, + getArgs: () => capturedArgs, + hasFlag: (flag: string) => capturedArgs.includes(flag), + getOptionValues: (option: string): string[] => { + const values: string[] = []; + for (let i = 0; i < capturedArgs.length - 1; i++) { + if (capturedArgs[i] === option && capturedArgs[i + 1]) { + values.push(capturedArgs[i + 1]!); + } + } + return values; + }, + }; +}; + +afterEach(() => { + mock.restore(); +}); + +describe("backup command", () => { + describe("argument construction", () => { + test("passes source path as positional arg when no include list is given", async () => { + const { getArgs, hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(getArgs()).toContain("/mnt/data"); + expect(hasFlag("--files-from")).toBe(false); + }); + + test("uses --files-from instead of source path when include list is provided", async () => { + const { hasFlag, getArgs } = setup(); + await backup(config, "/mnt/data", { + organizationId: "org-1", + include: ["/mnt/data/docs", "/mnt/data/photos"], + }); + + expect(hasFlag("--files-from")).toBe(true); + expect(getArgs()).not.toContain("/mnt/data"); + }); + + test("adds --tag for each entry in options.tags", async () => { + const { getOptionValues } = setup(); + await backup(config, "/mnt/data", { + organizationId: "org-1", + tags: ["tag-a", "tag-b"], + }); + + expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]); + }); + + test("omits --tag when tags list is empty", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }); + + expect(hasFlag("--tag")).toBe(false); + }); + + test("passes provided compressionMode to --compression", async () => { + const { getOptionValues } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }); + + expect(getOptionValues("--compression")).toEqual(["max"]); + }); + + test("defaults --compression to auto when compressionMode is omitted", async () => { + const { getOptionValues } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(getOptionValues("--compression")).toEqual(["auto"]); + }); + + test("adds --one-file-system when oneFileSystem is true", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }); + + expect(hasFlag("--one-file-system")).toBe(true); + }); + + test("omits --one-file-system when oneFileSystem is false", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }); + + expect(hasFlag("--one-file-system")).toBe(false); + }); + + test("adds --exclude-file when exclude list is provided", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { + organizationId: "org-1", + exclude: ["/mnt/data/tmp", "/mnt/data/cache"], + }); + + expect(hasFlag("--exclude-file")).toBe(true); + }); + + test("omits --exclude-file when exclude list is empty", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }); + + expect(hasFlag("--exclude-file")).toBe(false); + }); + + test("adds --exclude-if-present for each entry in excludeIfPresent", async () => { + const { getOptionValues } = setup(); + await backup(config, "/mnt/data", { + organizationId: "org-1", + excludeIfPresent: [".nobackup", ".gitignore"], + }); + + expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]); + }); + + test("always includes DEFAULT_EXCLUDES as --exclude args", async () => { + const { getOptionValues } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(getOptionValues("--exclude").length).toBeGreaterThan(0); + }); + + test("includes --host arg from config", async () => { + const { hasFlag } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(hasFlag("--host")).toBe(true); + }); + }); + + describe("exit code handling", () => { + test("returns parsed result on exit code 0", async () => { + setup(); + const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(exitCode).toBe(0); + expect(result?.snapshot_id).toBe("abcd1234"); + }); + + test("returns result without throwing on exit code 3 (partial read errors)", async () => { + setup({ spawnResult: { exitCode: 3 } }); + const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(exitCode).toBe(3); + expect(result).not.toBeNull(); + }); + + test("throws ResticError on non-zero, non-3 exit codes", async () => { + setup({ spawnResult: { exitCode: 1, summary: "", error: "fatal error" } }); + + await expect(backup(config, "/mnt/data", { organizationId: "org-1" })).rejects.toBeInstanceOf(ResticError); + }); + + test("preserves the exit code inside the thrown ResticError", async () => { + setup({ spawnResult: { exitCode: 12, summary: "", error: "wrong password" } }); + + const error = await backup(config, "/mnt/data", { organizationId: "org-1" }).catch((e) => e); + expect(error).toBeInstanceOf(ResticError); + expect((error as ResticError).code).toBe(12); + }); + + test("returns { result: null } when the abort signal is triggered", async () => { + const controller = new AbortController(); + setup({ + onSpawnCall: () => controller.abort(), + spawnResult: { exitCode: 130, summary: "", error: "" }, + }); + + const { result, exitCode } = await backup(config, "/mnt/data", { + organizationId: "org-1", + signal: controller.signal, + }); + + expect(result).toBeNull(); + expect(exitCode).toBe(130); + }); + }); + + describe("output parsing", () => { + test("returns a fully parsed summary object on valid output", async () => { + setup(); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(result).toMatchObject({ + message_type: "summary", + snapshot_id: "abcd1234", + total_duration: 12.34, + }); + }); + + test("returns { result: null } when summary line is not valid JSON", async () => { + setup({ spawnResult: { summary: "not-json" } }); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(result).toBeNull(); + }); + + test("returns { result: null } when summary JSON does not satisfy the schema", async () => { + setup({ spawnResult: { summary: JSON.stringify({ message_type: "summary" }) } }); + const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(result).toBeNull(); + }); + }); + + describe("progress callbacks", () => { + test("calls onProgress with parsed data when a valid status line arrives", async () => { + const progressUpdates: unknown[] = []; + setup({ onSpawnCall: (params) => params.onStdout?.(VALID_PROGRESS_LINE) }); + + await backup(config, "/mnt/data", { + organizationId: "org-1", + onProgress: (p) => progressUpdates.push(p), + }); + + expect(progressUpdates.length).toBeGreaterThan(0); + expect(progressUpdates[0]).toMatchObject({ + message_type: "status", + percent_done: 0.5, + files_done: 50, + }); + }); + + test("ignores non-JSON stdout lines without throwing", async () => { + setup({ + onSpawnCall: (params) => { + params.onStdout?.("scanning..."); + params.onStdout?.("repository opened"); + }, + }); + + await expect( + backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }), + ).resolves.toBeDefined(); + }); + + test("ignores valid JSON lines that do not match the progress schema", async () => { + const progressUpdates: unknown[] = []; + setup({ + onSpawnCall: (params) => params.onStdout?.(JSON.stringify({ message_type: "verbose_status", action: "scan" })), + }); + + await backup(config, "/mnt/data", { + organizationId: "org-1", + onProgress: (p) => progressUpdates.push(p), + }); + + expect(progressUpdates).toHaveLength(0); + }); + }); + + describe("cleanup", () => { + test("calls cleanupTemporaryKeys after a successful backup", async () => { + const { cleanupSpy } = setup(); + await backup(config, "/mnt/data", { organizationId: "org-1" }); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + + test("calls cleanupTemporaryKeys even when the command fails", async () => { + const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } }); + await backup(config, "/mnt/data", { organizationId: "org-1" }).catch(() => {}); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/server/utils/restic/commands/__tests__/restore.test.ts b/app/server/utils/restic/commands/__tests__/restore.test.ts new file mode 100644 index 00000000..c73537a5 --- /dev/null +++ b/app/server/utils/restic/commands/__tests__/restore.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as spawnModule from "~/server/utils/spawn"; +import { restore } from "../restore"; + +const successfulRestoreSummary = JSON.stringify({ + message_type: "summary", + files_restored: 1, + files_skipped: 0, + bytes_skipped: 0, +}); + +const config = { + backend: "local" as const, + path: "/tmp/restic-repo", + isExistingRepository: true, + customPassword: "custom-password", +}; + +/** + * Sets up the safeSpawn mock and returns helpers to inspect the restic args + * that were built for the restore command. + */ +const setup = () => { + let capturedArgs: string[] = []; + + spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { + capturedArgs = params.args; + return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" }); + }); + + const getRestoreArg = () => { + const restoreIndex = capturedArgs.indexOf("restore"); + if (restoreIndex < 0 || !capturedArgs[restoreIndex + 1]) { + throw new Error("Expected restore argument after restore command"); + } + return capturedArgs[restoreIndex + 1]!; + }; + + const getOptionValues = (option: string): string[] => { + const values: string[] = []; + for (let i = 0; i < capturedArgs.length - 1; i++) { + if (capturedArgs[i] === option && capturedArgs[i + 1]) { + values.push(capturedArgs[i + 1]!); + } + } + return values; + }; + + return { + getArgs: () => capturedArgs, + getRestoreArg, + getOptionValues, + }; +}; + +afterEach(() => { + mock.restore(); +}); + +describe("restore command", () => { + test("keeps snapshot restore arg and absolute include paths when target is root", async () => { + const { getRestoreArg, getOptionValues } = setup(); + await restore(config, "snapshot-123", "/", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + expect(getRestoreArg()).toBe("snapshot-123"); + expect(getOptionValues("--include")).toEqual([ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ]); + }); + + test("restores from common ancestor and strips include paths for non-root targets", async () => { + const { getRestoreArg, getOptionValues } = setup(); + await restore(config, "snapshot-456", "/tmp/restore-target", { + organizationId: "org-1", + include: [ + "/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf", + "/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg", + ], + }); + + expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]); + }); + + test("uses base path for non-root restore when includes are omitted", async () => { + const { getRestoreArg, getOptionValues } = setup(); + await restore(config, "snapshot-789", "/tmp/restore-target", { + organizationId: "org-1", + basePath: "/var/lib/zerobyte/volumes/vol123/_data", + }); + + expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data"); + expect(getOptionValues("--include")).toEqual([]); + }); + + test("does not pass an empty include when include equals restore root", async () => { + const { getArgs, getRestoreArg, getOptionValues } = setup(); + await restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { + organizationId: "org-1", + include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"], + overwrite: "always", + }); + + expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"); + expect(getOptionValues("--include")).toEqual([]); + expect(getArgs()).not.toContain(""); + }); +}); diff --git a/app/server/utils/restic/commands/backup.ts b/app/server/utils/restic/commands/backup.ts new file mode 100644 index 00000000..17bf4a78 --- /dev/null +++ b/app/server/utils/restic/commands/backup.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { type } from "arktype"; +import { throttle } from "es-toolkit"; +import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; +import { + type ResticBackupProgressDto, + resticBackupOutputSchema, + resticBackupProgressSchema, +} from "~/schemas/restic-dto"; +import { DEFAULT_EXCLUDES } from "~/server/core/constants"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeSpawn } from "~/server/utils/spawn"; +import { config as appConfig } from "~/server/core/config"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const backup = async ( + config: RepositoryConfig, + source: string, + options: { + organizationId: string; + exclude?: string[]; + excludeIfPresent?: string[]; + include?: string[]; + tags?: string[]; + oneFileSystem?: boolean; + compressionMode?: CompressionMode; + signal?: AbortSignal; + onProgress?: (progress: ResticBackupProgressDto) => void; + }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"]; + + if (options.oneFileSystem) { + args.push("--one-file-system"); + } + + if (appConfig.resticHostname) { + args.push("--host", appConfig.resticHostname); + } + + if (options.tags && options.tags.length > 0) { + for (const tag of options.tags) { + args.push("--tag", tag); + } + } + + let includeFile: string | null = null; + if (options.include && options.include.length > 0) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-")); + includeFile = path.join(tmp, "include.txt"); + + await fs.writeFile(includeFile, options.include.join("\n"), "utf-8"); + + args.push("--files-from", includeFile); + } else { + args.push(source); + } + + for (const exclude of DEFAULT_EXCLUDES) { + args.push("--exclude", exclude); + } + + let excludeFile: string | null = null; + if (options.exclude && options.exclude.length > 0) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-")); + excludeFile = path.join(tmp, "exclude.txt"); + + await fs.writeFile(excludeFile, options.exclude.join("\n"), "utf-8"); + + args.push("--exclude-file", excludeFile); + } + + if (options.excludeIfPresent && options.excludeIfPresent.length > 0) { + for (const filename of options.excludeIfPresent) { + args.push("--exclude-if-present", filename); + } + } + + addCommonArgs(args, env, config); + + const logData = throttle((data: string) => { + logger.info(data.trim()); + }, 5000); + + const streamProgress = throttle((data: string) => { + if (options.onProgress) { + try { + const jsonData = JSON.parse(data); + const progress = resticBackupProgressSchema(jsonData); + if (!(progress instanceof type.errors)) { + options.onProgress(progress); + } else { + logger.error(progress.summary); + } + } catch { + // Ignore JSON parse errors for non-JSON lines + } + } + }, 1000); + + logger.debug(`Executing: restic ${args.join(" ")}`); + const res = await safeSpawn({ + command: "restic", + args, + env, + signal: options.signal, + onStdout: (data) => { + logData(data); + if (options.onProgress) { + streamProgress(data); + } + }, + }); + + if (includeFile) { + await fs.unlink(includeFile).catch(() => {}); + } + if (excludeFile) { + await fs.unlink(excludeFile).catch(() => {}); + } + await cleanupTemporaryKeys(env); + + if (options.signal?.aborted) { + logger.warn("Restic backup was aborted by signal."); + return { result: null, exitCode: res.exitCode }; + } + + if (res.exitCode === 3) { + logger.error(`Restic backup encountered read errors: ${res.error}`); + } + + if (res.exitCode !== 0 && res.exitCode !== 3) { + logger.error(`Restic backup failed: ${res.error}`); + logger.error(`Command executed: restic ${args.join(" ")}`); + + throw new ResticError(res.exitCode, res.error); + } + + const lastLine = res.summary.trim(); + let summaryLine: unknown = {}; + try { + summaryLine = JSON.parse(lastLine ?? "{}"); + } catch { + logger.warn("Failed to parse restic backup output JSON summary.", lastLine); + summaryLine = {}; + } + + logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`); + const result = resticBackupOutputSchema(summaryLine); + + if (result instanceof type.errors) { + logger.error(`Restic backup output validation failed: ${result.summary}`); + return { result: null, exitCode: res.exitCode }; + } + + return { result, exitCode: res.exitCode }; +}; diff --git a/app/server/utils/restic/commands/check.ts b/app/server/utils/restic/commands/check.ts new file mode 100644 index 00000000..2576b9fa --- /dev/null +++ b/app/server/utils/restic/commands/check.ts @@ -0,0 +1,67 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const check = async ( + config: RepositoryConfig, + options: { + readData?: boolean; + signal?: AbortSignal; + organizationId: string; + }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + const args: string[] = ["--repo", repoUrl, "check"]; + + if (options.readData) { + args.push("--read-data"); + } + + addCommonArgs(args, env, config); + + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env); + + if (options.signal?.aborted) { + logger.warn("Restic check was aborted by signal."); + return { + success: false, + hasErrors: true, + output: "", + error: "Operation aborted", + }; + } + + const { stdout, stderr } = res; + + if (res.exitCode !== 0) { + logger.error(`Restic check failed: ${stderr}`); + return { + success: false, + hasErrors: true, + output: stdout, + error: stderr, + }; + } + + const hasErrors = stdout.includes("Fatal"); + + logger.info(`Restic check completed for repository: ${repoUrl}`); + return { + success: !hasErrors, + hasErrors, + output: stdout, + error: hasErrors ? "Repository contains errors" : null, + }; +}; diff --git a/app/server/utils/restic/commands/copy.ts b/app/server/utils/restic/commands/copy.ts new file mode 100644 index 00000000..97d90740 --- /dev/null +++ b/app/server/utils/restic/commands/copy.ts @@ -0,0 +1,73 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { formatBandwidthLimit } from "../helpers/bandwidth"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const copy = async ( + sourceConfig: RepositoryConfig, + destConfig: RepositoryConfig, + options: { organizationId: string; tag?: string; snapshotId?: string }, +) => { + const sourceRepoUrl = buildRepoUrl(sourceConfig); + const destRepoUrl = buildRepoUrl(destConfig); + + const sourceEnv = await buildEnv(sourceConfig, options.organizationId); + const destEnv = await buildEnv(destConfig, options.organizationId); + + const env: Record = { + ...sourceEnv, + ...destEnv, + RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE, + }; + + const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl]; + + if (options.tag) { + args.push("--tag", options.tag); + } + + if (options.snapshotId) { + args.push(options.snapshotId); + } else { + args.push("latest"); + } + + addCommonArgs(args, env, destConfig, { skipBandwidth: true }); + + const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit); + const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit); + + if (sourceDownloadLimit) { + args.push("--limit-download", sourceDownloadLimit); + } + + if (destUploadLimit) { + args.push("--limit-upload", destUploadLimit); + } + + logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`); + logger.debug(`Executing: restic ${args.join(" ")}`); + + const res = await safeExec({ command: "restic", args, env }); + + await cleanupTemporaryKeys(sourceEnv); + await cleanupTemporaryKeys(destEnv); + + const { stdout, stderr } = res; + + if (res.exitCode !== 0) { + logger.error(`Restic copy failed: ${stderr}`); + throw new ResticError(res.exitCode, stderr); + } + + logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`); + return { + success: true, + output: stdout, + }; +}; diff --git a/app/server/utils/restic/commands/delete-snapshots.ts b/app/server/utils/restic/commands/delete-snapshots.ts new file mode 100644 index 00000000..856df691 --- /dev/null +++ b/app/server/utils/restic/commands/delete-snapshots.ts @@ -0,0 +1,34 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId); + + if (snapshotIds.length === 0) { + throw new Error("No snapshot IDs provided for deletion."); + } + + const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"]; + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshot deletion failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + return { success: true }; +}; + +export const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => { + return deleteSnapshots(config, [snapshotId], organizationId); +}; diff --git a/app/server/utils/restic/commands/dump.ts b/app/server/utils/restic/commands/dump.ts new file mode 100644 index 00000000..7b3b71b5 --- /dev/null +++ b/app/server/utils/restic/commands/dump.ts @@ -0,0 +1,111 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { normalizeAbsolutePath } from "~/utils/path"; +import type { Readable } from "node:stream"; +import { logger } from "~/server/utils/logger"; +import { ResticError } from "~/server/utils/errors"; +import { safeSpawn } from "~/server/utils/spawn"; +import type { ResticDumpStream } from "../types"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +const normalizeDumpPath = (pathToDump?: string): string => { + const trimmedPath = pathToDump?.trim(); + if (!trimmedPath) { + return "/"; + } + + return normalizeAbsolutePath(trimmedPath); +}; + +export const dump = async ( + config: RepositoryConfig, + snapshotRef: string, + options: { + organizationId: string; + path?: string; + archive?: false; + }, +): Promise => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + const pathToDump = normalizeDumpPath(options.path); + + const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump]; + + if (options.archive !== false) { + args.push("--archive", "tar"); + } + + addCommonArgs(args, env, config, { includeJson: false }); + + logger.debug(`Executing: restic ${args.join(" ")}`); + + let didCleanup = false; + const cleanup = async () => { + if (didCleanup) { + return; + } + + didCleanup = true; + await cleanupTemporaryKeys(env); + }; + + let stream: Readable | null = null; + let abortController: AbortController | null = new AbortController(); + + const maxStderrChars = 64 * 1024; + let stderrTail = ""; + + const completion = safeSpawn({ + command: "restic", + args, + env, + signal: abortController.signal, + stdoutMode: "raw", + onSpawn: (child) => { + stream = child.stdout; + }, + onStderr: (line) => { + const chunk = line.trim(); + if (chunk) { + stderrTail += `${line}\n`; + if (stderrTail.length > maxStderrChars) { + stderrTail = stderrTail.slice(-maxStderrChars); + } + } + }, + }) + .then((result) => { + if (result.exitCode === 0) { + return; + } + + const stderr = stderrTail.trim() || result.error; + logger.error(`Restic dump failed: ${stderr}`); + throw new ResticError(result.exitCode, stderr); + }) + .finally(async () => { + abortController = null; + await cleanup(); + }); + + completion.catch(() => {}); + const completionPromise = new Promise((resolve, reject) => completion.then(resolve, reject)); + + if (!stream) { + await cleanup(); + throw new Error("Failed to initialize restic dump stream"); + } + + return { + stream, + completion: completionPromise, + abort: () => { + if (abortController) { + abortController.abort(); + } + }, + }; +}; diff --git a/app/server/utils/restic/commands/forget.ts b/app/server/utils/restic/commands/forget.ts new file mode 100644 index 00000000..a2bc98e2 --- /dev/null +++ b/app/server/utils/restic/commands/forget.ts @@ -0,0 +1,67 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import type { RetentionPolicy } from "~/server/modules/backups/backups.dto"; +import { ResticError } from "~/server/utils/errors"; +import { safeJsonParse } from "~/server/utils/json"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import type { ResticForgetResponse } from "../types"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const forget = async ( + config: RepositoryConfig, + options: RetentionPolicy, + extra: { tag: string; organizationId: string; dryRun?: boolean }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, extra.organizationId); + + const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; + + if (extra.dryRun) { + args.push("--dry-run", "--no-lock"); + } + + if (options.keepLast) { + args.push("--keep-last", String(options.keepLast)); + } + if (options.keepHourly) { + args.push("--keep-hourly", String(options.keepHourly)); + } + if (options.keepDaily) { + args.push("--keep-daily", String(options.keepDaily)); + } + if (options.keepWeekly) { + args.push("--keep-weekly", String(options.keepWeekly)); + } + if (options.keepMonthly) { + args.push("--keep-monthly", String(options.keepMonthly)); + } + if (options.keepYearly) { + args.push("--keep-yearly", String(options.keepYearly)); + } + if (options.keepWithinDuration) { + args.push("--keep-within-duration", options.keepWithinDuration); + } + + if (!extra.dryRun) { + args.push("--prune"); + } + + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic forget failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + const lines = res.stdout.split("\n").filter((line) => line.trim()); + const result = extra.dryRun ? safeJsonParse(lines.at(-1) ?? "[]") : null; + + return { success: true, data: result }; +}; diff --git a/app/server/utils/restic/commands/init.ts b/app/server/utils/restic/commands/init.ts new file mode 100644 index 00000000..0d1153d5 --- /dev/null +++ b/app/server/utils/restic/commands/init.ts @@ -0,0 +1,43 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { config as appConfig } from "~/server/core/config"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; +import { keyAdd } from "./key-add"; + +export const init = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => { + const repoUrl = buildRepoUrl(config); + + logger.info(`Initializing restic repository at ${repoUrl}...`); + + const env = await buildEnv(config, organizationId); + + const args = ["init", "--repo", repoUrl]; + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic init failed: ${res.stderr}`); + return { success: false, error: res.stderr }; + } + + logger.info(`Restic repository initialized: ${repoUrl}`); + + if (appConfig.resticHostname) { + const keyResult = await keyAdd(config, organizationId, { + host: appConfig.resticHostname, + timeoutMs: options?.timeoutMs, + }); + + if (!keyResult.success) { + logger.warn(`Repository initialized but failed to add key with hostname: ${keyResult.error}`); + } + } + + return { success: true, error: null }; +}; diff --git a/app/server/utils/restic/commands/key-add.ts b/app/server/utils/restic/commands/key-add.ts new file mode 100644 index 00000000..b5056665 --- /dev/null +++ b/app/server/utils/restic/commands/key-add.ts @@ -0,0 +1,42 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const keyAdd = async ( + config: RepositoryConfig, + organizationId: string, + options: { host: string; timeoutMs?: number }, +) => { + const repoUrl = buildRepoUrl(config); + + logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`); + + const env = await buildEnv(config, organizationId); + + const args = [ + "key", + "add", + "--repo", + repoUrl, + "--host", + options.host, + "--new-password-file", + env.RESTIC_PASSWORD_FILE, + ]; + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic key add failed: ${res.stderr}`); + return { success: false, error: res.stderr }; + } + + logger.info(`Restic key added with host "${options.host}" for repository: ${repoUrl}`); + return { success: true, error: null }; +}; diff --git a/app/server/utils/restic/commands/ls.ts b/app/server/utils/restic/commands/ls.ts new file mode 100644 index 00000000..bff352f7 --- /dev/null +++ b/app/server/utils/restic/commands/ls.ts @@ -0,0 +1,128 @@ +import { type } from "arktype"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeSpawn } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +const lsNodeSchema = type({ + name: "string", + type: "string", + path: "string", + uid: "number?", + gid: "number?", + size: "number?", + mode: "number?", + mtime: "string?", + atime: "string?", + ctime: "string?", + struct_type: "'node'", +}); + +const lsSnapshotInfoSchema = type({ + time: "string", + parent: "string?", + tree: "string", + paths: "string[]", + hostname: "string", + username: "string?", + id: "string", + short_id: "string", + struct_type: "'snapshot'", + message_type: "'snapshot'", +}); + +export const ls = async ( + config: RepositoryConfig, + snapshotId: string, + organizationId: string, + path?: string, + options?: { offset?: number; limit?: number }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId); + + const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; + + if (path) { + args.push(path); + } + + addCommonArgs(args, env, config); + + let snapshot: typeof lsSnapshotInfoSchema.infer | null = null; + const nodes: Array = []; + let totalNodes = 0; + let isFirstLine = true; + let hasMore = false; + + const offset = Math.max(options?.offset ?? 0, 0); + const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500); + + const res = await safeSpawn({ + command: "restic", + args, + env, + onStdout: (line) => { + const trimmedLine = line.trim(); + if (!trimmedLine) { + return; + } + + try { + const data = JSON.parse(trimmedLine); + + if (isFirstLine) { + isFirstLine = false; + const snapshotValidation = lsSnapshotInfoSchema(data); + if (!(snapshotValidation instanceof type.errors)) { + snapshot = snapshotValidation; + } + return; + } + + const nodeValidation = lsNodeSchema(data); + if (nodeValidation instanceof type.errors) { + logger.warn(`Skipping invalid node: ${nodeValidation.summary}`); + return; + } + + if (totalNodes >= offset && totalNodes < offset + limit) { + nodes.push(nodeValidation); + } + totalNodes++; + + if (totalNodes >= offset + limit + 1) { + hasMore = true; + } + } catch { + // Ignore JSON parse errors for non-JSON lines + } + }, + }); + + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic ls failed: ${res.error}`); + throw new ResticError(res.exitCode, res.error); + } + + if (totalNodes > offset + limit) { + hasMore = true; + } + + return { + snapshot: snapshot as typeof lsSnapshotInfoSchema.infer | null, + nodes, + pagination: { + offset, + limit, + total: totalNodes, + hasMore, + }, + }; +}; diff --git a/app/server/utils/restic/commands/repair-index.ts b/app/server/utils/restic/commands/repair-index.ts new file mode 100644 index 00000000..6dbd2ad4 --- /dev/null +++ b/app/server/utils/restic/commands/repair-index.ts @@ -0,0 +1,46 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const repairIndex = async ( + config: RepositoryConfig, + options: { signal?: AbortSignal; organizationId: string }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + const args = ["repair", "index", "--repo", repoUrl]; + addCommonArgs(args, env, config); + + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env); + + if (options.signal?.aborted) { + logger.warn("Restic repair index was aborted by signal."); + return { success: false, message: "Operation aborted", output: "" }; + } + + const { stdout, stderr } = res; + + if (res.exitCode !== 0) { + logger.error(`Restic repair index failed: ${stderr}`); + throw new ResticError(res.exitCode, stderr); + } + + logger.info(`Restic repair index completed for repository: ${repoUrl}`); + return { + success: true, + output: stdout, + message: "Index repaired successfully", + }; +}; diff --git a/app/server/utils/restic/commands/restore.ts b/app/server/utils/restic/commands/restore.ts new file mode 100644 index 00000000..0e283539 --- /dev/null +++ b/app/server/utils/restic/commands/restore.ts @@ -0,0 +1,161 @@ +import path from "node:path"; +import { type } from "arktype"; +import { throttle } from "es-toolkit"; +import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic"; +import type { ResticRestoreOutputDto } from "~/schemas/restic-dto"; +import { resticRestoreOutputSchema } from "~/schemas/restic-dto"; +import { findCommonAncestor } from "~/utils/common-ancestor"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeSpawn } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +const restoreProgressSchema = type({ + message_type: "'status' | 'summary'", + seconds_elapsed: "number", + percent_done: "number = 0", + total_files: "number", + files_restored: "number = 0", + total_bytes: "number = 0", + bytes_restored: "number = 0", +}); + +export type RestoreProgress = typeof restoreProgressSchema.infer; + +export const restore = async ( + config: RepositoryConfig, + snapshotId: string, + target: string, + options: { + basePath?: string; + organizationId: string; + include?: string[]; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + overwrite?: OverwriteMode; + onProgress?: (progress: RestoreProgress) => void; + signal?: AbortSignal; + }, +) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + let restoreArg = snapshotId; + + const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; + const commonAncestor = findCommonAncestor(includes); + if (target !== "/") { + restoreArg = `${snapshotId}:${commonAncestor}`; + } + + const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target]; + + if (options.overwrite) { + args.push("--overwrite", options.overwrite); + } + + if (options.include?.length) { + if (target === "/") { + for (const pattern of options.include) { + args.push("--include", pattern); + } + } else { + const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern)); + const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); + + if (!includesCoverRestoreRoot) { + for (const pattern of strippedIncludes) { + if (pattern !== "" && pattern !== ".") { + args.push("--include", pattern); + } + } + } + } + } + + if (options.exclude && options.exclude.length > 0) { + for (const pattern of options.exclude) { + args.push("--exclude", pattern); + } + } + + if (options.excludeXattr && options.excludeXattr.length > 0) { + for (const xattr of options.excludeXattr) { + args.push("--exclude-xattr", xattr); + } + } + + addCommonArgs(args, env, config); + + const streamProgress = throttle((data: string) => { + if (options.onProgress) { + try { + const jsonData = JSON.parse(data); + const progress = restoreProgressSchema(jsonData); + if (!(progress instanceof type.errors)) { + options.onProgress(progress); + } else { + logger.error(progress.summary); + } + } catch { + // Ignore JSON parse errors for non-JSON lines + } + } + }, 1000); + + logger.debug(`Executing: restic ${args.join(" ")}`); + const res = await safeSpawn({ + command: "restic", + args, + env, + signal: options.signal, + onStdout: (data) => { + if (options.onProgress) { + streamProgress(data); + } + }, + }); + + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic restore failed: ${res.error}`); + throw new ResticError(res.exitCode, res.error); + } + + const lastLine = res.summary.trim(); + let summaryLine: unknown = {}; + try { + summaryLine = JSON.parse(lastLine ?? "{}"); + } catch { + logger.warn("Failed to parse restic restore output JSON summary.", lastLine); + summaryLine = {}; + } + + logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`); + const result = resticRestoreOutputSchema(summaryLine); + + if (result instanceof type.errors) { + logger.warn(`Restic restore output validation failed: ${result.summary}`); + logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); + const fallback: ResticRestoreOutputDto = { + message_type: "summary" as const, + total_files: 0, + files_restored: 0, + files_skipped: 0, + bytes_skipped: 0, + }; + + return fallback; + } + + logger.info( + `Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`, + ); + + return result; +}; diff --git a/app/server/utils/restic/commands/snapshots.ts b/app/server/utils/restic/commands/snapshots.ts new file mode 100644 index 00000000..a628cafc --- /dev/null +++ b/app/server/utils/restic/commands/snapshots.ts @@ -0,0 +1,58 @@ +import { type } from "arktype"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { resticSnapshotSummarySchema } from "~/schemas/restic-dto"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +const snapshotInfoSchema = type({ + gid: "number?", + hostname: "string", + id: "string", + parent: "string?", + paths: "string[]", + program_version: "string?", + short_id: "string", + time: "string", + uid: "number?", + username: "string?", + tags: "string[]?", + summary: resticSnapshotSummarySchema.optional(), +}); + +export const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => { + const { tags, organizationId } = options; + + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId); + + const args = ["--repo", repoUrl, "snapshots"]; + + if (tags && tags.length > 0) { + for (const tag of tags) { + args.push("--tag", tag); + } + } + + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); + throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`); + } + + const result = snapshotInfoSchema.array()(JSON.parse(res.stdout)); + + if (result instanceof type.errors) { + logger.error(`Restic snapshots output validation failed: ${result.summary}`); + throw new Error(`Restic snapshots output validation failed: ${result.summary}`); + } + + return result; +}; diff --git a/app/server/utils/restic/commands/stats.ts b/app/server/utils/restic/commands/stats.ts new file mode 100644 index 00000000..112947d0 --- /dev/null +++ b/app/server/utils/restic/commands/stats.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { resticStatsSchema } from "~/schemas/restic-dto"; +import { safeJsonParse } from "~/server/utils/json"; +import { logger } from "~/server/utils/logger"; +import { ResticError } from "~/server/utils/errors"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const stats = async (config: RepositoryConfig, options: { organizationId: string }) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"]; + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic stats retrieval failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + const parsedJson = safeJsonParse(res.stdout); + const result = resticStatsSchema(parsedJson); + + if (result instanceof type.errors) { + logger.error(`Restic stats output validation failed: ${result.summary}`); + throw new Error(`Restic stats output validation failed: ${result.summary}`); + } + + return result; +}; diff --git a/app/server/utils/restic/commands/tag-snapshots.ts b/app/server/utils/restic/commands/tag-snapshots.ts new file mode 100644 index 00000000..b5e4bfcd --- /dev/null +++ b/app/server/utils/restic/commands/tag-snapshots.ts @@ -0,0 +1,54 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const tagSnapshots = async ( + config: RepositoryConfig, + snapshotIds: string[], + tags: { add?: string[]; remove?: string[]; set?: string[] }, + organizationId: string, +) => { + if (snapshotIds.length === 0) { + throw new Error("No snapshot IDs provided for tagging."); + } + + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, organizationId); + + const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds]; + + if (tags.add) { + for (const tag of tags.add) { + args.push("--add", tag); + } + } + + if (tags.remove) { + for (const tag of tags.remove) { + args.push("--remove", tag); + } + } + + if (tags.set) { + for (const tag of tags.set) { + args.push("--set", tag); + } + } + + addCommonArgs(args, env, config); + + const res = await safeExec({ command: "restic", args, env }); + await cleanupTemporaryKeys(env); + + if (res.exitCode !== 0) { + logger.error(`Restic snapshot tagging failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + return { success: true }; +}; diff --git a/app/server/utils/restic/commands/unlock.ts b/app/server/utils/restic/commands/unlock.ts new file mode 100644 index 00000000..13fb81e7 --- /dev/null +++ b/app/server/utils/restic/commands/unlock.ts @@ -0,0 +1,37 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { ResticError } from "~/server/utils/errors"; +import { logger } from "~/server/utils/logger"; +import { safeExec } from "~/server/utils/spawn"; +import { addCommonArgs } from "../helpers/add-common-args"; +import { buildEnv } from "../helpers/build-env"; +import { buildRepoUrl } from "../helpers/build-repo-url"; +import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys"; + +export const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }) => { + const repoUrl = buildRepoUrl(config); + const env = await buildEnv(config, options.organizationId); + + const args = ["unlock", "--repo", repoUrl, "--remove-all"]; + addCommonArgs(args, env, config); + + const res = await safeExec({ + command: "restic", + args, + env, + signal: options.signal, + }); + await cleanupTemporaryKeys(env); + + if (options.signal?.aborted) { + logger.warn("Restic unlock was aborted by signal."); + return { success: false, message: "Operation aborted" }; + } + + if (res.exitCode !== 0) { + logger.error(`Restic unlock failed: ${res.stderr}`); + throw new ResticError(res.exitCode, res.stderr); + } + + logger.info(`Restic unlock succeeded for repository: ${repoUrl}`); + return { success: true, message: "Repository unlocked successfully" }; +}; diff --git a/app/server/utils/restic/helpers/__tests__/build-env.test.ts b/app/server/utils/restic/helpers/__tests__/build-env.test.ts new file mode 100644 index 00000000..4b892f41 --- /dev/null +++ b/app/server/utils/restic/helpers/__tests__/build-env.test.ts @@ -0,0 +1,314 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { db } from "~/server/db/db"; +import { organization } from "~/server/db/schema"; +import { RESTIC_CACHE_DIR } from "~/server/core/constants"; +import { buildEnv } from "../build-env"; + +const withCustomPassword = (config: T) => ({ + ...config, + isExistingRepository: true as const, + customPassword: "test-password", +}); + +const createTestOrg = async (overrides: Partial = {}) => { + const id = randomUUID(); + await db.insert(organization).values({ + id, + name: "Build-env test org", + slug: `build-env-test-${id}`, + createdAt: new Date(), + metadata: { resticPassword: "org-restic-password" }, + ...overrides, + }); + return id; +}; + +const PLAIN_PRIVATE_KEY = + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----"; + +describe("buildEnv", () => { + describe("base environment", () => { + test("always sets RESTIC_CACHE_DIR", async () => { + const env = await buildEnv(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 buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + + expect(env.PATH).toBeTruthy(); + }); + }); + + describe("password resolution", () => { + test("writes a password file when using customPassword on an existing repository", async () => { + const env = await buildEnv( + { backend: "local" as const, path: "/tmp/repo", isExistingRepository: true, customPassword: "my-secret" }, + "org-1", + ); + + expect(env.RESTIC_PASSWORD_FILE).toMatch(/^\/tmp\/zerobyte-pass-.+\.txt$/); + }); + + test("writes a password file from the organization's resticPassword when no customPassword is given", async () => { + const orgId = await createTestOrg(); + + const env = await buildEnv({ backend: "local" as const, path: "/tmp/repo" }, orgId); + + expect(env.RESTIC_PASSWORD_FILE).toMatch(/^\/tmp\/zerobyte-pass-.+\.txt$/); + }); + + test("throws when the organization does not exist", async () => { + await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org")).rejects.toThrow( + "Organization non-existent-org not found", + ); + }); + + test("throws when the organization has no resticPassword configured", async () => { + const orgId = await createTestOrg({ metadata: null }); + + await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, orgId)).rejects.toThrow( + `Restic password not configured for organization ${orgId}`, + ); + }); + }); + + describe("s3 backend", () => { + const base = withCustomPassword({ + backend: "s3" as const, + endpoint: "https://s3.amazonaws.com", + bucket: "my-bucket", + accessKeyId: "my-access-key", + secretAccessKey: "my-secret-key", + }); + + test("sets AWS credentials", async () => { + const env = await buildEnv(base, "org-1"); + + expect(env.AWS_ACCESS_KEY_ID).toBe("my-access-key"); + expect(env.AWS_SECRET_ACCESS_KEY).toBe("my-secret-key"); + }); + + test("sets AWS_S3_BUCKET_LOOKUP=dns for Huawei Cloud endpoints", async () => { + const env = await buildEnv({ ...base, endpoint: "https://obs.ap-southeast-1.myhuaweicloud.com" }, "org-1"); + + expect(env.AWS_S3_BUCKET_LOOKUP).toBe("dns"); + }); + + test("does not set AWS_S3_BUCKET_LOOKUP for standard S3 endpoints", async () => { + const env = await buildEnv(base, "org-1"); + + expect(env.AWS_S3_BUCKET_LOOKUP).toBeUndefined(); + }); + }); + + describe("r2 backend", () => { + test("sets AWS credentials with auto region and forced path style", async () => { + const env = await buildEnv( + withCustomPassword({ + backend: "r2" as const, + endpoint: "https://myaccount.r2.cloudflarestorage.com", + bucket: "my-bucket", + accessKeyId: "r2-access-key", + secretAccessKey: "r2-secret-key", + }), + "org-1", + ); + + expect(env.AWS_ACCESS_KEY_ID).toBe("r2-access-key"); + expect(env.AWS_SECRET_ACCESS_KEY).toBe("r2-secret-key"); + expect(env.AWS_REGION).toBe("auto"); + expect(env.AWS_S3_FORCE_PATH_STYLE).toBe("true"); + }); + }); + + describe("gcs backend", () => { + test("sets project ID and writes credentials file", async () => { + const env = await buildEnv( + withCustomPassword({ + backend: "gcs" as const, + bucket: "my-gcs-bucket", + projectId: "my-gcp-project", + credentialsJson: '{"type":"service_account"}', + }), + "org-1", + ); + + expect(env.GOOGLE_PROJECT_ID).toBe("my-gcp-project"); + expect(env.GOOGLE_APPLICATION_CREDENTIALS).toMatch(/^\/tmp\/zerobyte-gcs-.+\.json$/); + }); + }); + + describe("azure backend", () => { + const base = withCustomPassword({ + backend: "azure" as const, + container: "my-container", + accountName: "mystorageaccount", + accountKey: "my-account-key", + }); + + test("sets account name and key", async () => { + const env = await buildEnv(base, "org-1"); + + expect(env.AZURE_ACCOUNT_NAME).toBe("mystorageaccount"); + expect(env.AZURE_ACCOUNT_KEY).toBe("my-account-key"); + }); + + test("includes endpoint suffix when provided", async () => { + const env = await buildEnv({ ...base, endpointSuffix: "core.chinacloudapi.cn" }, "org-1"); + + expect(env.AZURE_ENDPOINT_SUFFIX).toBe("core.chinacloudapi.cn"); + }); + + test("omits endpoint suffix when not provided", async () => { + const env = await buildEnv(base, "org-1"); + + expect(env.AZURE_ENDPOINT_SUFFIX).toBeUndefined(); + }); + }); + + describe("rest backend", () => { + test("sets username and password when both are provided", async () => { + const env = await buildEnv( + withCustomPassword({ + backend: "rest" as const, + url: "https://rest-server.example.com", + username: "rest-user", + password: "rest-pass", + }), + "org-1", + ); + + expect(env.RESTIC_REST_USERNAME).toBe("rest-user"); + expect(env.RESTIC_REST_PASSWORD).toBe("rest-pass"); + }); + + test("omits REST credentials when neither username nor password is provided", async () => { + const env = await buildEnv( + withCustomPassword({ backend: "rest" as const, url: "https://rest-server.example.com" }), + "org-1", + ); + + expect(env.RESTIC_REST_USERNAME).toBeUndefined(); + expect(env.RESTIC_REST_PASSWORD).toBeUndefined(); + }); + }); + + describe("sftp backend", () => { + const baseSftpConfig = withCustomPassword({ + backend: "sftp" as const, + host: "backup.example.com", + port: 22, + user: "backup", + path: "/backups", + privateKey: PLAIN_PRIVATE_KEY, + skipHostKeyCheck: true as const, + }); + + test("throws for passphrase-protected private keys", async () => { + await expect( + buildEnv( + { + ...baseSftpConfig, + privateKey: "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n-----END RSA PRIVATE KEY-----", + }, + "org-1", + ), + ).rejects.toThrow("Passphrase-protected SSH keys are not supported"); + }); + + test("succeeds when the private key has CRLF line endings", async () => { + const crlfKey = PLAIN_PRIVATE_KEY.replace(/\n/g, "\r\n"); + + await expect(buildEnv({ ...baseSftpConfig, privateKey: crlfKey }, "org-1")).resolves.toBeDefined(); + }); + + test("uses StrictHostKeyChecking=no when skipHostKeyCheck is true", async () => { + const env = await buildEnv({ ...baseSftpConfig, skipHostKeyCheck: true }, "org-1"); + + expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=no"); + expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); + }); + + test("uses StrictHostKeyChecking=no when knownHosts is absent", async () => { + const env = await buildEnv({ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, "org-1"); + + expect(env._SFTP_SSH_ARGS).toContain("StrictHostKeyChecking=no"); + expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); + }); + + test("uses StrictHostKeyChecking=yes and a known hosts file when knownHosts is provided", async () => { + const env = await buildEnv( + { + ...baseSftpConfig, + skipHostKeyCheck: false, + knownHosts: "backup.example.com ssh-rsa AAAAB3NzaC1...", + }, + "org-1", + ); + + 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-/); + }); + + test("adds -p flag for non-default ports", async () => { + const env = await buildEnv({ ...baseSftpConfig, port: 2222 }, "org-1"); + + expect(env._SFTP_SSH_ARGS).toContain("-p 2222"); + }); + + test("omits -p flag for the default port 22", async () => { + const env = await buildEnv({ ...baseSftpConfig, port: 22 }, "org-1"); + + expect(env._SFTP_SSH_ARGS).not.toContain("-p 22"); + }); + + test("sets the key path in both _SFTP_KEY_PATH and SSH args -i flag", async () => { + const env = await buildEnv(baseSftpConfig, "org-1"); + + expect(env._SFTP_KEY_PATH).toMatch(/^\/tmp\/zerobyte-ssh-/); + expect(env._SFTP_SSH_ARGS).toContain(`-i ${env._SFTP_KEY_PATH}`); + }); + }); + + describe("cacert", () => { + test("sets RESTIC_CACERT pointing to a temp file when cacert is provided", async () => { + const env = await buildEnv( + withCustomPassword({ + backend: "local" as const, + path: "/tmp/repo", + cacert: "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----", + }), + "org-1", + ); + + expect(env.RESTIC_CACERT).toMatch(/^\/tmp\/zerobyte-cacert-.+\.pem$/); + }); + + test("does not set RESTIC_CACERT when cacert is absent", async () => { + const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + + expect(env.RESTIC_CACERT).toBeUndefined(); + }); + }); + + describe("insecure TLS", () => { + test("sets _INSECURE_TLS=true when insecureTls is true", async () => { + const env = await buildEnv( + withCustomPassword({ backend: "local" as const, path: "/tmp/repo", insecureTls: true }), + "org-1", + ); + + expect(env._INSECURE_TLS).toBe("true"); + }); + + test("does not set _INSECURE_TLS when insecureTls is absent", async () => { + const env = await buildEnv(withCustomPassword({ backend: "local" as const, path: "/tmp/repo" }), "org-1"); + + expect(env._INSECURE_TLS).toBeUndefined(); + }); + }); +}); diff --git a/app/server/utils/restic/helpers/__tests__/build-repo-url.test.ts b/app/server/utils/restic/helpers/__tests__/build-repo-url.test.ts new file mode 100644 index 00000000..a5aa8c50 --- /dev/null +++ b/app/server/utils/restic/helpers/__tests__/build-repo-url.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { buildRepoUrl } from "../build-repo-url"; + +describe("buildRepoUrl", () => { + describe("S3 backend", () => { + test.each([ + { + label: "standard endpoint", + endpoint: "https://s3.amazonaws.com", + bucket: "my-bucket", + expected: "s3:https://s3.amazonaws.com/my-bucket", + }, + { + label: "trailing slash on endpoint", + endpoint: "https://s3.xxxxxxxxx.net/", + bucket: "backup", + expected: "s3:https://s3.xxxxxxxxx.net/backup", + }, + { + label: "trailing whitespace on endpoint", + endpoint: "https://s3.amazonaws.com/ ", + bucket: "my-bucket", + expected: "s3:https://s3.amazonaws.com/my-bucket", + }, + { + label: "leading and trailing whitespace on endpoint", + endpoint: " https://s3.amazonaws.com/ ", + bucket: "my-bucket", + expected: "s3:https://s3.amazonaws.com/my-bucket", + }, + ])("$label → $expected", ({ endpoint, bucket, expected }) => { + expect(buildRepoUrl({ backend: "s3", endpoint, bucket, accessKeyId: "test", secretAccessKey: "test" })).toBe( + expected, + ); + }); + }); + + describe("R2 backend", () => { + test.each([ + { + label: "standard endpoint (strips https:// protocol)", + endpoint: "https://myaccount.r2.cloudflarestorage.com", + bucket: "my-bucket", + expected: "s3:myaccount.r2.cloudflarestorage.com/my-bucket", + }, + { + label: "trailing slash on endpoint", + endpoint: "https://myaccount.r2.cloudflarestorage.com/", + bucket: "backup", + expected: "s3:myaccount.r2.cloudflarestorage.com/backup", + }, + { + label: "leading and trailing whitespace on endpoint", + endpoint: " https://myaccount.r2.cloudflarestorage.com/ ", + bucket: "my-bucket", + expected: "s3:myaccount.r2.cloudflarestorage.com/my-bucket", + }, + ])("$label → $expected", ({ endpoint, bucket, expected }) => { + expect(buildRepoUrl({ backend: "r2", endpoint, bucket, accessKeyId: "test", secretAccessKey: "test" })).toBe( + expected, + ); + }); + }); + + describe("other backends", () => { + test("builds local repository URL", () => { + expect(buildRepoUrl({ backend: "local", path: "/path/to/repo" })).toBe("/path/to/repo"); + }); + }); +}); diff --git a/app/server/utils/restic/helpers/add-common-args.ts b/app/server/utils/restic/helpers/add-common-args.ts new file mode 100644 index 00000000..1fd59477 --- /dev/null +++ b/app/server/utils/restic/helpers/add-common-args.ts @@ -0,0 +1,42 @@ +import type { RepositoryConfig } from "~/schemas/restic"; +import { formatBandwidthLimit } from "./bandwidth"; +import type { ResticEnv } from "../types"; + +export const addCommonArgs = ( + args: string[], + env: ResticEnv, + config?: RepositoryConfig, + options?: { skipBandwidth?: boolean; includeJson?: boolean }, +) => { + if (options?.includeJson !== false) { + args.push("--json"); + } + + if (env._SFTP_SSH_ARGS) { + args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); + } + + if (env.AWS_S3_BUCKET_LOOKUP === "dns") { + args.push("-o", "s3.bucket-lookup=dns"); + } + + if (env._INSECURE_TLS === "true") { + args.push("--insecure-tls"); + } + + if (env.RESTIC_CACERT) { + args.push("--cacert", env.RESTIC_CACERT); + } + + if (config && !options?.skipBandwidth) { + const uploadLimit = formatBandwidthLimit(config.uploadLimit); + if (uploadLimit) { + args.push("--limit-upload", uploadLimit); + } + + const downloadLimit = formatBandwidthLimit(config.downloadLimit); + if (downloadLimit) { + args.push("--limit-download", downloadLimit); + } + } +}; diff --git a/app/server/utils/restic/helpers/bandwidth.ts b/app/server/utils/restic/helpers/bandwidth.ts new file mode 100644 index 00000000..7dbdda7f --- /dev/null +++ b/app/server/utils/restic/helpers/bandwidth.ts @@ -0,0 +1,25 @@ +import type { BandwidthLimit } from "~/schemas/restic"; + +export const formatBandwidthLimit = (limit?: BandwidthLimit): string => { + if (!limit || !limit.enabled || limit.value <= 0) { + return ""; + } + + let kibibytesPerSecond: number; + switch (limit.unit) { + case "Kbps": + kibibytesPerSecond = (limit.value * 1000) / 8 / 1024; + break; + case "Mbps": + kibibytesPerSecond = (limit.value * 1000000) / (8 * 1024); + break; + case "Gbps": + kibibytesPerSecond = (limit.value * 1000000000) / (8 * 1024); + break; + default: + return ""; + } + + const limitValue = Math.max(1, Math.floor(kibibytesPerSecond)); + return `${limitValue}`; +}; diff --git a/app/server/utils/restic/helpers/build-env.ts b/app/server/utils/restic/helpers/build-env.ts new file mode 100644 index 00000000..0396f38d --- /dev/null +++ b/app/server/utils/restic/helpers/build-env.ts @@ -0,0 +1,167 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { RepositoryConfig } from "~/schemas/restic"; +import { RESTIC_CACHE_DIR } from "~/server/core/constants"; +import { db } from "~/server/db/db"; +import { cryptoUtils } from "~/server/utils/crypto"; +import { logger } from "~/server/utils/logger"; +import type { ResticEnv } from "../types"; + +export const buildEnv = async (config: RepositoryConfig, organizationId: string): Promise => { + const env: ResticEnv = { + RESTIC_CACHE_DIR, + PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", + }; + + 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, + }); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } else { + const org = await db.query.organization.findFirst({ + where: { id: organizationId }, + }); + + if (!org) { + throw new Error(`Organization ${organizationId} not found`); + } + + const metadata = org.metadata; + if (!metadata?.resticPassword) { + throw new Error(`Restic password not configured for organization ${organizationId}`); + } + + const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword); + const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); + await fs.writeFile(passwordFilePath, decryptedPassword, { + mode: 0o600, + }); + env.RESTIC_PASSWORD_FILE = passwordFilePath; + } + + 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); + + if (config.endpoint.includes("myhuaweicloud")) { + env.AWS_S3_BUCKET_LOOKUP = "dns"; + } + 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_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, + }); + 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); + if (config.endpointSuffix) { + env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; + } + break; + } + case "rest": { + if (config.username) { + env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); + } + if (config.password) { + env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(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"); + 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 fs.writeFile(keyPath, normalizedKey, { mode: 0o600 }); + + 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 || !config.knownHosts) { + 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 fs.writeFile(knownHostsPath, config.knownHosts, { + mode: 0o600, + }); + env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; + sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); + } + + 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 cryptoUtils.resolveSecret(config.cacert); + const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); + await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); + env.RESTIC_CACERT = certPath; + } + + if (config.insecureTls) { + env._INSECURE_TLS = "true"; + } + + return env; +}; diff --git a/app/server/utils/restic/helpers/build-repo-url.ts b/app/server/utils/restic/helpers/build-repo-url.ts new file mode 100644 index 00000000..c7b4cb9b --- /dev/null +++ b/app/server/utils/restic/helpers/build-repo-url.ts @@ -0,0 +1,34 @@ +import type { RepositoryConfig } from "~/schemas/restic"; + +export const buildRepoUrl = (config: RepositoryConfig): string => { + switch (config.backend) { + case "local": + return config.path; + case "s3": { + const endpoint = config.endpoint.trim().replace(/\/$/, ""); + return `s3:${endpoint}/${config.bucket}`; + } + case "r2": { + const endpoint = config.endpoint + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + return `s3:${endpoint}/${config.bucket}`; + } + case "gcs": + return `gs:${config.bucket}:/`; + case "azure": + return `azure:${config.container}:/`; + case "rclone": + return `rclone:${config.remote}:${config.path}`; + case "rest": { + const pathSuffix = config.path ? `/${config.path}` : ""; + return `rest:${config.url}${pathSuffix}`; + } + case "sftp": + return `sftp:${config.user}@${config.host}:${config.path}`; + default: { + throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); + } + } +}; diff --git a/app/server/utils/restic/helpers/cleanup-temporary-keys.ts b/app/server/utils/restic/helpers/cleanup-temporary-keys.ts new file mode 100644 index 00000000..3f5cebf9 --- /dev/null +++ b/app/server/utils/restic/helpers/cleanup-temporary-keys.ts @@ -0,0 +1,17 @@ +import fs from "node:fs/promises"; +import { RESTIC_PASS_FILE } from "~/server/core/constants"; +import type { ResticEnv } from "../types"; + +export const cleanupTemporaryKeys = async (env: ResticEnv) => { + const keysToClean = ["_SFTP_KEY_PATH", "_SFTP_KNOWN_HOSTS_PATH", "RESTIC_CACERT", "GOOGLE_APPLICATION_CREDENTIALS"]; + + for (const key of keysToClean) { + if (env[key]) { + await fs.unlink(env[key]).catch(() => {}); + } + } + + if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) { + await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); + } +}; diff --git a/app/server/utils/restic/index.ts b/app/server/utils/restic/index.ts new file mode 100644 index 00000000..8dddb08c --- /dev/null +++ b/app/server/utils/restic/index.ts @@ -0,0 +1,42 @@ +import { backup } from "./commands/backup"; +import { check } from "./commands/check"; +import { copy } from "./commands/copy"; +import { deleteSnapshot, deleteSnapshots } from "./commands/delete-snapshots"; +import { dump } from "./commands/dump"; +import { forget } from "./commands/forget"; +import { init } from "./commands/init"; +import { keyAdd } from "./commands/key-add"; +import { ls } from "./commands/ls"; +import { repairIndex } from "./commands/repair-index"; +import { restore } from "./commands/restore"; +import { snapshots } from "./commands/snapshots"; +import { stats } from "./commands/stats"; +import { tagSnapshots } from "./commands/tag-snapshots"; +import { unlock } from "./commands/unlock"; + +export { addCommonArgs } from "./helpers/add-common-args"; +export { buildEnv } from "./helpers/build-env"; +export { buildRepoUrl } from "./helpers/build-repo-url"; +export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; + +export type { RestoreProgress } from "./commands/restore"; +export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./types"; + +export const restic = { + init, + keyAdd, + backup, + restore, + dump, + snapshots, + stats, + forget, + deleteSnapshot, + deleteSnapshots, + tagSnapshots, + unlock, + ls, + check, + repairIndex, + copy, +}; diff --git a/app/server/utils/restic/types.ts b/app/server/utils/restic/types.ts new file mode 100644 index 00000000..d9a4d26e --- /dev/null +++ b/app/server/utils/restic/types.ts @@ -0,0 +1,43 @@ +import type { Readable } from "node:stream"; +import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto"; + +export type ResticEnv = Record; + +export interface ResticDumpStream { + stream: Readable; + completion: Promise; + abort: () => void; +} + +export type ResticForgetResponse = ForgetGroup[]; + +export interface ForgetGroup { + tags: string[] | null; + host: string; + paths: string[] | null; + keep: Snapshot[]; + remove: Snapshot[] | null; + reasons: ForgetReason[]; +} + +export interface Snapshot { + time: string; + parent?: string; + tree: string; + paths: string[]; + hostname: string; + username?: string; + uid?: number; + gid?: number; + excludes?: string[] | null; + tags?: string[] | null; + program_version?: string; + summary?: ResticSnapshotSummaryDto; + id: string; + short_id: string; +} + +export interface ForgetReason { + snapshot: Snapshot; + matches: string[]; +} diff --git a/bun.lock b/bun.lock index 5294aa81..8d9af7e5 100644 --- a/bun.lock +++ b/bun.lock @@ -79,7 +79,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@total-typescript/shoehorn": "^0.1.2", - "@types/bun": "^1.3.6", + "@types/bun": "^1.3.9", "@types/content-disposition": "^0.5.9", "@types/node": "^25.3.0", "@types/react": "^19.2.14", @@ -87,7 +87,6 @@ "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^5.1.4", "babel-plugin-react-compiler": "^1.0.0", - "bun-types": "^1.3.6", "dotenv-cli": "^11.0.0", "drizzle-kit": "^1.0.0-beta.15-859cf75", "lefthook": "^2.1.1", diff --git a/package.json b/package.json index f0462938..1035b00e 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@total-typescript/shoehorn": "^0.1.2", - "@types/bun": "^1.3.6", + "@types/bun": "^1.3.9", "@types/content-disposition": "^0.5.9", "@types/node": "^25.3.0", "@types/react": "^19.2.14", @@ -108,7 +108,6 @@ "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^5.1.4", "babel-plugin-react-compiler": "^1.0.0", - "bun-types": "^1.3.6", "dotenv-cli": "^11.0.0", "drizzle-kit": "^1.0.0-beta.15-859cf75", "lefthook": "^2.1.1", diff --git a/tsconfig.json b/tsconfig.json index 5302ad0d..bfd4ee03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "include": ["**/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client", "bun-types"], + "types": ["node", "vite/client"], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",