feat: SSH terminal gateway — ssh2 proxy over WebSocket

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:17:12 -04:00
parent fd916fa4ef
commit 60d7b6b024
6 changed files with 286 additions and 0 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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/(.*)'],

View File

@ -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<string, SshSession>();
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<boolean>,
): Promise<string> {
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<any> {
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);
});
});
}
}

View File

@ -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<any, string[]>(); // 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));
}
}
}

View File

@ -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 {}