import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { consola } from "consola"; import { execa } from "execa"; import type { WPopContext } from "./context"; import type { DeployEnv } from "./env"; import { run } from "./run"; import { createTempDir } from "./tempdir"; export type SshConfig = { host: string; port: number; user: string; privateKey?: string; }; export type PreparedSsh = { host: string; port: number; user: string; identityFile?: string; knownHostsFile: string; }; export function createSshConfig(env: DeployEnv): SshConfig { return { host: env.REMOTE_HOST, port: env.REMOTE_PORT, user: env.REMOTE_USER, privateKey: env.SSH_PRIVATE_KEY, }; } export async function prepareSsh( context: WPopContext, config: SshConfig, cacheDir: string, ): Promise { const knownHostsFile = ensureKnownHostsFile(context, cacheDir); const prepared: PreparedSsh = { host: config.host, port: config.port, user: config.user, knownHostsFile, }; if (config.privateKey) { prepared.identityFile = writePrivateKey(context, config.privateKey); } await ensureKnownHost(context, prepared); await verifySshAuth(context, prepared); return prepared; } export function sshTarget(config: PreparedSsh): string { return `${config.user}@${config.host}`; } export function sshArgs(config: PreparedSsh): string[] { const args = [ "-T", "-p", String(config.port), "-o", `UserKnownHostsFile=${config.knownHostsFile}`, "-o", "GlobalKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", ]; if (config.identityFile) { args.push("-i", config.identityFile); } return args; } export function rsyncSshShell(config: PreparedSsh): string { return ["ssh", ...sshArgs(config)].join(" "); } function ensureKnownHostsFile(context: WPopContext, cacheDir: string): string { const path = join(cacheDir, "known_hosts"); if (context.dryRun) { return path; } mkdirSync(cacheDir, { recursive: true }); if (!existsSync(path)) { writeFileSync(path, "", { mode: 0o600 }); } return path; } function writePrivateKey(context: WPopContext, privateKey: string): string { consola.info("Using SSH private key from SSH_PRIVATE_KEY"); const { path: keyDir, cleanup } = createTempDir(context, "wpop-ssh"); if (context.dryRun) { return join(keyDir, "id_ed25519"); } const keyPath = join(keyDir, "id_ed25519"); writeFileSync(keyPath, privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, { mode: 0o600, }); process.once("exit", cleanup); return keyPath; } async function ensureKnownHost(context: WPopContext, config: PreparedSsh): Promise { if (context.dryRun) { consola.info(`Ensuring SSH host key for ${config.host} is known`); await run(context, "ssh-keyscan", ["-p", String(config.port), "-H", config.host]); return; } if (await hostInKnownHosts(config)) { consola.debug(`Host ${config.host}:${config.port} already in known_hosts`); return; } consola.info(`Scanning SSH host key for ${config.host}`); const { stdout } = await execa("ssh-keyscan", ["-p", String(config.port), "-H", config.host]); appendFileSync(config.knownHostsFile, `${stdout}\n`); } async function hostInKnownHosts(config: PreparedSsh): Promise { if (!existsSync(config.knownHostsFile)) { return false; } const lookup = config.port === 22 ? config.host : `[${config.host}]:${config.port}`; const result = await execa("ssh-keygen", ["-F", lookup, "-f", config.knownHostsFile], { reject: false, }); return result.exitCode === 0; } async function verifySshAuth(context: WPopContext, config: PreparedSsh): Promise { consola.info(`Verifying SSH access to ${sshTarget(config)}`); await run(context, "ssh", [...sshArgs(config), "-o", "BatchMode=yes", sshTarget(config), "true"]); }