Simplifies bandwidth limit configuration for rclone source backends by considering only the source download limit. Ensures correct type casting when comparing upload and download limits, using base 10 for parsing. Makes the temporary keys cleanup function available for external use.
1020 lines
28 KiB
TypeScript
1020 lines
28 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { throttle } from "es-toolkit";
|
|
import { type } from "arktype";
|
|
import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
|
|
import { config as appConfig } from "../core/config";
|
|
import { logger } from "./logger";
|
|
import { cryptoUtils } from "./crypto";
|
|
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
|
import { safeSpawn } from "./spawn";
|
|
import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic";
|
|
import { ResticError } from "./errors";
|
|
|
|
const backupOutputSchema = type({
|
|
message_type: "'summary'",
|
|
files_new: "number",
|
|
files_changed: "number",
|
|
files_unmodified: "number",
|
|
dirs_new: "number",
|
|
dirs_changed: "number",
|
|
dirs_unmodified: "number",
|
|
data_blobs: "number",
|
|
tree_blobs: "number",
|
|
data_added: "number",
|
|
total_files_processed: "number",
|
|
total_bytes_processed: "number",
|
|
total_duration: "number",
|
|
snapshot_id: "string",
|
|
});
|
|
export type BackupOutput = typeof backupOutputSchema.infer;
|
|
|
|
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: type({
|
|
backup_end: "string",
|
|
backup_start: "string",
|
|
data_added: "number",
|
|
data_added_packed: "number",
|
|
data_blobs: "number",
|
|
dirs_changed: "number",
|
|
dirs_new: "number",
|
|
dirs_unmodified: "number",
|
|
files_changed: "number",
|
|
files_new: "number",
|
|
files_unmodified: "number",
|
|
total_bytes_processed: "number",
|
|
total_files_processed: "number",
|
|
tree_blobs: "number",
|
|
}).optional(),
|
|
});
|
|
|
|
const ensurePassfile = async () => {
|
|
await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true });
|
|
|
|
try {
|
|
await fs.access(RESTIC_PASS_FILE);
|
|
} catch {
|
|
logger.info("Restic passfile not found, creating a new one...");
|
|
await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
}
|
|
};
|
|
|
|
export const buildRepoUrl = (config: RepositoryConfig): string => {
|
|
switch (config.backend) {
|
|
case "local":
|
|
if (config.isExistingRepository) {
|
|
if (!config.path) throw new Error("Path is required for existing local repositories");
|
|
return config.path;
|
|
}
|
|
|
|
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
|
|
case "s3":
|
|
return `s3:${config.endpoint}/${config.bucket}`;
|
|
case "r2": {
|
|
const endpoint = config.endpoint.replace(/^https?:\/\//, "");
|
|
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) => {
|
|
const env: Record<string, string> = {
|
|
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 {
|
|
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
|
}
|
|
|
|
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);
|
|
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=VERBOSE",
|
|
"-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 init = async (config: RepositoryConfig) => {
|
|
await ensurePassfile();
|
|
|
|
const repoUrl = buildRepoUrl(config);
|
|
|
|
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
|
|
|
const env = await buildEnv(config);
|
|
|
|
const args = ["init", "--repo", repoUrl];
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
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}`);
|
|
return { success: true, error: null };
|
|
};
|
|
|
|
const backupProgressSchema = type({
|
|
message_type: "'status'",
|
|
seconds_elapsed: "number",
|
|
percent_done: "number",
|
|
total_files: "number",
|
|
files_done: "number",
|
|
total_bytes: "number",
|
|
bytes_done: "number",
|
|
current_files: "string[]",
|
|
});
|
|
|
|
export type BackupProgress = typeof backupProgressSchema.infer;
|
|
|
|
const backup = async (
|
|
config: RepositoryConfig,
|
|
source: string,
|
|
options?: {
|
|
exclude?: string[];
|
|
excludeIfPresent?: string[];
|
|
include?: string[];
|
|
tags?: string[];
|
|
oneFileSystem?: boolean;
|
|
compressionMode?: CompressionMode;
|
|
signal?: AbortSignal;
|
|
onProgress?: (progress: BackupProgress) => void;
|
|
},
|
|
) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
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 = backupProgressSchema(jsonData);
|
|
if (!(progress instanceof type.errors)) {
|
|
options.onProgress(progress);
|
|
}
|
|
} catch (_) {
|
|
// Ignore JSON parse errors for non-JSON lines
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
let stdout = "";
|
|
|
|
logger.debug(`Executing: restic ${args.join(" ")}`);
|
|
const res = await safeSpawn({
|
|
command: "restic",
|
|
args,
|
|
env,
|
|
signal: options?.signal,
|
|
onStdout: (data) => {
|
|
stdout = data;
|
|
logData(data);
|
|
|
|
if (options?.onProgress) {
|
|
streamProgress(data);
|
|
}
|
|
},
|
|
finally: async () => {
|
|
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.stderr}`);
|
|
}
|
|
|
|
if (res.exitCode !== 0 && res.exitCode !== 3) {
|
|
logger.error(`Restic backup failed: ${res.stderr}`);
|
|
logger.error(`Command executed: restic ${args.join(" ")}`);
|
|
|
|
throw new ResticError(res.exitCode, res.stderr);
|
|
}
|
|
|
|
const lastLine = (stdout || res.stdout).trim();
|
|
let summaryLine = "";
|
|
try {
|
|
const resSummary = JSON.parse(lastLine ?? "{}");
|
|
summaryLine = resSummary;
|
|
} catch (_) {
|
|
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
|
|
summaryLine = "{}";
|
|
}
|
|
|
|
const result = backupOutputSchema(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 restoreOutputSchema = type({
|
|
message_type: "'summary'",
|
|
total_files: "number?",
|
|
files_restored: "number",
|
|
files_skipped: "number",
|
|
total_bytes: "number?",
|
|
bytes_restored: "number?",
|
|
bytes_skipped: "number",
|
|
});
|
|
|
|
const restore = async (
|
|
config: RepositoryConfig,
|
|
snapshotId: string,
|
|
target: string,
|
|
options?: {
|
|
include?: string[];
|
|
exclude?: string[];
|
|
excludeXattr?: string[];
|
|
delete?: boolean;
|
|
overwrite?: OverwriteMode;
|
|
},
|
|
) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
|
|
|
if (options?.overwrite) {
|
|
args.push("--overwrite", options.overwrite);
|
|
}
|
|
|
|
if (options?.delete) {
|
|
args.push("--delete");
|
|
}
|
|
|
|
if (options?.include?.length) {
|
|
for (const pattern of options.include) {
|
|
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);
|
|
|
|
logger.debug(`Executing: restic ${args.join(" ")}`);
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
|
|
await cleanupTemporaryKeys(env);
|
|
|
|
if (res.exitCode !== 0) {
|
|
logger.error(`Restic restore failed: ${res.stderr}`);
|
|
throw new ResticError(res.exitCode, res.stderr);
|
|
}
|
|
|
|
const outputLines = res.stdout.trim().split("\n");
|
|
const lastLine = outputLines[outputLines.length - 1];
|
|
|
|
if (!lastLine) {
|
|
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
|
return {
|
|
message_type: "summary" as const,
|
|
total_files: 0,
|
|
files_restored: 0,
|
|
files_skipped: 0,
|
|
bytes_skipped: 0,
|
|
};
|
|
}
|
|
|
|
logger.debug(`Restic restore output last line: ${lastLine}`);
|
|
const resSummary = JSON.parse(lastLine);
|
|
const result = restoreOutputSchema(resSummary);
|
|
|
|
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}`);
|
|
return {
|
|
message_type: "summary" as const,
|
|
total_files: 0,
|
|
files_restored: 0,
|
|
files_skipped: 0,
|
|
bytes_skipped: 0,
|
|
};
|
|
}
|
|
|
|
logger.info(
|
|
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`,
|
|
);
|
|
|
|
return result;
|
|
};
|
|
|
|
const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
|
|
const { tags } = options;
|
|
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
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 safeSpawn({ 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 forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
|
|
|
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);
|
|
}
|
|
|
|
args.push("--prune");
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ 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);
|
|
}
|
|
|
|
return { success: true };
|
|
};
|
|
|
|
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
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 safeSpawn({ 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) => {
|
|
return deleteSnapshots(config, [snapshotId]);
|
|
};
|
|
|
|
const tagSnapshots = async (
|
|
config: RepositoryConfig,
|
|
snapshotIds: string[],
|
|
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
|
) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
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 safeSpawn({ 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, path?: string) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
|
|
|
if (path) {
|
|
args.push(path);
|
|
}
|
|
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
await cleanupTemporaryKeys(env);
|
|
|
|
if (res.exitCode !== 0) {
|
|
logger.error(`Restic ls failed: ${res.stderr}`);
|
|
throw new ResticError(res.exitCode, res.stderr);
|
|
}
|
|
|
|
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
|
|
const stdout = res.stdout;
|
|
const lines = stdout
|
|
.trim()
|
|
.split("\n")
|
|
.filter((line) => line.trim());
|
|
|
|
if (lines.length === 0) {
|
|
return { snapshot: null, nodes: [] };
|
|
}
|
|
|
|
// First line is snapshot info
|
|
const snapshotLine = JSON.parse(lines[0] ?? "{}");
|
|
const snapshot = lsSnapshotInfoSchema(snapshotLine);
|
|
|
|
if (snapshot instanceof type.errors) {
|
|
logger.error(`Restic ls snapshot info validation failed: ${snapshot.summary}`);
|
|
throw new Error(`Restic ls snapshot info validation failed: ${snapshot.summary}`);
|
|
}
|
|
|
|
const nodes: Array<typeof lsNodeSchema.infer> = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const nodeLine = JSON.parse(lines[i] ?? "{}");
|
|
const nodeValidation = lsNodeSchema(nodeLine);
|
|
|
|
if (nodeValidation instanceof type.errors) {
|
|
logger.warn(`Skipping invalid node: ${nodeValidation.summary}`);
|
|
continue;
|
|
}
|
|
|
|
nodes.push(nodeValidation);
|
|
}
|
|
|
|
return { snapshot, nodes };
|
|
};
|
|
|
|
const unlock = async (config: RepositoryConfig) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
await cleanupTemporaryKeys(env);
|
|
|
|
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 }) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args: string[] = ["--repo", repoUrl, "check"];
|
|
|
|
if (options?.readData) {
|
|
args.push("--read-data");
|
|
}
|
|
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
await cleanupTemporaryKeys(env);
|
|
|
|
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) => {
|
|
const repoUrl = buildRepoUrl(config);
|
|
const env = await buildEnv(config);
|
|
|
|
const args = ["repair", "index", "--repo", repoUrl];
|
|
addCommonArgs(args, env, config);
|
|
|
|
const res = await safeSpawn({ command: "restic", args, env });
|
|
await cleanupTemporaryKeys(env);
|
|
|
|
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: {
|
|
tag?: string;
|
|
snapshotId?: string;
|
|
},
|
|
) => {
|
|
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
|
const destRepoUrl = buildRepoUrl(destConfig);
|
|
|
|
const sourceEnv = await buildEnv(sourceConfig);
|
|
const destEnv = await buildEnv(destConfig);
|
|
|
|
const env: Record<string, string> = {
|
|
...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");
|
|
}
|
|
|
|
// Apply common args without automatic bandwidth limit injection to prevent duplication
|
|
addCommonArgs(args, env, destConfig, { skipBandwidth: true });
|
|
|
|
// Manually handle bandwidth limits with correct copy semantics:
|
|
// --limit-download uses sourceConfig.downloadLimit (limiting downloads from source repo)
|
|
// --limit-upload uses destConfig.uploadLimit (limiting uploads to destination repo)
|
|
const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit);
|
|
const destUploadLimit = formatBandwidthLimit(destConfig.uploadLimit);
|
|
|
|
if (sourceConfig.backend === "rclone") {
|
|
// For rclone source backends, use rclone.from.bwlimit with source download limit only
|
|
let effectiveSourceLimit = "";
|
|
if (sourceDownloadLimit) {
|
|
effectiveSourceLimit = sourceDownloadLimit;
|
|
}
|
|
|
|
if (effectiveSourceLimit) {
|
|
args.push("-o", `rclone.from.bwlimit=${effectiveSourceLimit}`);
|
|
}
|
|
} else {
|
|
// For restic source backends, apply download limit from source
|
|
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 safeSpawn({ 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,
|
|
};
|
|
};
|
|
|
|
// Helper function to convert bandwidth limit to restic/rclone format
|
|
const formatBandwidthLimit = (limit?: BandwidthLimit): string => {
|
|
if (!limit || !limit.enabled || limit.value <= 0) {
|
|
return "";
|
|
}
|
|
|
|
// Convert to KiB/s for restic compatibility, or use suffixed format for rclone
|
|
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 "";
|
|
}
|
|
|
|
// Return as integer KiB/s for restic
|
|
return `${Math.floor(kibibytesPerSecond)}`;
|
|
};
|
|
|
|
export const addCommonArgs = (args: string[], env: Record<string, string>, config?: RepositoryConfig, options?: { skipBandwidth?: boolean }) => {
|
|
args.push("--json");
|
|
|
|
if (env._SFTP_SSH_ARGS) {
|
|
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
|
|
}
|
|
|
|
if (env._INSECURE_TLS === "true") {
|
|
args.push("--insecure-tls");
|
|
}
|
|
|
|
if (env.RESTIC_CACERT) {
|
|
args.push("--cacert", env.RESTIC_CACERT);
|
|
}
|
|
|
|
// Add bandwidth limits if configuration is provided and not skipped
|
|
if (config && !options?.skipBandwidth) {
|
|
if (config.backend === "rclone") {
|
|
// For rclone backends, consolidate both upload and download limits into one bwlimit
|
|
const uploadLimit = formatBandwidthLimit(config.uploadLimit);
|
|
const downloadLimit = formatBandwidthLimit(config.downloadLimit);
|
|
|
|
// Determine effective limit (choose more restrictive when both exist)
|
|
let effectiveLimit = "";
|
|
if (uploadLimit && downloadLimit) {
|
|
effectiveLimit = parseInt(uploadLimit, 10) < parseInt(downloadLimit, 10) ? uploadLimit : downloadLimit;
|
|
} else if (uploadLimit) {
|
|
effectiveLimit = uploadLimit;
|
|
} else if (downloadLimit) {
|
|
effectiveLimit = downloadLimit;
|
|
}
|
|
|
|
if (effectiveLimit) {
|
|
args.push("-o", `rclone.bwlimit=${effectiveLimit}`);
|
|
}
|
|
} else {
|
|
// For restic backends, handle upload and download limits separately
|
|
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 = {
|
|
ensurePassfile,
|
|
init,
|
|
backup,
|
|
restore,
|
|
snapshots,
|
|
forget,
|
|
deleteSnapshot,
|
|
deleteSnapshots,
|
|
tagSnapshots,
|
|
unlock,
|
|
ls,
|
|
check,
|
|
repairIndex,
|
|
copy,
|
|
};
|
|
|
|
// Helper function to clean up temporary files
|
|
export const cleanupTemporaryKeys = async (env: Record<string, string>) => {
|
|
const keysToClean = ["_SFTP_KEY_PATH", "_SFTP_KNOWN_HOSTS_PATH", "RESTIC_CACERT", "GOOGLE_APPLICATION_CREDENTIALS"];
|
|
|
|
for (const key of keysToClean) {
|
|
if (env[key]) {
|
|
try {
|
|
await fs.unlink(env[key]);
|
|
} catch (_error) {
|
|
// Ignore errors when cleaning up temporary files
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up custom password files
|
|
if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) {
|
|
try {
|
|
await fs.unlink(env.RESTIC_PASSWORD_FILE);
|
|
} catch (_error) {
|
|
// Ignore errors when cleaning up temporary files
|
|
}
|
|
}
|
|
};
|