153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
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<PreparedSsh> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
consola.info(`Verifying SSH access to ${sshTarget(config)}`);
|
|
|
|
await run(context, "ssh", [...sshArgs(config), "-o", "BatchMode=yes", sshTarget(config), "true"]);
|
|
}
|