refactor: move tunnel and respond into core
This commit is contained in:
1 parent
c1c0e3383b
commit
dd99f4a47b
8 files changed
+9
-6
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { Client, type ConnectConfig } from 'ssh2'
|
||||
import type { SshConfig } from '../config/baseSchema.js'
|
||||
|
||||
export type Tunnel = {
|
||||
localHost: string
|
||||
localPort: number
|
||||
isClosed: () => boolean
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
export const expandHome = (path: string): string => {
|
||||
if (path === '~' || path.startsWith('~/')) {
|
||||
return homedir() + path.slice(1)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export const buildSshAuth = (
|
||||
ssh: SshConfig,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Partial<ConnectConfig> => {
|
||||
if (ssh.privateKey) {
|
||||
return { privateKey: ssh.privateKey, passphrase: ssh.passphrase }
|
||||
}
|
||||
if (ssh.privateKeyPath) {
|
||||
return {
|
||||
privateKey: readFileSync(expandHome(ssh.privateKeyPath), 'utf8'),
|
||||
passphrase: ssh.passphrase
|
||||
}
|
||||
}
|
||||
if (ssh.agent) {
|
||||
// Enabling agent is explicit intent: never silently fall through to
|
||||
// password when the socket var is missing.
|
||||
if (!env.SSH_AUTH_SOCK) {
|
||||
throw new Error('ssh agent auth requested but SSH_AUTH_SOCK is not set')
|
||||
}
|
||||
return { agent: env.SSH_AUTH_SOCK }
|
||||
}
|
||||
if (ssh.password) {
|
||||
return { password: ssh.password }
|
||||
}
|
||||
throw new Error(
|
||||
'ssh config needs one of: privateKey, privateKeyPath, agent (with SSH_AUTH_SOCK set), password'
|
||||
)
|
||||
}
|
||||
|
||||
export const openTunnel = async (
|
||||
ssh: SshConfig,
|
||||
dstHost: string,
|
||||
dstPort: number
|
||||
): Promise<Tunnel> => {
|
||||
const client = new Client()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.once('ready', resolve)
|
||||
client.once('error', reject)
|
||||
client.connect({
|
||||
host: ssh.host,
|
||||
port: ssh.port,
|
||||
username: ssh.user,
|
||||
...buildSshAuth(ssh)
|
||||
})
|
||||
})
|
||||
|
||||
// After 'ready' resolved, later ssh errors must not crash the process.
|
||||
client.on('error', (error) => {
|
||||
console.error('dbmole: ssh tunnel error: ' + error.message)
|
||||
})
|
||||
|
||||
let closed = false
|
||||
const sockets = new Set<net.Socket>()
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.on('close', () => {
|
||||
sockets.delete(socket)
|
||||
})
|
||||
client.forwardOut(
|
||||
socket.remoteAddress ?? '127.0.0.1',
|
||||
socket.remotePort ?? 0,
|
||||
dstHost,
|
||||
dstPort,
|
||||
(error, stream) => {
|
||||
if (error || !stream) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
// The tunnel may have closed (or the socket been torn down)
|
||||
// while forwardOut was in flight; if so, drop the freshly
|
||||
// forwarded channel instead of wiring it to a dead socket.
|
||||
if (closed || socket.destroyed) {
|
||||
socket.destroy()
|
||||
stream.destroy()
|
||||
return
|
||||
}
|
||||
socket.pipe(stream)
|
||||
stream.pipe(socket)
|
||||
stream.on('close', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('close', () => {
|
||||
stream.destroy()
|
||||
})
|
||||
stream.on('error', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('error', () => {
|
||||
stream.destroy()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
} catch (error) {
|
||||
client.end()
|
||||
throw error
|
||||
}
|
||||
|
||||
const address = server.address() as net.AddressInfo
|
||||
|
||||
// Idempotently mark the tunnel dead and stop the local listener so the
|
||||
// manager notices on the next get() and rebuilds.
|
||||
const markClosed = (): void => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
server.close()
|
||||
}
|
||||
|
||||
// listen() only attached a once-reject error handler that is now settled;
|
||||
// without a persistent one, a later listener error is swallowed and the
|
||||
// tunnel silently serves a dead socket. Mark it closed instead.
|
||||
server.on('error', (error) => {
|
||||
console.error('dbmole: tunnel listener error: ' + error.message)
|
||||
markClosed()
|
||||
})
|
||||
|
||||
// A dead ssh connection must stop the local listener. ssh2 emits 'end'
|
||||
// before 'close'; handle both so the dead window is as small as possible.
|
||||
client.on('end', markClosed)
|
||||
client.on('close', markClosed)
|
||||
|
||||
const isClosed = (): boolean => closed
|
||||
|
||||
const close = async () => {
|
||||
closed = true
|
||||
// server.close() waits for active sockets to finish, so destroy the
|
||||
// forwarded sockets first or close() can hang indefinitely.
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
sockets.clear()
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
client.end()
|
||||
}
|
||||
|
||||
return { localHost: '127.0.0.1', localPort: address.port, isClosed, close }
|
||||
}
|
||||
Reference in new issue
Block a user