zerobyte/packages/core/src/restic/helpers/build-env.ts
2026-05-17 15:51:37 +02:00

197 lines
6.4 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ResticDeps, ResticEnv } from "../types";
import type { RepositoryConfig } from "../schemas";
import { logger } from "../../node";
import { FILE_MODES, writeFileWithMode } from "../../node/fs.js";
const createRuntimeSecretsDir = async () => {
const runtimeSecretsDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-"));
await fs.chmod(runtimeSecretsDir, 0o700);
return runtimeSecretsDir;
};
const runtimeSecretPath = (runtimeSecretsDir: string, filename: string) => {
return path.join(runtimeSecretsDir, filename);
};
const removeRuntimeSecretsDir = async (runtimeSecretsDir: string | undefined) => {
if (runtimeSecretsDir) await fs.rm(runtimeSecretsDir, { recursive: true, force: true }).catch(() => {});
};
export const buildEnv = async (
config: RepositoryConfig,
organizationId: string,
deps: ResticDeps,
): Promise<ResticEnv> => {
const env: ResticEnv = {
RESTIC_CACHE_DIR: deps.resticCacheDir,
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
};
let runtimeSecretsDir: string | undefined;
const getRuntimeSecretPath = async (filename: string) => {
runtimeSecretsDir = await createRuntimeSecretsDir();
env._RUNTIME_SECRETS_DIR = runtimeSecretsDir;
return runtimeSecretPath(runtimeSecretsDir, filename);
};
try {
if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await deps.resolveSecret(config.customPassword);
const passwordFilePath = await getRuntimeSecretPath(
`zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`,
);
await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite);
env.RESTIC_PASSWORD_FILE = passwordFilePath;
} else {
const encryptedPassword = await deps.getOrganizationResticPassword(organizationId);
const decryptedPassword = await deps.resolveSecret(encryptedPassword);
const passwordFilePath = await getRuntimeSecretPath(
`zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`,
);
await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite);
env.RESTIC_PASSWORD_FILE = passwordFilePath;
}
switch (config.backend) {
case "s3":
env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey);
if (config.endpoint.includes("myhuaweicloud")) {
env.AWS_S3_BUCKET_LOOKUP = "dns";
}
break;
case "r2":
env.AWS_ACCESS_KEY_ID = await deps.resolveSecret(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await deps.resolveSecret(config.secretAccessKey);
env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true";
break;
case "gcs": {
const decryptedCredentials = await deps.resolveSecret(config.credentialsJson);
const credentialsPath = await getRuntimeSecretPath(
`zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`,
);
await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite);
env.GOOGLE_PROJECT_ID = config.projectId;
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
break;
}
case "azure": {
env.AZURE_ACCOUNT_NAME = config.accountName;
env.AZURE_ACCOUNT_KEY = await deps.resolveSecret(config.accountKey);
if (config.endpointSuffix) {
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
}
break;
}
case "rest": {
if (config.username) {
env.RESTIC_REST_USERNAME = await deps.resolveSecret(config.username);
}
if (config.password) {
env.RESTIC_REST_PASSWORD = await deps.resolveSecret(config.password);
}
break;
}
case "rclone":
env.RCLONE_CONFIG = deps.rcloneConfigFile;
break;
case "sftp": {
const decryptedKey = await deps.resolveSecret(config.privateKey);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) {
normalizedKey += "\n";
}
if (normalizedKey.includes("ENCRYPTED")) {
logger.error(
"SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.",
);
throw new Error(
"Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.",
);
}
const keyPath = await getRuntimeSecretPath(`zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`);
await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite);
env._SFTP_KEY_PATH = keyPath;
const sshArgs = [
"-o",
"LogLevel=ERROR",
"-o",
"BatchMode=yes",
"-o",
"NumberOfPasswordPrompts=0",
"-o",
"PreferredAuthentications=publickey",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"IdentitiesOnly=yes",
"-o",
"ConnectTimeout=10",
"-o",
"ConnectionAttempts=1",
"-o",
"ServerAliveInterval=60",
"-o",
"ServerAliveCountMax=240",
"-i",
keyPath,
];
if (config.skipHostKeyCheck) {
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {
const knownHostsPath = await getRuntimeSecretPath(
`zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`,
);
await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite);
env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath;
sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`);
} else {
sshArgs.push("-o", "StrictHostKeyChecking=yes");
}
if (config.port && config.port !== 22) {
sshArgs.push("-p", String(config.port));
}
env._SFTP_SSH_ARGS = sshArgs.join(" ");
logger.info(`SFTP: SSH args: ${env._SFTP_SSH_ARGS}`);
break;
}
}
if (config.cacert) {
const decryptedCert = await deps.resolveSecret(config.cacert);
const certPath = await getRuntimeSecretPath(`zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite);
env.RESTIC_CACERT = certPath;
}
if (config.insecureTls) {
env._INSECURE_TLS = "true";
if (config.backend === "rclone") {
env.RCLONE_NO_CHECK_CERTIFICATE = "true";
}
}
return env;
} catch (error) {
await removeRuntimeSecretsDir(runtimeSecretsDir);
throw error;
}
};