latest changes

This commit is contained in:
felixg
2026-03-03 07:08:00 +01:00
parent eba699d7bc
commit 8e8b849ff8
7 changed files with 776 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ import { connectionRoutes } from './routes/connections';
import { profileRoutes } from './routes/profiles';
import { sshWebsocket } from './websocket/ssh';
import { rdpWebsocket } from './websocket/rdp';
import { sftpWebsocket } from './websocket/sftp';
import { encrypt } from './utils/encryption';
declare module 'fastify' {
@@ -48,6 +49,7 @@ async function buildApp(): Promise<FastifyInstance> {
// WebSocket routes
await fastify.register(sshWebsocket);
await fastify.register(rdpWebsocket);
await fastify.register(sftpWebsocket);
return fastify;
}

View File

@@ -0,0 +1,283 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { SocketStream } from '@fastify/websocket';
import { WebSocket } from 'ws';
import { Client as SshClient, ConnectConfig, SFTPWrapper } from 'ssh2';
import { resolveCredentials } from '../utils/resolveCredentials';
interface JwtPayload {
id: string;
username: string;
}
function sendJson(socket: WebSocket, msg: object) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}
export async function sftpWebsocket(fastify: FastifyInstance) {
fastify.get(
'/ws/sftp/:connectionId',
{ websocket: true },
async (connection: SocketStream, request: FastifyRequest) => {
const socket = connection.socket;
// --- Auth via ?token= query param ---
const query = request.query as { token?: string };
let userId: string;
try {
if (!query.token) throw new Error('No token');
const payload = fastify.jwt.verify<JwtPayload>(query.token);
userId = payload.id;
} catch {
socket.close(1008, 'Unauthorized');
return;
}
// --- Fetch connection ---
const { connectionId } = request.params as { connectionId: string };
const conn = await fastify.prisma.connection.findFirst({
where: { id: connectionId, userId },
include: { profile: true },
});
if (!conn) {
socket.close(1008, 'Connection not found');
return;
}
// --- Build SSH config ---
const creds = resolveCredentials(conn);
const sshConfig: ConnectConfig = {
host: conn.host,
port: conn.port,
username: creds.username,
readyTimeout: 10_000,
};
if (creds.privateKey) {
sshConfig.privateKey = creds.privateKey;
} else if (creds.password) {
sshConfig.password = creds.password;
}
// --- Open SSH + SFTP session ---
const ssh = new SshClient();
let sftp: SFTPWrapper | null = null;
// Upload state
let uploadPath: string | null = null;
let uploadStream: ReturnType<SFTPWrapper['createWriteStream']> | null = null;
ssh.on('ready', () => {
ssh.sftp((err, sftpSession) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
socket.close();
ssh.end();
return;
}
sftp = sftpSession;
// Resolve home directory and send ready
sftp.realpath('.', (rpErr, home) => {
sendJson(socket, {
type: 'ready',
home: rpErr ? '/' : home,
});
});
});
});
// --- Handle incoming messages ---
socket.on('message', (message: Buffer | string, isBinary: boolean) => {
if (!sftp) return;
// Binary frames → upload data
if (isBinary) {
if (uploadStream) {
const buf = Buffer.isBuffer(message) ? message : Buffer.from(message as string);
uploadStream.write(buf);
}
return;
}
// JSON commands
let msg: any;
try {
msg = JSON.parse(message.toString());
} catch {
sendJson(socket, { type: 'error', message: 'Invalid JSON' });
return;
}
switch (msg.type) {
case 'list': {
const dirPath: string = msg.path || '/';
sftp.readdir(dirPath, (err, list) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
return;
}
const entries = list
.filter((item) => item.filename !== '.' && item.filename !== '..')
.map((item) => ({
name: item.filename,
isDir: (item.attrs.mode! & 0o40000) !== 0,
size: item.attrs.size ?? 0,
modTime: (item.attrs.mtime ?? 0) * 1000,
}))
.sort((a, b) => {
// Directories first, then alphabetical
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
sendJson(socket, { type: 'entries', path: dirPath, entries });
});
break;
}
case 'download': {
const filePath: string = msg.path;
if (!filePath) {
sendJson(socket, { type: 'error', message: 'Missing path' });
return;
}
sftp.stat(filePath, (err, stats) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
return;
}
const name = filePath.split('/').pop() || 'download';
sendJson(socket, { type: 'downloadStart', name, size: stats.size });
const rs = sftp!.createReadStream(filePath);
rs.on('data', (chunk: Buffer) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(chunk);
}
});
rs.on('end', () => {
sendJson(socket, { type: 'downloadEnd' });
});
rs.on('error', (readErr: Error) => {
sendJson(socket, { type: 'error', message: readErr.message });
});
});
break;
}
case 'upload': {
const upPath: string = msg.path;
if (!upPath) {
sendJson(socket, { type: 'error', message: 'Missing path' });
return;
}
uploadPath = upPath;
uploadStream = sftp.createWriteStream(upPath);
uploadStream.on('error', (writeErr: Error) => {
sendJson(socket, { type: 'error', message: writeErr.message });
uploadStream = null;
uploadPath = null;
});
break;
}
case 'uploadEnd': {
if (uploadStream) {
uploadStream.end(() => {
sendJson(socket, { type: 'ok' });
uploadStream = null;
uploadPath = null;
});
}
break;
}
case 'mkdir': {
const mkPath: string = msg.path;
if (!mkPath) {
sendJson(socket, { type: 'error', message: 'Missing path' });
return;
}
sftp.mkdir(mkPath, (err) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
} else {
sendJson(socket, { type: 'ok' });
}
});
break;
}
case 'delete': {
const delPath: string = msg.path;
if (!delPath) {
sendJson(socket, { type: 'error', message: 'Missing path' });
return;
}
sftp.stat(delPath, (statErr, stats) => {
if (statErr) {
sendJson(socket, { type: 'error', message: statErr.message });
return;
}
const isDir = (stats.mode! & 0o40000) !== 0;
const cb = (err: Error | null | undefined) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
} else {
sendJson(socket, { type: 'ok' });
}
};
if (isDir) {
sftp!.rmdir(delPath, cb);
} else {
sftp!.unlink(delPath, cb);
}
});
break;
}
case 'rename': {
const { oldPath, newPath } = msg;
if (!oldPath || !newPath) {
sendJson(socket, { type: 'error', message: 'Missing oldPath or newPath' });
return;
}
sftp.rename(oldPath, newPath, (err) => {
if (err) {
sendJson(socket, { type: 'error', message: err.message });
} else {
sendJson(socket, { type: 'ok' });
}
});
break;
}
default:
sendJson(socket, { type: 'error', message: `Unknown command: ${msg.type}` });
}
});
ssh.on('error', (err) => {
fastify.log.error({ err }, 'SFTP SSH connection error');
sendJson(socket, { type: 'error', message: err.message });
if (socket.readyState === WebSocket.OPEN) {
socket.close(1011, err.message);
}
});
ssh.connect(sshConfig);
socket.on('close', () => {
if (uploadStream) {
uploadStream.destroy();
uploadStream = null;
}
ssh.end();
});
}
);
}