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:
FelixG
2026-02-22 12:21:36 +01:00
parent 8bc43896fb
commit 3802924c6a
44 changed files with 2707 additions and 1 deletions

35
backend/package.json Normal file
View File

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

View File

@@ -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")
}

3
backend/prisma/seed.ts Normal file
View File

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

61
backend/src/index.ts Normal file
View 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);
});

View 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' });

View 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 });
}
);
}

View 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();
});
}

View 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
View 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());

View 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');
}

View 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();
});
}
);
}

View 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();
});
}
);
}

15
backend/tsconfig.json Normal file
View File

@@ -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"]
}