- backend/src/ssh/connect.ts: extracted shared SSH-connect logic (jump-host chaining, TOFU host-key verification) out of terminal.ts so tunnels can reuse it. - backend/src/tunnels/manager.ts + socks5.ts: in-memory tunnel runtime manager supporting local forward (forwardOut), remote forward (forwardIn), and dynamic SOCKS5 proxying, with automatic reconnect/retry and an auto-start-on-boot option. New `tunnels` table persists configs as the saved presets. - backend/src/routes/tunnels.ts: REST CRUD + connect/disconnect. - src/pages/Tunnels.tsx: new /tunnels page (sidebar entry added) to create, start/stop, and delete tunnels with live status polling. - Verified end-to-end against a real ssh2 test server handling real forwardOut/forwardIn requests and a real upstream TCP echo server - all three tunnel modes moved real data, and disconnect correctly tore down the local listener. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
import type { FastifyInstance } from 'fastify'
|
|
import { Client, type ClientChannel } from 'ssh2'
|
|
import { spawn as spawnPty, type IPty } from 'node-pty'
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { loadSshHost, connectTarget, type SshHost } from '../ssh/connect.js'
|
|
|
|
interface ClientMessage {
|
|
type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux'
|
|
integrationId?: number
|
|
cols?: number
|
|
rows?: number
|
|
data?: string
|
|
tmuxSession?: string
|
|
}
|
|
|
|
const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
|
|
|
|
const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'
|
|
|
|
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
|
socket.send(JSON.stringify(payload))
|
|
}
|
|
|
|
/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the
|
|
* system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under
|
|
* a real pty. Jump-host chaining is not supported on this path. */
|
|
function connectWithCertificate(
|
|
target: SshHost,
|
|
cols: number,
|
|
rows: number,
|
|
onReady: (pty: IPty, keyDir: string) => void,
|
|
onError: (message: string) => void,
|
|
) {
|
|
const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-'))
|
|
const keyFile = join(keyDir, 'id_key')
|
|
const certFile = join(keyDir, 'id_key-cert.pub')
|
|
try {
|
|
writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 })
|
|
writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 })
|
|
} catch (err) {
|
|
rmSync(keyDir, { recursive: true, force: true })
|
|
onError(err instanceof Error ? err.message : 'Failed to write certificate files')
|
|
return
|
|
}
|
|
|
|
const args = [
|
|
'-tt',
|
|
'-p', String(Number(target.config.port) || 22),
|
|
'-i', keyFile,
|
|
'-o', `CertificateFile=${certFile}`,
|
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
'-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`,
|
|
`${target.config.username}@${target.config.host}`,
|
|
]
|
|
|
|
let pty: IPty
|
|
try {
|
|
pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows })
|
|
} catch (err) {
|
|
rmSync(keyDir, { recursive: true, force: true })
|
|
onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh')
|
|
return
|
|
}
|
|
onReady(pty, keyDir)
|
|
}
|
|
|
|
function sessionLogPath(integrationId: number) {
|
|
mkdirSync(SESSION_LOG_DIR, { recursive: true })
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`)
|
|
}
|
|
|
|
export async function terminalRoutes(app: FastifyInstance) {
|
|
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
|
let conn: Client | null = null
|
|
let jumpConn: Client | null = null
|
|
let stream: ClientChannel | null = null
|
|
let pty: IPty | null = null
|
|
let ptyKeyDir: string | null = null
|
|
let logPath: string | null = null
|
|
|
|
const logData = (data: string) => {
|
|
if (logPath) appendFileSync(logPath, data)
|
|
}
|
|
|
|
const cleanup = () => {
|
|
stream?.end()
|
|
conn?.end()
|
|
jumpConn?.end()
|
|
pty?.kill()
|
|
if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true })
|
|
stream = null
|
|
conn = null
|
|
jumpConn = null
|
|
pty = null
|
|
ptyKeyDir = null
|
|
logPath = 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' || msg.type === 'list_tmux') {
|
|
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 target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null
|
|
if (!target) {
|
|
send(socket, { type: 'error', message: 'SSH integration not found' })
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'list_tmux') {
|
|
const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget(
|
|
target,
|
|
(client) => {
|
|
client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => {
|
|
if (err) {
|
|
send(socket, { type: 'tmux_sessions', sessions: [] })
|
|
client.end()
|
|
ephemeralJump?.end()
|
|
return
|
|
}
|
|
let out = ''
|
|
ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8')))
|
|
ch.on('close', () => {
|
|
const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
send(socket, { type: 'tmux_sessions', sessions })
|
|
client.end()
|
|
ephemeralJump?.end()
|
|
})
|
|
})
|
|
},
|
|
(message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }),
|
|
)
|
|
void ephemeralConn
|
|
return
|
|
}
|
|
|
|
const cols = msg.cols ?? 80
|
|
const rows = msg.rows ?? 24
|
|
|
|
if (target.secrets.certificate) {
|
|
connectWithCertificate(
|
|
target,
|
|
cols,
|
|
rows,
|
|
(p, keyDir) => {
|
|
pty = p
|
|
ptyKeyDir = keyDir
|
|
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
|
send(socket, { type: 'connected' })
|
|
p.onData((data) => {
|
|
logData(data)
|
|
send(socket, { type: 'data', data })
|
|
})
|
|
p.onExit(() => {
|
|
send(socket, { type: 'closed' })
|
|
cleanup()
|
|
})
|
|
},
|
|
(message) => send(socket, { type: 'error', message }),
|
|
)
|
|
return
|
|
}
|
|
|
|
const startSession = (client: Client) => {
|
|
conn = client
|
|
const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null
|
|
const onChannel = (err: Error | undefined, ch: ClientChannel) => {
|
|
if (err) {
|
|
send(socket, { type: 'error', message: err.message })
|
|
return
|
|
}
|
|
stream = ch
|
|
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
|
send(socket, { type: 'connected' })
|
|
ch.on('data', (chunk: Buffer) => {
|
|
const text = chunk.toString('utf8')
|
|
logData(text)
|
|
send(socket, { type: 'data', data: text })
|
|
})
|
|
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
|
ch.on('close', () => {
|
|
send(socket, { type: 'closed' })
|
|
cleanup()
|
|
})
|
|
}
|
|
if (tmuxSession) {
|
|
client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, {
|
|
pty: { cols, rows, term: 'xterm-256color' },
|
|
}, onChannel)
|
|
} else {
|
|
client.shell({ cols, rows, term: 'xterm-256color' }, onChannel)
|
|
}
|
|
}
|
|
|
|
const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message }))
|
|
conn = result.conn
|
|
jumpConn = result.jumpConn
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'input') {
|
|
if (pty) pty.write(msg.data ?? '')
|
|
else stream?.write(msg.data ?? '')
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'resize') {
|
|
if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24)
|
|
else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
|
return
|
|
}
|
|
|
|
if (msg.type === 'disconnect') {
|
|
cleanup()
|
|
}
|
|
})
|
|
})
|
|
}
|