diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f5e304 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# PostgreSQL connection URL +POSTGRES_URL=postgresql://mremotify:mremotify@localhost:5432/mremotify + +# AES-256 encryption key for passwords (must be exactly 32 characters) +ENCRYPTION_KEY=change-me-to-a-random-32char-key! + +# JWT signing secret +JWT_SECRET=change-me-to-a-secure-jwt-secret + +# Default admin credentials (used during first-time seeding) +ADMIN_USER=admin +ADMIN_PASSWORD=admin123 + +# Apache Guacamole daemon +GUACD_HOST=guacd +GUACD_PORT=4822 + +# Backend port +PORT=3000 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..407d6c2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +node_modules +dist +.pnpm-store +frontend/dist +backend/dist diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..4679d7d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'react', 'react-hooks'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'prettier', + ], + settings: { + react: { version: 'detect' }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + }, + env: { + browser: true, + node: true, + es2021: true, + }, +}; diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1f4c4bb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/README.md b/README.md index e4a31cd..81c9af8 100644 --- a/README.md +++ b/README.md @@ -1 +1,92 @@ -# mRemotify \ No newline at end of file +# mRemotify + +A browser-based remote connection manager — open-source alternative to mRemoteNG. + +Manage SSH and RDP connections through a clean web UI with a familiar tree-based layout and tabbed session view. + +## Stack + +| Layer | Tech | +|-----------|-------------------------------------------------| +| Frontend | React 18 · TypeScript · Vite · Ant Design 5 | +| Backend | Fastify · TypeScript · Prisma · PostgreSQL | +| SSH | ssh2 (Node.js) proxied over WebSocket | +| RDP | Apache Guacamole (guacd) + guacamole-common-js | +| Auth | JWT · bcryptjs · AES-256 password encryption | +| Infra | Docker Compose · pnpm workspaces | + +## Features + +- **Connection tree** — folders with drag-and-drop, Linux / Windows icons per connection +- **Tabbed sessions** — each opened connection gets its own tab +- **SSH sessions** — full xterm.js terminal over WebSocket +- **RDP sessions** — Guacamole-based remote desktop in the browser +- **Encrypted storage** — passwords stored AES-256 encrypted at rest +- **JWT auth** — login-protected, all API + WebSocket routes secured + +## Quick start (Docker Compose) + +```bash +# 1. Clone & enter +git clone https://github.com/yourname/mremotify.git +cd mremotify + +# 2. Configure environment +cp .env.example .env +# Edit .env: set ENCRYPTION_KEY (32 chars), JWT_SECRET, ADMIN_PASSWORD + +# 3. Start everything +docker compose up -d + +# 4. Open in browser +open http://localhost +``` + +Default login: `admin` / `admin123` (change via `ADMIN_USER` / `ADMIN_PASSWORD` in `.env` before first start). + +## Development (without Docker) + +Prerequisites: Node.js >= 20, pnpm >= 9, PostgreSQL running locally. + +```bash +# Install all workspace dependencies +pnpm install + +# Copy and fill in env +cp .env.example .env +# Set POSTGRES_URL to your local DB, etc. + +# Run DB migrations + seed +cd backend && npx prisma migrate dev && npx prisma db seed && cd .. + +# Start backend + frontend in parallel +pnpm dev +``` + +Frontend dev server: http://localhost:5173 +Backend API: http://localhost:3000 + +## Project structure + +``` +mremotify/ + frontend/ React + Vite app + backend/ Fastify API + WebSocket proxies + docker/ Dockerfiles + nginx config + docker-compose.yml + .env.example + pnpm-workspace.yaml +``` + +## Environment variables + +| Variable | Description | +|-------------------|-----------------------------------------------| +| POSTGRES_URL | PostgreSQL connection string | +| ENCRYPTION_KEY | 32-character key for AES-256 password crypto | +| JWT_SECRET | Secret for signing JWT tokens | +| ADMIN_USER | Initial admin username | +| ADMIN_PASSWORD | Initial admin password | +| GUACD_HOST | Hostname of guacd service | +| GUACD_PORT | Port of guacd service (default: 4822) | +| PORT | Backend port (default: 3000) | diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7fe7933 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,35 @@ +{ + "name": "backend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:migrate": "prisma migrate dev", + "db:seed": "prisma db seed" + }, + "prisma": { + "seed": "tsx src/seed.ts" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.1", + "@fastify/websocket": "^8.3.1", + "@prisma/client": "^5.17.0", + "bcryptjs": "^2.4.3", + "fastify": "^4.28.1", + "fastify-plugin": "^4.5.1", + "ssh2": "^1.16.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.16.1", + "@types/ssh2": "^1.15.0", + "@types/ws": "^8.5.12", + "prisma": "^5.17.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..90543f7 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,54 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_URL") +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + createdAt DateTime @default(now()) + folders Folder[] + connections Connection[] + + @@map("users") +} + +model Folder { + id String @id @default(cuid()) + name String + parentId String? + parent Folder? @relation("FolderChildren", fields: [parentId], references: [id]) + children Folder[] @relation("FolderChildren") + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + connections Connection[] + createdAt DateTime @default(now()) + + @@map("folders") +} + +model Connection { + id String @id @default(cuid()) + name String + host String + port Int + protocol String + username String + encryptedPassword String? + privateKey String? + domain String? + osType String? + notes String? + folderId String? + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@map("connections") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..af3f061 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,3 @@ +// Seed logic has been moved to src/seed.ts. +// The package.json prisma.seed config points to: tsx src/seed.ts +// Run with: pnpm db:seed diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..c8bb05c --- /dev/null +++ b/backend/src/index.ts @@ -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; + } +} + +async function buildApp(): Promise { + 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); +}); diff --git a/backend/src/plugins/prisma.ts b/backend/src/plugins/prisma.ts new file mode 100644 index 0000000..4bea555 --- /dev/null +++ b/backend/src/plugins/prisma.ts @@ -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' }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..1dd8222 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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 }); + } + ); +} diff --git a/backend/src/routes/connections.ts b/backend/src/routes/connections.ts new file mode 100644 index 0000000..d4346f7 --- /dev/null +++ b/backend/src/routes/connections.ts @@ -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 }>( + '/: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(); + }); +} diff --git a/backend/src/routes/folders.ts b/backend/src/routes/folders.ts new file mode 100644 index 0000000..9b406d2 --- /dev/null +++ b/backend/src/routes/folders.ts @@ -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(); + }); +} diff --git a/backend/src/seed.ts b/backend/src/seed.ts new file mode 100644 index 0000000..6596224 --- /dev/null +++ b/backend/src/seed.ts @@ -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()); diff --git a/backend/src/utils/encryption.ts b/backend/src/utils/encryption.ts new file mode 100644 index 0000000..4547782 --- /dev/null +++ b/backend/src/utils/encryption.ts @@ -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'); +} diff --git a/backend/src/websocket/rdp.ts b/backend/src/websocket/rdp.ts new file mode 100644 index 0000000..858a9a1 --- /dev/null +++ b/backend/src/websocket/rdp.ts @@ -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 { + 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(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((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 = { + 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(); + }); + } + ); +} diff --git a/backend/src/websocket/ssh.ts b/backend/src/websocket/ssh.ts new file mode 100644 index 0000000..5953e15 --- /dev/null +++ b/backend/src/websocket/ssh.ts @@ -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(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(); + }); + } + ); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..8de372f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "lib": ["ES2021"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f72aba5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +version: '3.9' + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: mremotify + POSTGRES_PASSWORD: mremotify + POSTGRES_DB: mremotify + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - internal + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U mremotify'] + interval: 5s + timeout: 5s + retries: 10 + + guacd: + image: guacamole/guacd:1.5.4 + restart: unless-stopped + networks: + - internal + + backend: + build: + context: . + dockerfile: docker/backend.Dockerfile + restart: unless-stopped + env_file: .env + environment: + POSTGRES_URL: postgresql://mremotify:mremotify@postgres:5432/mremotify + GUACD_HOST: guacd + GUACD_PORT: '4822' + depends_on: + postgres: + condition: service_healthy + guacd: + condition: service_started + networks: + - internal + + frontend: + build: + context: . + dockerfile: docker/frontend.Dockerfile + restart: unless-stopped + ports: + - '80:80' + depends_on: + - backend + networks: + - internal + +networks: + internal: + +volumes: + postgres_data: diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile new file mode 100644 index 0000000..f6ce252 --- /dev/null +++ b/docker/backend.Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +# ---- Build stage ---- +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install all dependencies (including devDeps for tsc, prisma CLI, tsx) +COPY backend/package*.json ./ +RUN npm install + +# Copy backend source files +COPY backend/prisma ./prisma +COPY backend/src ./src +COPY backend/tsconfig.json ./ + +# Generate Prisma client and compile TypeScript +RUN npx prisma generate +RUN npx tsc + +# ---- Runtime stage ---- +FROM node:20-alpine AS runner + +WORKDIR /app + +# Copy only production runtime artifacts +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/package.json ./ + +EXPOSE 3000 + +# Migrate schema, seed admin user, then start the server +CMD ["sh", "-c", \ + "node_modules/.bin/prisma migrate deploy && \ + node dist/seed.js && \ + node dist/index.js"] diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 0000000..5cd76f1 --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +# ---- Build stage ---- +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend/ . + +# Type-check and build +RUN npm run build + +# ---- Serve stage ---- +FROM nginx:1.27-alpine AS runner + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..95d5e16 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + + # Serve static frontend assets + root /usr/share/nginx/html; + index index.html; + + # All unknown routes → index.html (React SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy REST API to backend + location /api/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy WebSocket connections to backend + location /ws/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin" always; + + # Compression + gzip on; + gzip_types text/plain text/css application/javascript application/json; + gzip_min_length 1024; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c3e093e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + + mRemotify + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..daf3551 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^5.4.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "antd": "^5.20.5", + "axios": "^1.7.7", + "guacamole-common-js": "^1.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.3" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e965b3e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { ConfigProvider, theme } from 'antd'; +import { useStore } from './store'; +import { LoginPage } from './pages/LoginPage'; +import { MainLayout } from './components/Layout/MainLayout'; + +const App: React.FC = () => { + const token = useStore((s) => s.token); + + return ( + + {token ? : } + + ); +}; + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..4a7dc47 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; +import { Connection, ConnectionFormValues, Folder, User } from '../types'; + +const api = axios.create({ baseURL: '/api' }); + +// Attach JWT token to every request +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +// Auth +export const apiLogin = (username: string, password: string) => + api.post<{ token: string; user: User }>('/auth/login', { username, password }); + +export const apiMe = () => api.get('/auth/me'); + +// Folders +export const apiFolderList = () => api.get('/folders'); +export const apiFolderCreate = (data: { name: string; parentId?: string | null }) => + api.post('/folders', data); +export const apiFolderUpdate = (id: string, data: { name?: string; parentId?: string | null }) => + api.patch(`/folders/${id}`, data); +export const apiFolderDelete = (id: string) => api.delete(`/folders/${id}`); + +// Connections +export const apiConnectionList = () => api.get('/connections'); +export const apiConnectionGet = (id: string) => api.get(`/connections/${id}`); +export const apiConnectionCreate = (data: ConnectionFormValues) => + api.post('/connections', data); +export const apiConnectionUpdate = (id: string, data: Partial) => + api.patch(`/connections/${id}`, data); +export const apiConnectionDelete = (id: string) => api.delete(`/connections/${id}`); + +export default api; diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx new file mode 100644 index 0000000..7e392a8 --- /dev/null +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { Layout } from 'antd'; +import { TopNav } from '../Nav/TopNav'; +import { ConnectionTree } from '../Sidebar/ConnectionTree'; +import { SessionTabs } from '../Tabs/SessionTabs'; + +const { Content } = Layout; + +const MIN_SIDEBAR = 180; +const MAX_SIDEBAR = 600; +const DEFAULT_SIDEBAR = 260; + +export const MainLayout: React.FC = () => { + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR); + const isResizing = useRef(false); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (me: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = Math.min(MAX_SIDEBAR, Math.max(MIN_SIDEBAR, me.clientX)); + setSidebarWidth(newWidth); + }; + + const onMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + + return ( + + + + + {/* Resizable sidebar */} +
+ +
+ + {/* Resize handle */} +
{ + (e.currentTarget as HTMLDivElement).style.background = '#1677ff40'; + }} + onMouseLeave={(e) => { + if (!isResizing.current) + (e.currentTarget as HTMLDivElement).style.background = 'transparent'; + }} + /> + + {/* Main content area */} + + + + + + ); +}; diff --git a/frontend/src/components/Modals/ConnectionModal.tsx b/frontend/src/components/Modals/ConnectionModal.tsx new file mode 100644 index 0000000..b8ef7b9 --- /dev/null +++ b/frontend/src/components/Modals/ConnectionModal.tsx @@ -0,0 +1,190 @@ +import React, { useEffect } from 'react'; +import { + Modal, + Form, + Input, + InputNumber, + Select, + Button, + Space, + Divider, + Row, + Col, +} from 'antd'; +import { Connection, ConnectionFormValues, Folder } from '../../types'; + +interface Props { + open: boolean; + connection?: Connection | null; + folders: Folder[]; + onClose: () => void; + onSave: (values: ConnectionFormValues, id?: string) => Promise; +} + +const { Option } = Select; +const { TextArea } = Input; + +export const ConnectionModal: React.FC = ({ + open, + connection, + folders, + onClose, + onSave, +}) => { + const [form] = Form.useForm(); + const protocol = Form.useWatch('protocol', form); + const isEdit = !!connection; + + useEffect(() => { + if (open) { + if (connection) { + form.setFieldsValue({ + name: connection.name, + host: connection.host, + port: connection.port, + protocol: connection.protocol, + username: connection.username, + privateKey: connection.privateKey ?? undefined, + domain: connection.domain ?? undefined, + osType: connection.osType ?? undefined, + notes: connection.notes ?? undefined, + folderId: connection.folderId ?? null, + }); + } else { + form.resetFields(); + form.setFieldsValue({ protocol: 'ssh', port: 22 }); + } + } + }, [open, connection, form]); + + const handleProtocolChange = (value: 'ssh' | 'rdp') => { + form.setFieldValue('port', value === 'ssh' ? 22 : 3389); + }; + + const handleOk = async () => { + const values = await form.validateFields(); + await onSave(values, connection?.id); + form.resetFields(); + }; + + // Build flat folder options with visual indentation + const buildFolderOptions = ( + allFolders: Folder[], + parentId: string | null = null, + depth = 0 + ): React.ReactNode[] => { + return allFolders + .filter((f) => f.parentId === parentId) + .flatMap((f) => [ + , + ...buildFolderOptions(allFolders, f.id, depth + 1), + ]); + }; + + return ( + + + + + } + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {protocol === 'ssh' && ( + +