Fix backend TypeScript build errors
- Inline tsconfig.base.json settings into backend/tsconfig.json so the Docker build (which only copies backend/) can resolve them - Replace default imports of Node built-ins (crypto, net) with named imports - Replace default bcrypt import with named imports (compare, hash) - Switch @fastify/websocket from v8 to v7 (SocketStream API) to match Fastify v4 peer dependency; update WebSocket handler signatures accordingly - Remove obsolete `version` key from docker-compose.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^8.0.1",
|
"@fastify/jwt": "^8.0.1",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^7.2.0",
|
||||||
"@prisma/client": "^5.17.0",
|
"@prisma/client": "^5.17.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import bcrypt from 'bcryptjs';
|
import { compare as bcryptCompare } from 'bcryptjs';
|
||||||
|
|
||||||
interface LoginBody {
|
interface LoginBody {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -30,7 +30,7 @@ export async function authRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
const valid = await bcryptCompare(password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Creates the initial admin user if one does not already exist.
|
* Creates the initial admin user if one does not already exist.
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import { hash as bcryptHash } from 'bcryptjs';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ async function main() {
|
|||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { username } });
|
const existing = await prisma.user.findUnique({ where: { username } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcryptHash(password, 10);
|
||||||
await prisma.user.create({ data: { username, passwordHash } });
|
await prisma.user.create({ data: { username, passwordHash } });
|
||||||
console.log(`[seed] Created admin user: ${username}`);
|
console.log(`[seed] Created admin user: ${username}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import crypto from 'crypto';
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-cbc';
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const raw = process.env.ENCRYPTION_KEY || '';
|
const raw = process.env.ENCRYPTION_KEY || '';
|
||||||
// Derive exactly 32 bytes from whatever key string is provided
|
return createHash('sha256').update(raw).digest();
|
||||||
return crypto.createHash('sha256').update(raw).digest();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encrypt(text: string): string {
|
export function encrypt(text: string): string {
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = randomBytes(IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
||||||
}
|
}
|
||||||
@@ -20,7 +19,7 @@ export function decrypt(encryptedText: string): string {
|
|||||||
const [ivHex, encryptedHex] = encryptedText.split(':');
|
const [ivHex, encryptedHex] = encryptedText.split(':');
|
||||||
const iv = Buffer.from(ivHex, 'hex');
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
return decrypted.toString('utf8');
|
return decrypted.toString('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import net from 'net';
|
import { createConnection, Socket } from 'net';
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
import { SocketStream } from '@fastify/websocket';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { decrypt } from '../utils/encryption';
|
import { decrypt } from '../utils/encryption';
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ function buildInstruction(opcode: string, ...args: string[]): string {
|
|||||||
return [encodeElement(opcode), ...args.map(encodeElement)].join(',') + ';';
|
return [encodeElement(opcode), ...args.map(encodeElement)].join(',') + ';';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a single complete Guacamole instruction string into [opcode, ...args] */
|
|
||||||
function parseInstruction(instruction: string): string[] {
|
function parseInstruction(instruction: string): string[] {
|
||||||
const raw = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction;
|
const raw = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction;
|
||||||
const elements: string[] = [];
|
const elements: string[] = [];
|
||||||
@@ -38,14 +37,10 @@ function parseInstruction(instruction: string): string[] {
|
|||||||
if (raw[pos] === ',') pos++;
|
if (raw[pos] === ',') pos++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements; // elements[0] = opcode, rest = args
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read from a TCP socket until a complete Guacamole instruction (ending with ';') is received. */
|
function readInstruction(tcpSocket: Socket, buf: { value: string }): Promise<string[]> {
|
||||||
function readInstruction(
|
|
||||||
tcpSocket: net.Socket,
|
|
||||||
buf: { value: string }
|
|
||||||
): Promise<string[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const idx = buf.value.indexOf(';');
|
const idx = buf.value.indexOf(';');
|
||||||
@@ -86,7 +81,9 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
fastify.get(
|
fastify.get(
|
||||||
'/ws/rdp/:connectionId',
|
'/ws/rdp/:connectionId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
async (socket: WebSocket, request) => {
|
async (connection: SocketStream, request: FastifyRequest) => {
|
||||||
|
const socket = connection.socket;
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
const query = request.query as { token?: string };
|
const query = request.query as { token?: string };
|
||||||
let userId: string;
|
let userId: string;
|
||||||
@@ -115,7 +112,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
const guacdPort = Number(process.env.GUACD_PORT) || 4822;
|
const guacdPort = Number(process.env.GUACD_PORT) || 4822;
|
||||||
|
|
||||||
// --- Connect to guacd ---
|
// --- Connect to guacd ---
|
||||||
const guacd = net.createConnection(guacdPort, guacdHost);
|
const guacd = createConnection(guacdPort, guacdHost);
|
||||||
const tcpBuf = { value: '' };
|
const tcpBuf = { value: '' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +122,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'guacd connect failed';
|
const msg = err instanceof Error ? err.message : 'guacd connect failed';
|
||||||
fastify.log.error({ err }, 'guacd connect failed');
|
fastify.log.warn({ err }, 'guacd connect failed');
|
||||||
socket.close(1011, msg);
|
socket.close(1011, msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,12 +133,10 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// 2. Read args list from guacd
|
// 2. Read args list from guacd
|
||||||
const argsInstruction = await readInstruction(guacd, tcpBuf);
|
const argsInstruction = await readInstruction(guacd, tcpBuf);
|
||||||
// argsInstruction = ['args', 'hostname', 'port', 'username', ...]
|
|
||||||
const argNames = argsInstruction.slice(1);
|
const argNames = argsInstruction.slice(1);
|
||||||
|
|
||||||
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
||||||
|
|
||||||
// Build a map of known RDP parameters
|
|
||||||
const rdpParams: Record<string, string> = {
|
const rdpParams: Record<string, string> = {
|
||||||
hostname: conn.host,
|
hostname: conn.host,
|
||||||
port: String(conn.port),
|
port: String(conn.port),
|
||||||
@@ -160,7 +155,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
'enable-printing': 'false',
|
'enable-printing': 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Connect with values for each arg guacd requested
|
// 3. Connect with values guacd requested
|
||||||
const argValues = argNames.map((name) => rdpParams[name] ?? '');
|
const argValues = argNames.map((name) => rdpParams[name] ?? '');
|
||||||
guacd.write(buildInstruction('connect', ...argValues));
|
guacd.write(buildInstruction('connect', ...argValues));
|
||||||
|
|
||||||
@@ -170,31 +165,27 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`);
|
throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Flush any remaining buffered bytes to socket
|
// 5. Flush any buffered bytes that arrived after 'ready'
|
||||||
if (tcpBuf.value.length > 0) {
|
if (tcpBuf.value.length > 0 && socket.readyState === WebSocket.OPEN) {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
socket.send(tcpBuf.value);
|
||||||
socket.send(tcpBuf.value);
|
|
||||||
}
|
|
||||||
tcpBuf.value = '';
|
tcpBuf.value = '';
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Guacamole handshake failed';
|
const msg = err instanceof Error ? err.message : 'Guacamole handshake failed';
|
||||||
fastify.log.error({ err }, 'Guacamole handshake failed');
|
fastify.log.warn({ err }, 'Guacamole handshake failed');
|
||||||
guacd.destroy();
|
guacd.destroy();
|
||||||
socket.close(1011, msg);
|
socket.close(1011, msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Proxy mode: bidirectional forwarding ---
|
// --- Proxy mode ---
|
||||||
guacd.on('data', (data: Buffer) => {
|
guacd.on('data', (data: Buffer) => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) socket.send(data.toString('utf8'));
|
||||||
socket.send(data.toString('utf8'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
guacd.on('end', () => socket.close());
|
guacd.on('end', () => socket.close());
|
||||||
guacd.on('error', (err) => {
|
guacd.on('error', (err) => {
|
||||||
fastify.log.error({ err }, 'guacd socket error');
|
fastify.log.warn({ err }, 'guacd socket error');
|
||||||
socket.close(1011, err.message);
|
socket.close(1011, err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,9 +195,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => guacd.destroy());
|
||||||
guacd.destroy();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
import { SocketStream } from '@fastify/websocket';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||||
import { decrypt } from '../utils/encryption';
|
import { decrypt } from '../utils/encryption';
|
||||||
@@ -19,7 +19,9 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
fastify.get(
|
fastify.get(
|
||||||
'/ws/ssh/:connectionId',
|
'/ws/ssh/:connectionId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
async (socket: WebSocket, request) => {
|
async (connection: SocketStream, request: FastifyRequest) => {
|
||||||
|
const socket = connection.socket;
|
||||||
|
|
||||||
// --- Auth via ?token= query param ---
|
// --- Auth via ?token= query param ---
|
||||||
const query = request.query as { token?: string };
|
const query = request.query as { token?: string };
|
||||||
let userId: string;
|
let userId: string;
|
||||||
@@ -72,15 +74,11 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
// SSH stdout/stderr → WebSocket (binary frames)
|
// SSH stdout/stderr → WebSocket (binary frames)
|
||||||
stream.on('data', (data: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||||
socket.send(data);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.stderr.on('data', (data: Buffer) => {
|
stream.stderr.on('data', (data: Buffer) => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||||
socket.send(data);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
@@ -93,17 +91,14 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
if (!stream.writable) return;
|
if (!stream.writable) return;
|
||||||
|
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
// Raw terminal input
|
|
||||||
stream.write(Buffer.isBuffer(message) ? message : Buffer.from(message as string));
|
stream.write(Buffer.isBuffer(message) ? message : Buffer.from(message as string));
|
||||||
} else {
|
} else {
|
||||||
// Control message (JSON text frame)
|
|
||||||
try {
|
try {
|
||||||
const msg: ResizeMessage = JSON.parse(message.toString());
|
const msg: ResizeMessage = JSON.parse(message.toString());
|
||||||
if (msg.type === 'resize') {
|
if (msg.type === 'resize') {
|
||||||
stream.setWindow(msg.rows || 24, msg.cols || 80, 0, 0);
|
stream.setWindow(msg.rows || 24, msg.cols || 80, 0, 0);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-JSON text: treat as raw input
|
|
||||||
stream.write(message.toString());
|
stream.write(message.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +112,7 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ssh.on('error', (err) => {
|
ssh.on('error', (err) => {
|
||||||
fastify.log.error({ err }, 'SSH error');
|
fastify.log.error({ err }, 'SSH connection error');
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(`\r\n\x1b[31mSSH error: ${err.message}\x1b[0m\r\n`);
|
socket.send(`\r\n\x1b[31mSSH error: ${err.message}\x1b[0m\r\n`);
|
||||||
socket.close(1011, err.message);
|
socket.close(1011, err.message);
|
||||||
@@ -126,10 +121,7 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
|
|
||||||
ssh.connect(sshConfig);
|
ssh.connect(sshConfig);
|
||||||
|
|
||||||
// If WebSocket closes before SSH is ready
|
socket.on('close', () => ssh.end());
|
||||||
socket.on('close', () => {
|
|
||||||
ssh.end();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"lib": ["ES2021"],
|
"lib": ["ES2021"],
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"moduleResolution": "node"
|
"moduleResolution": "node"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.9'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
Reference in New Issue
Block a user