116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
|
|
import type { FastifyInstance } from 'fastify'
|
||
|
|
import { Client } from 'ssh2'
|
||
|
|
import type { ClientChannel } from 'ssh2'
|
||
|
|
import { db } from '../db/index.js'
|
||
|
|
import { loadSecrets } from '../db/secrets.js'
|
||
|
|
|
||
|
|
interface IntegrationRow {
|
||
|
|
id: number
|
||
|
|
type: string
|
||
|
|
config_json: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ClientMessage {
|
||
|
|
type: 'connect' | 'input' | 'resize' | 'disconnect'
|
||
|
|
integrationId?: number
|
||
|
|
cols?: number
|
||
|
|
rows?: number
|
||
|
|
data?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
||
|
|
socket.send(JSON.stringify(payload))
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function terminalRoutes(app: FastifyInstance) {
|
||
|
|
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
||
|
|
let conn: Client | null = null
|
||
|
|
let stream: ClientChannel | null = null
|
||
|
|
|
||
|
|
const cleanup = () => {
|
||
|
|
stream?.end()
|
||
|
|
conn?.end()
|
||
|
|
stream = null
|
||
|
|
conn = null
|
||
|
|
}
|
||
|
|
|
||
|
|
socket.on('close', cleanup)
|
||
|
|
|
||
|
|
socket.on('message', async (raw: Buffer) => {
|
||
|
|
let msg: ClientMessage
|
||
|
|
try {
|
||
|
|
msg = JSON.parse(raw.toString())
|
||
|
|
} catch {
|
||
|
|
send(socket, { type: 'error', message: 'Invalid JSON' })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (msg.type === 'connect') {
|
||
|
|
const query = req.query as { token?: string }
|
||
|
|
try {
|
||
|
|
await app.jwt.verify(query.token ?? '')
|
||
|
|
} catch {
|
||
|
|
send(socket, { type: 'error', message: 'Unauthorized' })
|
||
|
|
socket.close()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const row = db
|
||
|
|
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
|
||
|
|
.get(msg.integrationId) as IntegrationRow | undefined
|
||
|
|
if (!row || row.type !== 'ssh') {
|
||
|
|
send(socket, { type: 'error', message: 'SSH integration not found' })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const config = JSON.parse(row.config_json) as Record<string, string>
|
||
|
|
const secrets = loadSecrets(row.id)
|
||
|
|
|
||
|
|
conn = new Client()
|
||
|
|
conn.on('ready', () => {
|
||
|
|
conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
||
|
|
if (err) {
|
||
|
|
send(socket, { type: 'error', message: err.message })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
stream = ch
|
||
|
|
send(socket, { type: 'connected' })
|
||
|
|
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||
|
|
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||
|
|
ch.on('close', () => {
|
||
|
|
send(socket, { type: 'closed' })
|
||
|
|
cleanup()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
conn.on('error', (err) => {
|
||
|
|
send(socket, { type: 'error', message: err.message })
|
||
|
|
})
|
||
|
|
conn.connect({
|
||
|
|
host: config.host,
|
||
|
|
port: Number(config.port) || 22,
|
||
|
|
username: config.username,
|
||
|
|
password: secrets.password || undefined,
|
||
|
|
privateKey: secrets.privateKey || undefined,
|
||
|
|
passphrase: secrets.passphrase || undefined,
|
||
|
|
readyTimeout: 8000,
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (msg.type === 'input') {
|
||
|
|
stream?.write(msg.data ?? '')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (msg.type === 'resize') {
|
||
|
|
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (msg.type === 'disconnect') {
|
||
|
|
cleanup()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|