Refactor ssh connection for remote-terminal

This commit is contained in:
Nishan
2025-04-12 14:42:27 +05:30
parent d65ea253a8
commit 89808a64e3
8 changed files with 304 additions and 202 deletions

View File

@@ -10,6 +10,7 @@ RUN npm install --production
# Copy the application files
COPY server.js ./
COPY services/ ./services/
COPY public/ ./public/
# Ensure the public directory exists

View File

@@ -1,11 +1,12 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const { createProxyMiddleware } = require('http-proxy-middleware');
const http = require('http');
const socketio = require('socket.io');
const { Client } = require('ssh2');
const SSHTerminal = require('./services/ssh-terminal');
const PublicService = require('./services/public-service');
const RouteService = require('./services/route-service');
const VNCService = require('./services/vnc-service');
// Server configuration
const PORT = process.env.PORT || 3000;
@@ -25,211 +26,47 @@ const app = express();
const server = http.createServer(app);
const io = socketio(server);
// SSH terminal namespace
const sshIO = io.of('/ssh');
// Handle SSH connections
sshIO.on('connection', (socket) => {
console.log('New SSH terminal connection established');
let ssh = new Client();
// Connect to the SSH server
ssh.on('ready', () => {
console.log('SSH connection established');
// Create shell session
ssh.shell((err, stream) => {
if (err) {
console.error('SSH shell error:', err);
socket.emit('data', `Error: ${err.message}\r\n`);
socket.disconnect();
return;
}
// Handle incoming data from SSH server
stream.on('data', (data) => {
socket.emit('data', data.toString('utf-8'));
});
// Handle errors on stream
stream.on('close', () => {
console.log('SSH stream closed');
ssh.end();
socket.disconnect();
});
stream.on('error', (err) => {
console.error('SSH stream error:', err);
socket.emit('data', `Error: ${err.message}\r\n`);
});
// Handle incoming data from browser
socket.on('data', (data) => {
stream.write(data);
});
// Handle resize events
socket.on('resize', (dimensions) => {
if (dimensions && dimensions.cols && dimensions.rows) {
stream.setWindow(dimensions.rows, dimensions.cols, 0, 0);
}
});
// Handle socket disconnection
socket.on('disconnect', () => {
console.log('Client disconnected from SSH terminal');
stream.close();
ssh.end();
});
});
});
// Handle SSH connection errors
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
socket.emit('data', `SSH connection error: ${err.message}\r\n`);
socket.disconnect();
});
// Connect to SSH server
ssh.connect({
host: SSH_HOST,
port: SSH_PORT,
username: SSH_USER,
password: SSH_PASSWORD,
readyTimeout: 30000,
keepaliveInterval: 10000
});
// Initialize SSH Terminal
const sshTerminal = new SSHTerminal({
host: SSH_HOST,
port: SSH_PORT,
username: SSH_USER,
password: SSH_PASSWORD
});
// Create the public directory if it doesn't exist
const publicDir = path.join(__dirname, 'public');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
console.log('Created public directory');
}
// Initialize Public Service
const publicService = new PublicService(path.join(__dirname, 'public'));
publicService.initialize();
// Copy index.html to public directory if it doesn't exist
const indexHtmlSrc = path.join(__dirname, 'index.html');
const indexHtmlDest = path.join(publicDir, 'index.html');
if (fs.existsSync(indexHtmlSrc) && !fs.existsSync(indexHtmlDest)) {
fs.copyFileSync(indexHtmlSrc, indexHtmlDest);
console.log('Copied index.html to public directory');
}
// Initialize VNC Service
const vncService = new VNCService({
host: VNC_SERVICE_HOST,
port: VNC_SERVICE_PORT,
password: VNC_PASSWORD
});
// SSH terminal namespace
const sshIO = io.of('/ssh');
sshIO.on('connection', (socket) => {
sshTerminal.handleConnection(socket);
});
// Initialize Route Service
const routeService = new RouteService(publicService, vncService);
// Serve static files from the public directory
app.use(express.static(publicService.getPublicDir()));
// Setup VNC proxy
vncService.setupVNCProxy(app);
// Setup routes
routeService.setupRoutes(app);
// Enable CORS
app.use(cors());
// Serve static files from the public directory
app.use(express.static(path.join(__dirname, 'public')));
// Configure VNC proxy middleware
const vncProxyConfig = {
target: `http://${VNC_SERVICE_HOST}:${VNC_SERVICE_PORT}`,
changeOrigin: true,
ws: true,
secure: false,
pathRewrite: {
'^/vnc-proxy': ''
},
onProxyReq: (proxyReq, req, res) => {
// Log HTTP requests being proxied
console.log(`Proxying HTTP request to VNC server: ${req.url}`);
},
onProxyReqWs: (proxyReq, req, socket, options, head) => {
// Log WebSocket connections
console.log(`WebSocket connection established to VNC server: ${req.url}`);
},
onProxyRes: (proxyRes, req, res) => {
// Log the responses from VNC server
console.log(`Received response from VNC server for: ${req.url}`);
},
onError: (err, req, res) => {
console.error(`Proxy error: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end(`Proxy error: ${err.message}`);
}
}
};
// Middleware to enhance VNC URLs with authentication if needed
app.use('/vnc-proxy', (req, res, next) => {
// Check if the URL already has a password parameter
if (!req.query.password) {
// If no password provided, add default password
console.log('Adding default VNC password to request');
const separator = req.url.includes('?') ? '&' : '?';
req.url = `${req.url}${separator}password=${VNC_PASSWORD}`;
}
next();
}, createProxyMiddleware(vncProxyConfig));
// Direct WebSocket proxy to handle the websockify endpoint
app.use('/websockify', createProxyMiddleware({
...vncProxyConfig,
pathRewrite: {
'^/websockify': '/websockify'
},
ws: true,
onProxyReqWs: (proxyReq, req, socket, options, head) => {
// Log WebSocket connections to websockify
console.log(`WebSocket connection to websockify established: ${req.url}`);
// Add additional headers if needed
proxyReq.setHeader('Origin', `http://${VNC_SERVICE_HOST}:${VNC_SERVICE_PORT}`);
},
onError: (err, req, res) => {
console.error(`Websockify proxy error: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end(`Websockify proxy error: ${err.message}`);
}
}
}));
// API endpoint to get VNC server info
app.get('/api/vnc-info', (req, res) => {
res.json({
host: VNC_SERVICE_HOST,
port: VNC_SERVICE_PORT,
wsUrl: `/websockify`,
defaultPassword: VNC_PASSWORD,
status: 'connected'
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', message: 'Service is healthy' });
});
// Catch-all route to serve index.html for any other requests
app.get('*', (req, res) => {
// Special handling for exam page
if (req.path === '/exam') {
res.sendFile(path.join(__dirname, 'public', 'exam.html'));
}
// Special handling for results page
else if (req.path === '/results') {
res.sendFile(path.join(__dirname, 'public', 'results.html'));
}
else {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
}
});
// Handle errors
app.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(500).sendFile(path.join(__dirname, 'public', '50x.html'));
});
// Start the server
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);

View File

@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
class PublicService {
constructor(publicDir) {
this.publicDir = publicDir;
this.indexHtmlSrc = path.join(__dirname, '..', 'index.html');
this.indexHtmlDest = path.join(publicDir, 'index.html');
}
initialize() {
this.createPublicDirectory();
this.copyIndexHtml();
}
createPublicDirectory() {
if (!fs.existsSync(this.publicDir)) {
fs.mkdirSync(this.publicDir, { recursive: true });
console.log('Created public directory');
}
}
copyIndexHtml() {
if (fs.existsSync(this.indexHtmlSrc) && !fs.existsSync(this.indexHtmlDest)) {
fs.copyFileSync(this.indexHtmlSrc, this.indexHtmlDest);
console.log('Copied index.html to public directory');
}
}
getPublicDir() {
return this.publicDir;
}
}
module.exports = PublicService;

View File

@@ -0,0 +1,43 @@
const path = require('path');
class RouteService {
constructor(publicService, vncService) {
this.publicService = publicService;
this.vncService = vncService;
}
setupRoutes(app) {
// API endpoint to get VNC server info
app.get('/api/vnc-info', (req, res) => {
res.json(this.vncService.getVNCInfo());
});
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', message: 'Service is healthy' });
});
// Catch-all route to serve index.html for any other requests
app.get('*', (req, res) => {
// Special handling for exam page
if (req.path === '/exam') {
res.sendFile(path.join(this.publicService.getPublicDir(), 'exam.html'));
}
// Special handling for results page
else if (req.path === '/results') {
res.sendFile(path.join(this.publicService.getPublicDir(), 'results.html'));
}
else {
res.sendFile(path.join(this.publicService.getPublicDir(), 'index.html'));
}
});
// Handle errors
app.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(500).sendFile(path.join(this.publicService.getPublicDir(), '50x.html'));
});
}
}
module.exports = RouteService;

View File

@@ -0,0 +1,81 @@
const { Client } = require('ssh2');
class SSHTerminal {
constructor(config) {
this.config = {
host: config.host || 'remote-terminal',
port: config.port || 22,
username: config.username || 'candidate',
password: config.password || 'password',
readyTimeout: 30000,
keepaliveInterval: 10000
};
}
handleConnection(socket) {
console.log('New SSH terminal connection established');
let ssh = new Client();
ssh.on('ready', () => {
console.log('SSH connection established');
this.createShellSession(ssh, socket);
});
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
socket.emit('data', `SSH connection error: ${err.message}\r\n`);
socket.disconnect();
});
ssh.connect(this.config);
}
createShellSession(ssh, socket) {
ssh.shell((err, stream) => {
if (err) {
console.error('SSH shell error:', err);
socket.emit('data', `Error: ${err.message}\r\n`);
socket.disconnect();
return;
}
this.setupStreamHandlers(stream, socket, ssh);
});
}
setupStreamHandlers(stream, socket, ssh) {
stream.on('data', (data) => {
socket.emit('data', data.toString('utf-8'));
});
stream.on('close', () => {
console.log('SSH stream closed');
ssh.end();
socket.disconnect();
});
stream.on('error', (err) => {
console.error('SSH stream error:', err);
socket.emit('data', `Error: ${err.message}\r\n`);
});
socket.on('data', (data) => {
stream.write(data);
});
socket.on('resize', (dimensions) => {
if (dimensions && dimensions.cols && dimensions.rows) {
stream.setWindow(dimensions.rows, dimensions.cols, 0, 0);
}
});
socket.on('disconnect', () => {
console.log('Client disconnected from SSH terminal');
stream.close();
ssh.end();
});
}
}
module.exports = SSHTerminal;

View File

@@ -0,0 +1,93 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
class VNCService {
constructor(config) {
this.config = {
host: config.host || 'remote-desktop-service',
port: config.port || 6901,
password: config.password || 'bakku-the-wizard'
};
this.vncProxyConfig = {
target: `http://${this.config.host}:${this.config.port}`,
changeOrigin: true,
ws: true,
secure: false,
pathRewrite: {
'^/vnc-proxy': ''
},
onProxyReq: (proxyReq, req, res) => {
// Log HTTP requests being proxied
console.log(`Proxying HTTP request to VNC server: ${req.url}`);
},
onProxyReqWs: (proxyReq, req, socket, options, head) => {
// Log WebSocket connections
console.log(`WebSocket connection established to VNC server: ${req.url}`);
},
onProxyRes: (proxyRes, req, res) => {
// Log the responses from VNC server
console.log(`Received response from VNC server for: ${req.url}`);
},
onError: (err, req, res) => {
console.error(`Proxy error: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end(`Proxy error: ${err.message}`);
}
}
};
}
setupVNCProxy(app) {
// Middleware to enhance VNC URLs with authentication if needed
app.use('/vnc-proxy', (req, res, next) => {
// Check if the URL already has a password parameter
if (!req.query.password) {
// If no password provided, add default password
console.log('Adding default VNC password to request');
const separator = req.url.includes('?') ? '&' : '?';
req.url = `${req.url}${separator}password=${this.config.password}`;
}
next();
}, createProxyMiddleware(this.vncProxyConfig));
// Direct WebSocket proxy to handle the websockify endpoint
app.use('/websockify', createProxyMiddleware({
...this.vncProxyConfig,
pathRewrite: {
'^/websockify': '/websockify'
},
ws: true,
onProxyReqWs: (proxyReq, req, socket, options, head) => {
// Log WebSocket connections to websockify
console.log(`WebSocket connection to websockify established: ${req.url}`);
// Add additional headers if needed
proxyReq.setHeader('Origin', `http://${this.config.host}:${this.config.port}`);
},
onError: (err, req, res) => {
console.error(`Websockify proxy error: ${err.message}`);
if (res && res.writeHead) {
res.writeHead(500, {
'Content-Type': 'text/plain'
});
res.end(`Websockify proxy error: ${err.message}`);
}
}
}));
}
getVNCInfo() {
return {
host: this.config.host,
port: this.config.port,
wsUrl: `/websockify`,
defaultPassword: this.config.password,
status: 'connected'
};
}
}
module.exports = VNCService;

View File

@@ -212,7 +212,7 @@ services:
- LOG_LEVEL=info
- REDIS_HOST=redis
- REDIS_PORT=6379
- TRACK_METRICS=true # based on the environment variable TRACK_METRICS, the facilitator will send metrics to the metric server
- TRACK_METRICS=false # based on the environment variable TRACK_METRICS, the facilitator will send metrics to the metric server
restart: unless-stopped
depends_on:
- jumphost

View File

@@ -20,6 +20,18 @@ server {
proxy_cache_bypass $http_upgrade;
}
location /ssh/ {
proxy_pass http://webapp:3000/ssh/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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_cache_bypass $http_upgrade;
}
# Facilitator API endpoint
location /facilitator/api/ {
proxy_pass http://facilitator:3000/api/;