feat: SSH terminal gateway — ssh2 proxy over WebSocket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd916fa4ef
commit
60d7b6b024
22
backend/package-lock.json
generated
22
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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/(.*)'],
|
||||
|
||||
158
backend/src/terminal/ssh-connection.service.ts
Normal file
158
backend/src/terminal/ssh-connection.service.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
88
backend/src/terminal/terminal.gateway.ts
Normal file
88
backend/src/terminal/terminal.gateway.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
backend/src/terminal/terminal.module.ts
Normal file
14
backend/src/terminal/terminal.module.ts
Normal 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 {}
|
||||
Loading…
Reference in New Issue
Block a user