From 60d7b6b024969710eea1705cd4ed344b38737935 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 12 Mar 2026 17:17:12 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20SSH=20terminal=20gateway=20=E2=80=94=20?= =?UTF-8?q?ssh2=20proxy=20over=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/package-lock.json | 22 +++ backend/package.json | 2 + backend/src/app.module.ts | 2 + .../src/terminal/ssh-connection.service.ts | 158 ++++++++++++++++++ backend/src/terminal/terminal.gateway.ts | 88 ++++++++++ backend/src/terminal/terminal.module.ts | 14 ++ 6 files changed, 286 insertions(+) create mode 100644 backend/src/terminal/ssh-connection.service.ts create mode 100644 backend/src/terminal/terminal.gateway.ts create mode 100644 backend/src/terminal/terminal.module.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 1b1a932..0931568 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,6 +26,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "ssh2": "^1.15.0", + "uuid": "^13.0.0", "ws": "^8.16.0" }, "devDependencies": { @@ -36,6 +37,7 @@ "@types/node": "^20.0.0", "@types/passport-jwt": "^4.0.0", "@types/ssh2": "^1.15.0", + "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", "jest": "^29.0.0", "prisma": "^6.0.0", @@ -2527,6 +2529,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -9039,6 +9048,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 7e9f33a..07eec82 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "ssh2": "^1.15.0", + "uuid": "^13.0.0", "ws": "^8.16.0" }, "devDependencies": { @@ -41,6 +42,7 @@ "@types/node": "^20.0.0", "@types/passport-jwt": "^4.0.0", "@types/ssh2": "^1.15.0", + "@types/uuid": "^10.0.0", "@types/ws": "^8.5.0", "jest": "^29.0.0", "prisma": "^6.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c146e78..bcd589e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from './auth/auth.module'; import { VaultModule } from './vault/vault.module'; import { ConnectionsModule } from './connections/connections.module'; import { SettingsModule } from './settings/settings.module'; +import { TerminalModule } from './terminal/terminal.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { SettingsModule } from './settings/settings.module'; VaultModule, ConnectionsModule, SettingsModule, + TerminalModule, ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'public'), exclude: ['/api/(.*)'], diff --git a/backend/src/terminal/ssh-connection.service.ts b/backend/src/terminal/ssh-connection.service.ts new file mode 100644 index 0000000..bba4238 --- /dev/null +++ b/backend/src/terminal/ssh-connection.service.ts @@ -0,0 +1,158 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Client, ClientChannel } from 'ssh2'; +import { createHash } from 'crypto'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { v4 as uuid } from 'uuid'; + +export interface SshSession { + id: string; + hostId: number; + client: Client; + stream: ClientChannel | null; +} + +@Injectable() +export class SshConnectionService { + private readonly logger = new Logger(SshConnectionService.name); + private sessions = new Map(); + + constructor( + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + ) {} + + async connect( + hostId: number, + onData: (data: string) => void, + onClose: (reason: string) => void, + onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise, + ): Promise { + const host = await this.hosts.findOne(hostId); + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + const sessionId = uuid(); + const client = new Client(); + + return new Promise((resolve, reject) => { + client.on('ready', () => { + client.shell({ term: 'xterm-256color' }, (err, stream) => { + if (err) { + client.end(); + return reject(err); + } + const session: SshSession = { id: sessionId, hostId, client, stream }; + this.sessions.set(sessionId, session); + + stream.on('data', (data: Buffer) => onData(data.toString('utf-8'))); + stream.on('close', () => { + this.disconnect(sessionId); + onClose('Session ended'); + }); + + // Update lastConnectedAt and create connection log + this.hosts.touchLastConnected(hostId); + this.prisma.connectionLog.create({ + data: { hostId, protocol: host.protocol }, + }).catch(() => {}); + + resolve(sessionId); + }); + }); + + client.on('error', (err) => { + this.logger.error(`SSH error for host ${hostId}: ${err.message}`); + this.disconnect(sessionId); + onClose(err.message); + reject(err); + }); + + const connectConfig: any = { + host: host.hostname, + port: host.port, + username: cred?.username || 'root', + hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => { + const fingerprint = createHash('sha256').update(key).digest('base64'); + const fp = `SHA256:${fingerprint}`; + + if (host.hostFingerprint === fp) { + verify(true); // known host — accept silently + return; + } + + // Unknown or changed fingerprint — ask the user via WebSocket + const isNew = !host.hostFingerprint; + onHostKeyVerify(fp, isNew).then((accepted) => { + if (accepted) { + // Persist fingerprint so future connections auto-accept + this.prisma.host.update({ + where: { id: hostId }, + data: { hostFingerprint: fp }, + }).catch(() => {}); + } + verify(accepted); + }); + }, + }; + + if (cred?.sshKey) { + connectConfig.privateKey = cred.sshKey.privateKey; + if (cred.sshKey.passphrase) { + connectConfig.passphrase = cred.sshKey.passphrase; + } + } else if (cred?.password) { + connectConfig.password = cred.password; + } + + client.connect(connectConfig); + }); + } + + write(sessionId: string, data: string) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.write(data); + } + } + + resize(sessionId: string, cols: number, rows: number) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.setWindow(rows, cols, 0, 0); + } + } + + disconnect(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + session.stream?.close(); + session.client.end(); + this.sessions.delete(sessionId); + + // Update connection log with disconnect time + this.prisma.connectionLog.updateMany({ + where: { hostId: session.hostId, disconnectedAt: null }, + data: { disconnectedAt: new Date() }, + }).catch(() => {}); + } + } + + getSession(sessionId: string): SshSession | undefined { + return this.sessions.get(sessionId); + } + + getSftpChannel(sessionId: string): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionId); + if (!session) return reject(new Error('Session not found')); + session.client.sftp((err, sftp) => { + if (err) return reject(err); + resolve(sftp); + }); + }); + } +} diff --git a/backend/src/terminal/terminal.gateway.ts b/backend/src/terminal/terminal.gateway.ts new file mode 100644 index 0000000..418337f --- /dev/null +++ b/backend/src/terminal/terminal.gateway.ts @@ -0,0 +1,88 @@ +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { SshConnectionService } from './ssh-connection.service'; + +@WebSocketGateway({ path: '/ws/terminal' }) +export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(TerminalGateway.name); + private clientSessions = new Map(); // ws client → sessionIds + + constructor( + private ssh: SshConnectionService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.clientSessions.set(client, []); + this.logger.log(`Terminal WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + await this.handleMessage(client, msg); + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const sessions = this.clientSessions.get(client) || []; + sessions.forEach((sid) => this.ssh.disconnect(sid)); + this.clientSessions.delete(client); + } + + private async handleMessage(client: any, msg: any) { + switch (msg.type) { + case 'connect': { + const sessionId = await this.ssh.connect( + msg.hostId, + (data) => this.send(client, { type: 'data', sessionId, data }), + (reason) => this.send(client, { type: 'disconnected', sessionId, reason }), + async (fingerprint, isNew) => { + // Send verification request to client + this.send(client, { type: 'host-key-verify', fingerprint, isNew }); + return true; // auto-accept for now, full flow in Task 12 + }, + ); + const sessions = this.clientSessions.get(client) || []; + sessions.push(sessionId); + this.clientSessions.set(client, sessions); + this.send(client, { type: 'connected', sessionId }); + break; + } + case 'data': { + if (msg.sessionId) { + this.ssh.write(msg.sessionId, msg.data); + } + break; + } + case 'resize': { + if (msg.sessionId) { + this.ssh.resize(msg.sessionId, msg.cols, msg.rows); + } + break; + } + case 'disconnect': { + if (msg.sessionId) { + this.ssh.disconnect(msg.sessionId); + } + break; + } + } + } + + private send(client: any, data: any) { + if (client.readyState === 1) { // WebSocket.OPEN + client.send(JSON.stringify(data)); + } + } +} diff --git a/backend/src/terminal/terminal.module.ts b/backend/src/terminal/terminal.module.ts new file mode 100644 index 0000000..77a7f52 --- /dev/null +++ b/backend/src/terminal/terminal.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { SshConnectionService } from './ssh-connection.service'; +import { TerminalGateway } from './terminal.gateway'; +import { SftpGateway } from './sftp.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [SshConnectionService, TerminalGateway, SftpGateway], + exports: [SshConnectionService], +}) +export class TerminalModule {}