mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-05-19 06:03:01 -04:00
212 lines
6.5 KiB
TypeScript
212 lines
6.5 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../../../core/constants";
|
|
import { cryptoUtils } from "../../../utils/crypto";
|
|
import { toMessage } from "../../../utils/errors";
|
|
import { logger } from "@zerobyte/core/node";
|
|
import { FILE_MODES, writeFileWithMode } from "@zerobyte/core/utils";
|
|
import { getMountForPath } from "../../../utils/mountinfo";
|
|
import { withTimeout } from "../../../utils/timeout";
|
|
import type { VolumeBackend } from "../backend";
|
|
import { executeUnmount } from "../utils/backend-utils";
|
|
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
|
|
|
const getPrivateKeyPath = (mountPath: string) => {
|
|
const name = path.basename(mountPath);
|
|
return path.join(SSH_KEYS_DIR, `${name}.key`);
|
|
};
|
|
|
|
const getKnownHostsPath = (mountPath: string) => {
|
|
const name = path.basename(mountPath);
|
|
return path.join(SSH_KEYS_DIR, `${name}.known_hosts`);
|
|
};
|
|
|
|
const mount = async (config: BackendConfig, mountPath: string) => {
|
|
logger.debug(`Mounting SFTP volume ${mountPath}...`);
|
|
|
|
if (config.backend !== "sftp") {
|
|
logger.error("Provided config is not for SFTP backend");
|
|
return { status: BACKEND_STATUS.error, error: "Provided config is not for SFTP backend" };
|
|
}
|
|
|
|
if (os.platform() !== "linux") {
|
|
logger.error("SFTP mounting is only supported on Linux hosts.");
|
|
return { status: BACKEND_STATUS.error, error: "SFTP mounting is only supported on Linux hosts." };
|
|
}
|
|
|
|
const { status } = await checkHealth(mountPath);
|
|
if (status === "mounted") {
|
|
return { status: BACKEND_STATUS.mounted };
|
|
}
|
|
|
|
if (status === "error") {
|
|
logger.debug(`Trying to unmount any existing mounts at ${mountPath} before mounting...`);
|
|
await unmount(mountPath);
|
|
}
|
|
|
|
const run = async () => {
|
|
await fs.mkdir(mountPath, { recursive: true });
|
|
await fs.mkdir(SSH_KEYS_DIR, { recursive: true });
|
|
|
|
const { uid, gid } = os.userInfo();
|
|
const options = [
|
|
"reconnect",
|
|
"ServerAliveInterval=15",
|
|
"ServerAliveCountMax=3",
|
|
"allow_other",
|
|
`uid=${uid}`,
|
|
`gid=${gid}`,
|
|
];
|
|
|
|
if (config.skipHostKeyCheck || !config.knownHosts) {
|
|
options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null");
|
|
} else if (config.knownHosts) {
|
|
const knownHostsPath = getKnownHostsPath(mountPath);
|
|
await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite);
|
|
options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes");
|
|
}
|
|
|
|
if (config.readOnly) {
|
|
options.push("ro");
|
|
}
|
|
|
|
if (config.port) {
|
|
options.push(`port=${config.port}`);
|
|
}
|
|
|
|
const keyPath = getPrivateKeyPath(mountPath);
|
|
if (config.privateKey) {
|
|
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
|
|
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
|
|
if (!normalizedKey.endsWith("\n")) {
|
|
normalizedKey += "\n";
|
|
}
|
|
await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite);
|
|
options.push(`IdentityFile=${keyPath}`);
|
|
}
|
|
|
|
const source = `${config.username}@${config.host}:${config.path || ""}`;
|
|
const args = [source, mountPath, "-o", options.join(",")];
|
|
|
|
logger.debug(`Mounting SFTP volume ${mountPath}...`);
|
|
|
|
const runSshfs = async (mountArgs: string[], password?: string) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const child = spawn("sshfs", mountArgs, { stdio: ["pipe", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
|
|
child.stdout.on("data", (data) => {
|
|
stdout += data;
|
|
});
|
|
|
|
child.stderr.on("data", (data) => {
|
|
stderr += data;
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
reject(new Error(`Failed to start sshfs: ${error.message}`));
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const errorMsg = stderr.trim() || stdout.trim() || "Unknown error";
|
|
reject(new Error(`Failed to mount SFTP volume: ${errorMsg}`));
|
|
});
|
|
|
|
if (password) {
|
|
child.stdin.write(password);
|
|
}
|
|
child.stdin.end();
|
|
});
|
|
};
|
|
|
|
if (config.password) {
|
|
const password = await cryptoUtils.resolveSecret(config.password);
|
|
args.push("-o", "password_stdin");
|
|
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
|
await runSshfs(args, password);
|
|
} else {
|
|
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
|
|
await runSshfs(args);
|
|
}
|
|
|
|
logger.info(`SFTP volume at ${mountPath} mounted successfully.`);
|
|
return { status: BACKEND_STATUS.mounted };
|
|
};
|
|
|
|
try {
|
|
return await withTimeout(run(), OPERATION_TIMEOUT * 2, "SFTP mount");
|
|
} catch (error) {
|
|
const errorMsg = toMessage(error);
|
|
logger.error("Error mounting SFTP volume", { error: errorMsg });
|
|
return { status: BACKEND_STATUS.error, error: errorMsg };
|
|
}
|
|
};
|
|
|
|
const unmount = async (mountPath: string) => {
|
|
if (os.platform() !== "linux") {
|
|
logger.error("SFTP unmounting is only supported on Linux hosts.");
|
|
return { status: BACKEND_STATUS.error, error: "SFTP unmounting is only supported on Linux hosts." };
|
|
}
|
|
|
|
const run = async () => {
|
|
const mount = await getMountForPath(mountPath);
|
|
if (!mount || mount.mountPoint !== mountPath) {
|
|
logger.debug(`Path ${mountPath} is not a mount point. Skipping unmount.`);
|
|
} else {
|
|
await executeUnmount(mountPath);
|
|
}
|
|
|
|
const keyPath = getPrivateKeyPath(mountPath);
|
|
await fs.unlink(keyPath).catch(() => {});
|
|
|
|
const knownHostsPath = getKnownHostsPath(mountPath);
|
|
await fs.unlink(knownHostsPath).catch(() => {});
|
|
|
|
await fs.rmdir(mountPath).catch(() => {});
|
|
|
|
logger.info(`SFTP volume at ${mountPath} unmounted successfully.`);
|
|
return { status: BACKEND_STATUS.unmounted };
|
|
};
|
|
|
|
try {
|
|
return await withTimeout(run(), OPERATION_TIMEOUT, "SFTP unmount");
|
|
} catch (error) {
|
|
logger.error("Error unmounting SFTP volume", { mountPath, error: toMessage(error) });
|
|
return { status: BACKEND_STATUS.error, error: toMessage(error) };
|
|
}
|
|
};
|
|
|
|
const checkHealth = async (mountPath: string) => {
|
|
const mount = await getMountForPath(mountPath);
|
|
|
|
if (!mount || mount.mountPoint !== mountPath) {
|
|
return { status: BACKEND_STATUS.unmounted };
|
|
}
|
|
|
|
if (mount.fstype !== "fuse.sshfs") {
|
|
return {
|
|
status: BACKEND_STATUS.error,
|
|
error: `Invalid filesystem type: ${mount.fstype} (expected fuse.sshfs)`,
|
|
};
|
|
}
|
|
|
|
return { status: BACKEND_STATUS.mounted };
|
|
};
|
|
|
|
export const makeSftpBackend = (config: BackendConfig, mountPath: string): VolumeBackend => ({
|
|
mount: () => mount(config, mountPath),
|
|
unmount: () => unmount(mountPath),
|
|
checkHealth: () => checkHealth(mountPath),
|
|
});
|