2023-01-26 19:24:29 +01:00
|
|
|
|
|
|
|
import 'zx/globals';
|
|
|
|
|
2023-04-23 10:32:27 +02:00
|
|
|
import { spawn, ChildProcess } from 'child_process'
|
2023-01-26 19:24:29 +01:00
|
|
|
import { Socket } from 'node:net'
|
|
|
|
|
2023-04-23 10:32:27 +02:00
|
|
|
async function promiseWithTimeout<T>(timeoutMs: number, promise: Promise<T>, failureMessage?: string) {
|
|
|
|
let timeoutHandle: NodeJS.Timeout | null = null
|
|
|
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
|
|
timeoutHandle = setTimeout(() => reject(new Error(failureMessage)), timeoutMs)
|
|
|
|
})
|
|
|
|
const result = await Promise.race([promise, timeoutPromise])
|
|
|
|
if (timeoutHandle) clearTimeout(timeoutHandle)
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
const to_kill: ProcessPromise[] = []
|
|
|
|
|
|
|
|
// sad that I can't chain these
|
|
|
|
process.on("uncaughtException", err => {
|
|
|
|
console.error("uncaught exception", err)
|
|
|
|
console.log("killing", to_kill.length, "child processes")
|
|
|
|
for (const k of to_kill) if (k.child?.pid) process.kill(k.child.pid)
|
|
|
|
to_kill.length = 0
|
|
|
|
process.exit(1)
|
|
|
|
})
|
|
|
|
|
2023-01-26 19:24:29 +01:00
|
|
|
export function ssh(host: string): Promise<SSH>
|
|
|
|
export function ssh<R>(host: string, cb: () => Promise<R>): Promise<R>
|
|
|
|
export async function ssh<R>(host: string, cb?: () => Promise<R>) {
|
|
|
|
const ret = new SSH(host)
|
|
|
|
try {
|
|
|
|
await ($`ssh ${host} -O check`).quiet()
|
|
|
|
} catch (p) {
|
|
|
|
if (p instanceof ProcessOutput && p.exitCode == 255) {
|
|
|
|
console.log("Spawning ssh master")
|
2023-04-23 10:32:27 +02:00
|
|
|
const x = $`ssh ${host} -M -N -o Compression=no -o PermitLocalCommand=yes -o LocalCommand="echo connected"`
|
|
|
|
to_kill.push(x)
|
|
|
|
await promiseWithTimeout(60000, new Promise<void>((resolve, _reject) => {
|
|
|
|
x.stdout.on('data', (d: Buffer) => {
|
|
|
|
if (d.toString('utf8').trim() == "connected")
|
|
|
|
resolve()
|
|
|
|
})
|
|
|
|
}))
|
2023-01-26 19:24:29 +01:00
|
|
|
if (x.child) {
|
|
|
|
x.child.unref()
|
|
|
|
if (x.child.stderr instanceof Socket) x.child.stderr.unref()
|
|
|
|
if (x.child.stdout instanceof Socket) x.child.stdout.unref()
|
|
|
|
ret.child = x.child
|
|
|
|
process.on('beforeExit', () => {
|
|
|
|
ret.stop()
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
console.warn("Failed to spawn SSH master, but someone else did")
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
throw p
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (cb !== undefined) return ret.within(cb)
|
|
|
|
else return ret
|
|
|
|
}
|
|
|
|
|
|
|
|
export class SSH {
|
|
|
|
child?: ChildProcess | null;
|
2023-04-23 09:43:11 +02:00
|
|
|
constructor(public host: string) { }
|
2023-01-26 19:24:29 +01:00
|
|
|
within<R>(cb: () => Promise<R>): Promise<R> {
|
|
|
|
return within(async () => {
|
2023-04-23 10:32:27 +02:00
|
|
|
// todo: the default options.shell is set to local which.sync('bash')
|
|
|
|
// which doesn't neccesarily work on the remote
|
|
|
|
$.shell = "bash"
|
2023-01-26 19:24:29 +01:00
|
|
|
$.spawn = (command: string, options: any): any => {
|
|
|
|
const stdio = ["pipe", options.stdio[1], options.stdio[2]]
|
|
|
|
const proc: ChildProcess = spawn("ssh", [this.host, options.shell],
|
|
|
|
Object.assign({}, options, {shell: false, stdio}))
|
|
|
|
// todo: type safety
|
|
|
|
if (!proc.stdin) throw new Error("Failed to spawn input pipe")
|
|
|
|
proc.stdin.write(command + "; exit $?\n")
|
|
|
|
if (options.stdio[0] == 'inherit') process.stdin.pipe(proc.stdin)
|
|
|
|
return proc
|
|
|
|
}
|
|
|
|
$.log = (entry) => {
|
|
|
|
switch(entry.kind) {
|
|
|
|
case 'cmd':
|
|
|
|
if (entry.verbose) process.stderr.write(`[${this.host}] `)
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
log(entry)
|
|
|
|
}
|
|
|
|
return cb()
|
|
|
|
})
|
|
|
|
}
|
|
|
|
interactive() {
|
|
|
|
$`ssh ${this.host}`
|
|
|
|
}
|
|
|
|
stop() {
|
|
|
|
if (this.child) {
|
|
|
|
$`ssh ${this.host} -O stop`
|
|
|
|
this.child = null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|