fix(restic): clean temp secrets dir on env setup failure
This commit is contained in:
parent
3b1b3ff5fe
commit
0fa4e05ba0
2 changed files with 193 additions and 153 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { buildEnv } from "../build-env";
|
||||
import type { ResticDeps } from "../../types";
|
||||
|
|
@ -36,6 +37,10 @@ const buildEnvForTest = async (
|
|||
return env;
|
||||
};
|
||||
|
||||
const listResticTempDirs = async () => {
|
||||
return new Set((await fs.readdir(os.tmpdir())).filter((name) => name.startsWith("zerobyte-restic-")));
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
tempDirs.clear();
|
||||
|
|
@ -107,10 +112,39 @@ describe("buildEnv", () => {
|
|||
throw new Error("Organization non-existent-org not found");
|
||||
},
|
||||
});
|
||||
const before = await listResticTempDirs();
|
||||
|
||||
await expect(
|
||||
buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps),
|
||||
).rejects.toThrow("Organization non-existent-org not found");
|
||||
|
||||
expect(await listResticTempDirs()).toEqual(before);
|
||||
});
|
||||
|
||||
test("cleans the temp directory when setup fails after creating one", async () => {
|
||||
const before = await listResticTempDirs();
|
||||
const deps = makeDeps({
|
||||
resolveSecret: async (secret) => {
|
||||
if (secret === "my-access-key") throw new Error("failed to resolve access key");
|
||||
return secret;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildEnv(
|
||||
withCustomPassword({
|
||||
backend: "s3" as const,
|
||||
endpoint: "https://s3.amazonaws.com",
|
||||
bucket: "my-bucket",
|
||||
accessKeyId: "my-access-key",
|
||||
secretAccessKey: "my-secret-key",
|
||||
}),
|
||||
"org-1",
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("failed to resolve access key");
|
||||
|
||||
expect(await listResticTempDirs()).toEqual(before);
|
||||
});
|
||||
|
||||
test("throws when getOrganizationResticPassword throws (no password configured)", async () => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ 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,
|
||||
|
|
@ -26,166 +30,168 @@ export const buildEnv = async (
|
|||
RESTIC_CACHE_DIR: deps.resticCacheDir,
|
||||
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||
};
|
||||
const runtimeSecretsDir = await createRuntimeSecretsDir();
|
||||
env._RUNTIME_SECRETS_DIR = runtimeSecretsDir;
|
||||
let runtimeSecretsDir: string | undefined;
|
||||
const getRuntimeSecretPath = async (filename: string) => {
|
||||
runtimeSecretsDir = await createRuntimeSecretsDir();
|
||||
env._RUNTIME_SECRETS_DIR = runtimeSecretsDir;
|
||||
return runtimeSecretPath(runtimeSecretsDir, filename);
|
||||
};
|
||||
|
||||
if (config.isExistingRepository && config.customPassword) {
|
||||
const decryptedPassword = await deps.resolveSecret(config.customPassword);
|
||||
const passwordFilePath = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`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 = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`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 = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`,
|
||||
);
|
||||
await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite);
|
||||
env.GOOGLE_PROJECT_ID = config.projectId;
|
||||
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
||||
break;
|
||||
}
|
||||
case "azure": {
|
||||
env.AZURE_ACCOUNT_NAME = config.accountName;
|
||||
env.AZURE_ACCOUNT_KEY = await deps.resolveSecret(config.accountKey);
|
||||
if (config.endpointSuffix) {
|
||||
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rest": {
|
||||
if (config.username) {
|
||||
env.RESTIC_REST_USERNAME = await deps.resolveSecret(config.username);
|
||||
}
|
||||
if (config.password) {
|
||||
env.RESTIC_REST_PASSWORD = await deps.resolveSecret(config.password);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rclone":
|
||||
env.RCLONE_CONFIG = deps.rcloneConfigFile;
|
||||
break;
|
||||
case "sftp": {
|
||||
const decryptedKey = await deps.resolveSecret(config.privateKey);
|
||||
const keyPath = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`,
|
||||
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`,
|
||||
);
|
||||
|
||||
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
||||
if (!normalizedKey.endsWith("\n")) {
|
||||
normalizedKey += "\n";
|
||||
}
|
||||
|
||||
if (normalizedKey.includes("ENCRYPTED")) {
|
||||
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.");
|
||||
throw new Error(
|
||||
"Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.",
|
||||
);
|
||||
}
|
||||
|
||||
await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite);
|
||||
|
||||
env._SFTP_KEY_PATH = keyPath;
|
||||
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"NumberOfPasswordPrompts=0",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"KbdInteractiveAuthentication=no",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ConnectionAttempts=1",
|
||||
"-o",
|
||||
"ServerAliveInterval=60",
|
||||
"-o",
|
||||
"ServerAliveCountMax=240",
|
||||
"-i",
|
||||
keyPath,
|
||||
];
|
||||
|
||||
if (config.skipHostKeyCheck) {
|
||||
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
|
||||
} else if (config.knownHosts) {
|
||||
const knownHostsPath = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.cacert) {
|
||||
const decryptedCert = await deps.resolveSecret(config.cacert);
|
||||
const certPath = runtimeSecretPath(
|
||||
runtimeSecretsDir,
|
||||
`zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`,
|
||||
);
|
||||
await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite);
|
||||
env.RESTIC_CACERT = certPath;
|
||||
}
|
||||
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.insecureTls) {
|
||||
env._INSECURE_TLS = "true";
|
||||
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);
|
||||
|
||||
if (config.backend === "rclone") {
|
||||
env.RCLONE_NO_CHECK_CERTIFICATE = "true";
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue