Initial scaffold: full-stack mRemotify monorepo
Sets up the complete mRemotify project — a browser-based remote connection manager — with a working pnpm workspace monorepo: Frontend (React + TypeScript + Vite + Ant Design 5): - Login page with JWT auth - Resizable sidebar with drag-and-drop connection tree (folders + connections) - Tabbed session area (SSH via xterm.js, RDP via guacamole-common-js) - Connection CRUD modal with SSH/RDP-specific fields - Zustand store for auth, tree data, and open sessions Backend (Fastify + TypeScript + Prisma + PostgreSQL): - JWT authentication (login + /me endpoint) - Full CRUD REST API for folders (self-referencing) and connections - AES-256-CBC password encryption at rest - WebSocket proxy for SSH sessions (ssh2 <-> xterm.js) - WebSocket proxy for RDP sessions (guacd TCP handshake + bidirectional relay) - Admin user seeding on first start Infrastructure: - Docker Compose: postgres (healthcheck) + guacd + backend + frontend/nginx - nginx: serves SPA, proxies /api and /ws (with WebSocket upgrade) to backend - .env.example with all required variables documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
61
backend/src/index.ts
Normal file
61
backend/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import websocketPlugin from '@fastify/websocket';
|
||||
import prismaPlugin from './plugins/prisma';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { folderRoutes } from './routes/folders';
|
||||
import { connectionRoutes } from './routes/connections';
|
||||
import { sshWebsocket } from './websocket/ssh';
|
||||
import { rdpWebsocket } from './websocket/rdp';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
await fastify.register(cors, { origin: true, credentials: true });
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'supersecret-change-me',
|
||||
});
|
||||
|
||||
await fastify.register(websocketPlugin);
|
||||
|
||||
await fastify.register(prismaPlugin);
|
||||
|
||||
// Reusable auth preHandler
|
||||
fastify.decorate('authenticate', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.send(err);
|
||||
}
|
||||
});
|
||||
|
||||
// REST routes
|
||||
await fastify.register(authRoutes, { prefix: '/api/auth' });
|
||||
await fastify.register(folderRoutes, { prefix: '/api/folders' });
|
||||
await fastify.register(connectionRoutes, { prefix: '/api/connections' });
|
||||
|
||||
// WebSocket routes
|
||||
await fastify.register(sshWebsocket);
|
||||
await fastify.register(rdpWebsocket);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
backend/src/plugins/prisma.ts
Normal file
25
backend/src/plugins/prisma.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
|
||||
async function prismaPlugin(fastify: FastifyInstance) {
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
await prisma.$connect();
|
||||
|
||||
fastify.decorate('prisma', prisma);
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(prismaPlugin, { name: 'prisma' });
|
||||
58
backend/src/routes/auth.ts
Normal file
58
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
interface LoginBody {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function authRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/auth/login
|
||||
fastify.post<{ Body: LoginBody }>(
|
||||
'/login',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: LoginBody }>, reply: FastifyReply) => {
|
||||
const { username, password } = request.body;
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = fastify.jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
{ expiresIn: '12h' }
|
||||
);
|
||||
|
||||
return reply.send({ token, user: { id: user.id, username: user.username } });
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/auth/me — verify token and return current user
|
||||
fastify.get(
|
||||
'/me',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const payload = request.user as { id: string; username: string };
|
||||
const user = await fastify.prisma.user.findUnique({ where: { id: payload.id } });
|
||||
if (!user) return reply.status(404).send({ error: 'User not found' });
|
||||
return reply.send({ id: user.id, username: user.username });
|
||||
}
|
||||
);
|
||||
}
|
||||
155
backend/src/routes/connections.ts
Normal file
155
backend/src/routes/connections.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { encrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ConnectionBody {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'ssh' | 'rdp';
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
domain?: string;
|
||||
osType?: string;
|
||||
notes?: string;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
export async function connectionRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// GET /api/connections
|
||||
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const connections = await fastify.prisma.connection.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
// Do NOT expose encryptedPassword or privateKey in list
|
||||
},
|
||||
});
|
||||
return reply.send(connections);
|
||||
});
|
||||
|
||||
// GET /api/connections/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: request.params.id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
privateKey: true,
|
||||
createdAt: true,
|
||||
// still omit encryptedPassword
|
||||
},
|
||||
});
|
||||
if (!conn) return reply.status(404).send({ error: 'Connection not found' });
|
||||
return reply.send(conn);
|
||||
});
|
||||
|
||||
// POST /api/connections
|
||||
fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { password, ...rest } = request.body;
|
||||
|
||||
const connection = await fastify.prisma.connection.create({
|
||||
data: {
|
||||
...rest,
|
||||
folderId: rest.folderId || null,
|
||||
encryptedPassword: password ? encrypt(password) : null,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(connection);
|
||||
});
|
||||
|
||||
// PATCH /api/connections/:id
|
||||
fastify.patch<{ Params: { id: string }; Body: Partial<ConnectionBody> }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { password, folderId, ...rest } = request.body;
|
||||
|
||||
const existing = await fastify.prisma.connection.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Connection not found' });
|
||||
|
||||
const updated = await fastify.prisma.connection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(folderId !== undefined && { folderId: folderId ?? null }),
|
||||
...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/connections/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
|
||||
const existing = await fastify.prisma.connection.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Connection not found' });
|
||||
|
||||
await fastify.prisma.connection.delete({ where: { id } });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
93
backend/src/routes/folders.ts
Normal file
93
backend/src/routes/folders.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface CreateFolderBody {
|
||||
name: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface UpdateFolderBody {
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export async function folderRoutes(fastify: FastifyInstance) {
|
||||
// All folder routes require authentication
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// GET /api/folders — list all folders for the current user
|
||||
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const folders = await fastify.prisma.folder.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send(folders);
|
||||
});
|
||||
|
||||
// POST /api/folders — create folder
|
||||
fastify.post<{ Body: CreateFolderBody }>(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
parentId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: CreateFolderBody }>, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { name, parentId } = request.body;
|
||||
|
||||
const folder = await fastify.prisma.folder.create({
|
||||
data: { name, parentId: parentId || null, userId: user.id },
|
||||
});
|
||||
return reply.status(201).send(folder);
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/folders/:id — update folder
|
||||
fastify.patch<{ Params: { id: string }; Body: UpdateFolderBody }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { name, parentId } = request.body;
|
||||
|
||||
const folder = await fastify.prisma.folder.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!folder) return reply.status(404).send({ error: 'Folder not found' });
|
||||
|
||||
const updated = await fastify.prisma.folder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(parentId !== undefined && { parentId: parentId ?? null }),
|
||||
},
|
||||
});
|
||||
return reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/folders/:id — delete folder (connections become root-level)
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
|
||||
const folder = await fastify.prisma.folder.findFirst({ where: { id, userId: user.id } });
|
||||
if (!folder) return reply.status(404).send({ error: 'Folder not found' });
|
||||
|
||||
await fastify.prisma.folder.delete({ where: { id } });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
29
backend/src/seed.ts
Normal file
29
backend/src/seed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Database seed — run with `tsx src/seed.ts` locally or `node dist/seed.js` in Docker.
|
||||
* Creates the initial admin user if one does not already exist.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const username = process.env.ADMIN_USER || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username } });
|
||||
if (!existing) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.create({ data: { username, passwordHash } });
|
||||
console.log(`[seed] Created admin user: ${username}`);
|
||||
} else {
|
||||
console.log(`[seed] Admin user '${username}' already exists — skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('[seed] error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
26
backend/src/utils/encryption.ts
Normal file
26
backend/src/utils/encryption.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const raw = process.env.ENCRYPTION_KEY || '';
|
||||
// Derive exactly 32 bytes from whatever key string is provided
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const [ivHex, encryptedHex] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
212
backend/src/websocket/rdp.ts
Normal file
212
backend/src/websocket/rdp.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import net from 'net';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
||||
import { WebSocket } from 'ws';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guacamole protocol helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encodeElement(value: string): string {
|
||||
return `${value.length}.${value}`;
|
||||
}
|
||||
|
||||
function buildInstruction(opcode: string, ...args: string[]): string {
|
||||
return [encodeElement(opcode), ...args.map(encodeElement)].join(',') + ';';
|
||||
}
|
||||
|
||||
/** Parse a single complete Guacamole instruction string into [opcode, ...args] */
|
||||
function parseInstruction(instruction: string): string[] {
|
||||
const raw = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction;
|
||||
const elements: string[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < raw.length) {
|
||||
const dotPos = raw.indexOf('.', pos);
|
||||
if (dotPos === -1) break;
|
||||
const length = parseInt(raw.substring(pos, dotPos), 10);
|
||||
if (isNaN(length)) break;
|
||||
const value = raw.substring(dotPos + 1, dotPos + 1 + length);
|
||||
elements.push(value);
|
||||
pos = dotPos + 1 + length;
|
||||
if (raw[pos] === ',') pos++;
|
||||
}
|
||||
|
||||
return elements; // elements[0] = opcode, rest = args
|
||||
}
|
||||
|
||||
/** Read from a TCP socket until a complete Guacamole instruction (ending with ';') is received. */
|
||||
function readInstruction(
|
||||
tcpSocket: net.Socket,
|
||||
buf: { value: string }
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const idx = buf.value.indexOf(';');
|
||||
if (idx !== -1) {
|
||||
const instruction = buf.value.substring(0, idx + 1);
|
||||
buf.value = buf.value.substring(idx + 1);
|
||||
resolve(parseInstruction(instruction));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (check()) return;
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buf.value += data.toString('utf8');
|
||||
if (check()) {
|
||||
tcpSocket.removeListener('data', onData);
|
||||
tcpSocket.removeListener('error', onError);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
tcpSocket.removeListener('data', onData);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
tcpSocket.on('data', onData);
|
||||
tcpSocket.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RDP WebSocket handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/ws/rdp/:connectionId',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, request) => {
|
||||
// --- Auth ---
|
||||
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 },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
socket.close(1008, 'Connection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const guacdHost = process.env.GUACD_HOST || 'localhost';
|
||||
const guacdPort = Number(process.env.GUACD_PORT) || 4822;
|
||||
|
||||
// --- Connect to guacd ---
|
||||
const guacd = net.createConnection(guacdPort, guacdHost);
|
||||
const tcpBuf = { value: '' };
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
guacd.once('connect', resolve);
|
||||
guacd.once('error', reject);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'guacd connect failed';
|
||||
fastify.log.error({ err }, 'guacd connect failed');
|
||||
socket.close(1011, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Select protocol
|
||||
guacd.write(buildInstruction('select', conn.protocol));
|
||||
|
||||
// 2. Read args list from guacd
|
||||
const argsInstruction = await readInstruction(guacd, tcpBuf);
|
||||
// argsInstruction = ['args', 'hostname', 'port', 'username', ...]
|
||||
const argNames = argsInstruction.slice(1);
|
||||
|
||||
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
||||
|
||||
// Build a map of known RDP parameters
|
||||
const rdpParams: Record<string, string> = {
|
||||
hostname: conn.host,
|
||||
port: String(conn.port),
|
||||
username: conn.username,
|
||||
password: decryptedPassword,
|
||||
domain: conn.domain || '',
|
||||
width: '1280',
|
||||
height: '720',
|
||||
dpi: '96',
|
||||
'color-depth': '32',
|
||||
'ignore-cert': 'true',
|
||||
security: 'any',
|
||||
'disable-auth': 'false',
|
||||
'enable-drive': 'false',
|
||||
'create-drive-path': 'false',
|
||||
'enable-printing': 'false',
|
||||
};
|
||||
|
||||
// 3. Connect with values for each arg guacd requested
|
||||
const argValues = argNames.map((name) => rdpParams[name] ?? '');
|
||||
guacd.write(buildInstruction('connect', ...argValues));
|
||||
|
||||
// 4. Read ready instruction
|
||||
const readyInstruction = await readInstruction(guacd, tcpBuf);
|
||||
if (readyInstruction[0] !== 'ready') {
|
||||
throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`);
|
||||
}
|
||||
|
||||
// 5. Flush any remaining buffered bytes to socket
|
||||
if (tcpBuf.value.length > 0) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(tcpBuf.value);
|
||||
}
|
||||
tcpBuf.value = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Guacamole handshake failed';
|
||||
fastify.log.error({ err }, 'Guacamole handshake failed');
|
||||
guacd.destroy();
|
||||
socket.close(1011, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Proxy mode: bidirectional forwarding ---
|
||||
guacd.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data.toString('utf8'));
|
||||
}
|
||||
});
|
||||
|
||||
guacd.on('end', () => socket.close());
|
||||
guacd.on('error', (err) => {
|
||||
fastify.log.error({ err }, 'guacd socket error');
|
||||
socket.close(1011, err.message);
|
||||
});
|
||||
|
||||
socket.on('message', (message: Buffer | string) => {
|
||||
if (guacd.writable) {
|
||||
guacd.write(typeof message === 'string' ? message : message.toString('utf8'));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
guacd.destroy();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
135
backend/src/websocket/ssh.ts
Normal file
135
backend/src/websocket/ssh.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
||||
import { WebSocket } from 'ws';
|
||||
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ResizeMessage {
|
||||
type: 'resize';
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export async function sshWebsocket(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/ws/ssh/:connectionId',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, request) => {
|
||||
// --- 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 },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
socket.close(1008, 'Connection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build SSH config ---
|
||||
const sshConfig: ConnectConfig = {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
readyTimeout: 10_000,
|
||||
};
|
||||
|
||||
if (conn.privateKey) {
|
||||
sshConfig.privateKey = conn.privateKey;
|
||||
} else if (conn.encryptedPassword) {
|
||||
sshConfig.password = decrypt(conn.encryptedPassword);
|
||||
}
|
||||
|
||||
// --- Open SSH session ---
|
||||
const ssh = new SshClient();
|
||||
|
||||
ssh.on('ready', () => {
|
||||
ssh.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => {
|
||||
if (err) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
socket.close();
|
||||
ssh.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// SSH stdout/stderr → WebSocket (binary frames)
|
||||
stream.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
socket.close();
|
||||
ssh.end();
|
||||
});
|
||||
|
||||
// WebSocket → SSH stdin
|
||||
socket.on('message', (message: Buffer | string, isBinary: boolean) => {
|
||||
if (!stream.writable) return;
|
||||
|
||||
if (isBinary) {
|
||||
// Raw terminal input
|
||||
stream.write(Buffer.isBuffer(message) ? message : Buffer.from(message as string));
|
||||
} else {
|
||||
// Control message (JSON text frame)
|
||||
try {
|
||||
const msg: ResizeMessage = JSON.parse(message.toString());
|
||||
if (msg.type === 'resize') {
|
||||
stream.setWindow(msg.rows || 24, msg.cols || 80, 0, 0);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON text: treat as raw input
|
||||
stream.write(message.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
stream.close();
|
||||
ssh.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ssh.on('error', (err) => {
|
||||
fastify.log.error({ err }, 'SSH error');
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(`\r\n\x1b[31mSSH error: ${err.message}\x1b[0m\r\n`);
|
||||
socket.close(1011, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ssh.connect(sshConfig);
|
||||
|
||||
// If WebSocket closes before SSH is ready
|
||||
socket.on('close', () => {
|
||||
ssh.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user