refactor: move tunnel and respond into core

This commit is contained in:
admin committed 2026-06-16 19:44:07 +05:00
1 parent c1c0e3383b
commit dd99f4a47b
8 files changed
+9 -6

No files matched your search

+171
View File
@@ -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 }
}