From db891ecb921d307b517517fdfabf5801bc39e5b4 Mon Sep 17 00:00:00 2001 From: Bill Church Date: Sat, 14 Dec 2024 13:47:38 +0000 Subject: [PATCH] chore: update config, routes, middleware, and other files to es style modules #383 --- app/app.js | 12 ++- app/config.js | 81 +++++++++---------- app/configSchema.js | 115 +++++++++++++-------------- app/connectionHandler.js | 39 +++++----- app/constants.js | 50 ++++++------ app/crypto-utils.js | 4 +- app/errors.js | 10 +-- app/io.js | 16 ++-- app/middleware.js | 28 ++++--- app/routes.js | 48 ++++++------ app/server.js | 10 +-- app/socket.js | 163 ++++++++++++++++++--------------------- app/ssh.js | 69 +++++++---------- app/utils.js | 58 ++++++-------- 14 files changed, 324 insertions(+), 379 deletions(-) diff --git a/app/app.js b/app/app.js index 24177c03..6b9dfe71 100644 --- a/app/app.js +++ b/app/app.js @@ -12,7 +12,7 @@ import { handleError, ConfigError } from './errors.js' import { createNamespacedDebug } from './logger.js' import { DEFAULTS, MESSAGES } from './constants.js' -const debug = createNamespacedDebug("app") +const debug = createNamespacedDebug('app') const sshRoutes = createRoutes(config) /** @@ -30,16 +30,14 @@ function createApp() { const { sessionMiddleware } = applyMiddleware(app, config) // Serve static files from the webssh2_client module with a custom prefix - app.use("/ssh/assets", express.static(clientPath)) + app.use('/ssh/assets', express.static(clientPath)) // Use the SSH routes - app.use("/ssh", sshRoutes) + app.use('/ssh', sshRoutes) return { app: app, sessionMiddleware: sessionMiddleware } } catch (err) { - throw new ConfigError( - `${MESSAGES.EXPRESS_APP_CONFIG_ERROR}: ${err.message}` - ) + throw new ConfigError(`${MESSAGES.EXPRESS_APP_CONFIG_ERROR}: ${err.message}`) } } @@ -59,7 +57,7 @@ function initializeServer() { // Start the server startServer(server, config) - debug("Server initialized") + debug('Server initialized') return { server: server, io: io, app: app } } catch (err) { diff --git a/app/config.js b/app/config.js index 6e6ea983..ae0fd29a 100644 --- a/app/config.js +++ b/app/config.js @@ -10,19 +10,19 @@ import { createNamespacedDebug } from './logger.js' import { ConfigError, handleError } from './errors.js' import { DEFAULTS } from './constants.js' -const debug = createNamespacedDebug("config") +const debug = createNamespacedDebug('config') const defaultConfig = { listen: { - ip: "0.0.0.0", - port: DEFAULTS.LISTEN_PORT + ip: '0.0.0.0', + port: DEFAULTS.LISTEN_PORT, }, http: { - origins: ["*:*"] + origins: ['*:*'], }, user: { name: null, - password: null + password: null, }, ssh: { host: null, @@ -35,47 +35,47 @@ const defaultConfig = { disableInteractiveAuth: false, algorithms: { cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm", - "aes128-gcm@openssh.com", - "aes256-gcm", - "aes256-gcm@openssh.com", - "aes256-cbc" + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-gcm', + 'aes128-gcm@openssh.com', + 'aes256-gcm', + 'aes256-gcm@openssh.com', + 'aes256-cbc', ], - compress: ["none", "zlib@openssh.com", "zlib"], - hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1"], + compress: ['none', 'zlib@openssh.com', 'zlib'], + hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1'], kex: [ - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha1" + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group14-sha1', ], serverHostKey: [ - "ecdsa-sha2-nistp256", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp521", - "ssh-rsa" - ] - } + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-rsa', + ], + }, }, header: { text: null, - background: "green" + background: 'green', }, options: { challengeButton: true, autoLog: false, allowReauth: true, allowReconnect: true, - allowReplay: true + allowReplay: true, }, session: { secret: process.env.WEBSSH_SESSION_SECRET || generateSecureSecret(), - name: "webssh2.sid" - } + name: 'webssh2.sid', + }, } import { fileURLToPath } from 'url' @@ -85,7 +85,7 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) function getConfigPath() { - return path.join(__dirname, "..", "config.json") + return path.join(__dirname, '..', 'config.json') } function loadConfig() { @@ -94,26 +94,21 @@ function loadConfig() { try { if (fs.existsSync(configPath)) { const providedConfig = readConfig.sync(configPath) - const mergedConfig = deepMerge( - JSON.parse(JSON.stringify(defaultConfig)), - providedConfig - ) + const mergedConfig = deepMerge(JSON.parse(JSON.stringify(defaultConfig)), providedConfig) if (process.env.PORT) { mergedConfig.listen.port = parseInt(process.env.PORT, 10) - debug("Using PORT from environment: %s", mergedConfig.listen.port) + debug('Using PORT from environment: %s', mergedConfig.listen.port) } const validatedConfig = validateConfig(mergedConfig) - debug("Merged and validated configuration") + debug('Merged and validated configuration') return validatedConfig } - debug("Missing config.json for webssh. Using default config") + debug('Missing config.json for webssh. Using default config') return defaultConfig } catch (err) { - const error = new ConfigError( - `Problem loading config.json for webssh: ${err.message}` - ) + const error = new ConfigError(`Problem loading config.json for webssh: ${err.message}`) handleError(error) return defaultConfig } @@ -165,8 +160,8 @@ const config = loadConfig() function getCorsConfig() { return { origin: config.http.origins, - methods: ["GET", "POST"], - credentials: true + methods: ['GET', 'POST'], + credentials: true, } } diff --git a/app/configSchema.js b/app/configSchema.js index 2e4e5406..c4692e6e 100644 --- a/app/configSchema.js +++ b/app/configSchema.js @@ -2,109 +2,102 @@ * Schema for validating the config */ const configSchema = { - type: "object", + type: 'object', properties: { listen: { - type: "object", + type: 'object', properties: { - ip: { type: "string", format: "ipv4" }, - port: { type: "integer", minimum: 1, maximum: 65535 } + ip: { type: 'string', format: 'ipv4' }, + port: { type: 'integer', minimum: 1, maximum: 65535 }, }, - required: ["ip", "port"] + required: ['ip', 'port'], }, http: { - type: "object", + type: 'object', properties: { origins: { - type: "array", - items: { type: "string" } - } + type: 'array', + items: { type: 'string' }, + }, }, - required: ["origins"] + required: ['origins'], }, user: { - type: "object", + type: 'object', properties: { - name: { type: ["string", "null"] }, - password: { type: ["string", "null"] }, - privateKey: { type: ["string", "null"] } + name: { type: ['string', 'null'] }, + password: { type: ['string', 'null'] }, + privateKey: { type: ['string', 'null'] }, }, - required: ["name", "password"] + required: ['name', 'password'], }, ssh: { - type: "object", + type: 'object', properties: { - host: { type: ["string", "null"] }, - port: { type: "integer", minimum: 1, maximum: 65535 }, - term: { type: "string" }, - readyTimeout: { type: "integer" }, - keepaliveInterval: { type: "integer" }, - keepaliveCountMax: { type: "integer" }, + host: { type: ['string', 'null'] }, + port: { type: 'integer', minimum: 1, maximum: 65535 }, + term: { type: 'string' }, + readyTimeout: { type: 'integer' }, + keepaliveInterval: { type: 'integer' }, + keepaliveCountMax: { type: 'integer' }, algorithms: { - type: "object", + type: 'object', properties: { kex: { - type: "array", - items: { type: "string" } + type: 'array', + items: { type: 'string' }, }, cipher: { - type: "array", - items: { type: "string" } + type: 'array', + items: { type: 'string' }, }, hmac: { - type: "array", - items: { type: "string" } + type: 'array', + items: { type: 'string' }, }, serverHostKey: { - type: "array", - items: { type: "string" } + type: 'array', + items: { type: 'string' }, }, compress: { - type: "array", - items: { type: "string" } - } + type: 'array', + items: { type: 'string' }, + }, }, - required: ["kex", "cipher", "hmac", "serverHostKey", "compress"] - } + required: ['kex', 'cipher', 'hmac', 'serverHostKey', 'compress'], + }, }, - required: [ - "host", - "port", - "term", - "readyTimeout", - "keepaliveInterval", - "keepaliveCountMax" - ] + required: ['host', 'port', 'term', 'readyTimeout', 'keepaliveInterval', 'keepaliveCountMax'], }, header: { - type: "object", + type: 'object', properties: { - text: { type: ["string", "null"] }, - background: { type: "string" } + text: { type: ['string', 'null'] }, + background: { type: 'string' }, }, - required: ["text", "background"] + required: ['text', 'background'], }, options: { - type: "object", + type: 'object', properties: { - challengeButton: { type: "boolean" }, - autoLog: { type: "boolean" }, - allowReauth: { type: "boolean" }, - allowReconnect: { type: "boolean" }, - allowReplay: { type: "boolean" } + challengeButton: { type: 'boolean' }, + autoLog: { type: 'boolean' }, + allowReauth: { type: 'boolean' }, + allowReconnect: { type: 'boolean' }, + allowReplay: { type: 'boolean' }, }, - required: ["challengeButton", "allowReauth", "allowReplay"] + required: ['challengeButton', 'allowReauth', 'allowReplay'], }, session: { - type: "object", + type: 'object', properties: { - secret: { type: "string" }, - name: { type: "string" } + secret: { type: 'string' }, + name: { type: 'string' }, }, - required: ["secret", "name"] - } + required: ['secret', 'name'], + }, }, - required: ["listen", "http", "user", "ssh", "header", "options"] + required: ['listen', 'http', 'user', 'ssh', 'header', 'options'], } export default configSchema diff --git a/app/connectionHandler.js b/app/connectionHandler.js index 784c1796..534f3758 100644 --- a/app/connectionHandler.js +++ b/app/connectionHandler.js @@ -3,15 +3,15 @@ import { fileURLToPath } from 'url' import { dirname } from 'path' -import fs from "fs" -import path from "path" -import { createNamespacedDebug } from "./logger.js" -import { HTTP, MESSAGES, DEFAULTS } from "./constants.js" -import { modifyHtml } from "./utils.js" +import fs from 'fs' +import path from 'path' +import { createNamespacedDebug } from './logger.js' +import { HTTP, MESSAGES, DEFAULTS } from './constants.js' +import { modifyHtml } from './utils.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const debug = createNamespacedDebug("connectionHandler") +const debug = createNamespacedDebug('connectionHandler') /** * Handle reading the file and processing the response. @@ -20,12 +20,9 @@ const debug = createNamespacedDebug("connectionHandler") * @param {Object} res - The Express response object. */ function handleFileRead(filePath, config, res) { - // eslint-disable-next-line consistent-return - fs.readFile(filePath, "utf8", (err, data) => { + fs.readFile(filePath, 'utf8', (err, data) => { if (err) { - return res - .status(HTTP.INTERNAL_SERVER_ERROR) - .send(MESSAGES.CLIENT_FILE_ERROR) + return res.status(HTTP.INTERNAL_SERVER_ERROR).send(MESSAGES.CLIENT_FILE_ERROR) } const modifiedHtml = modifyHtml(data, config) @@ -39,27 +36,27 @@ function handleFileRead(filePath, config, res) { * @param {Object} res - The Express response object. */ function handleConnection(req, res) { - debug("Handling connection req.path:", req.path) + debug('Handling connection req.path:', req.path) const clientPath = path.resolve( __dirname, - "..", - "node_modules", - "webssh2_client", - "client", - "public" + '..', + 'node_modules', + 'webssh2_client', + 'client', + 'public' ) const tempConfig = { socket: { - url: `${req.protocol}://${req.get("host")}`, - path: "/ssh/socket.io" + url: `${req.protocol}://${req.get('host')}`, + path: '/ssh/socket.io', }, - autoConnect: req.path.startsWith("/host/") // Automatically connect if path starts with /host/ + autoConnect: req.path.startsWith('/host/'), // Automatically connect if path starts with /host/ } const filePath = path.join(clientPath, DEFAULTS.CLIENT_FILE) handleFileRead(filePath, tempConfig, res) } -export default handleConnection \ No newline at end of file +export default handleConnection diff --git a/app/constants.js b/app/constants.js index 57e77e67..c1470236 100644 --- a/app/constants.js +++ b/app/constants.js @@ -12,15 +12,15 @@ const __dirname = dirname(__filename) * Error messages */ export const MESSAGES = { - INVALID_CREDENTIALS: "Invalid credentials format", - SSH_CONNECTION_ERROR: "SSH CONNECTION ERROR", - SHELL_ERROR: "SHELL ERROR", - CONFIG_ERROR: "CONFIG_ERROR", - UNEXPECTED_ERROR: "An unexpected error occurred", - EXPRESS_APP_CONFIG_ERROR: "Failed to configure Express app", - CLIENT_FILE_ERROR: "Error loading client file", - FAILED_SESSION_SAVE: "Failed to save session", - CONFIG_VALIDATION_ERROR: "Config validation error" + INVALID_CREDENTIALS: 'Invalid credentials format', + SSH_CONNECTION_ERROR: 'SSH CONNECTION ERROR', + SHELL_ERROR: 'SHELL ERROR', + CONFIG_ERROR: 'CONFIG_ERROR', + UNEXPECTED_ERROR: 'An unexpected error occurred', + EXPRESS_APP_CONFIG_ERROR: 'Failed to configure Express app', + CLIENT_FILE_ERROR: 'Error loading client file', + FAILED_SESSION_SAVE: 'Failed to save session', + CONFIG_VALIDATION_ERROR: 'Config validation error', } /** @@ -29,20 +29,20 @@ export const MESSAGES = { export const DEFAULTS = { SSH_PORT: 22, LISTEN_PORT: 2222, - SSH_TERM: "xterm-color", + SSH_TERM: 'xterm-color', IO_PING_TIMEOUT: 60000, IO_PING_INTERVAL: 25000, - IO_PATH: "/ssh/socket.io", + IO_PATH: '/ssh/socket.io', WEBSSH2_CLIENT_PATH: path.resolve( __dirname, - "..", - "node_modules", - "webssh2_client", - "client", - "public" + '..', + 'node_modules', + 'webssh2_client', + 'client', + 'public' ), - CLIENT_FILE: "client.htm", - MAX_AUTH_ATTEMPTS: 2 + CLIENT_FILE: 'client.htm', + MAX_AUTH_ATTEMPTS: 2, } /** * HTTP Related @@ -51,12 +51,12 @@ export const HTTP = { OK: 200, UNAUTHORIZED: 401, INTERNAL_SERVER_ERROR: 500, - AUTHENTICATE: "WWW-Authenticate", + AUTHENTICATE: 'WWW-Authenticate', REALM: 'Basic realm="WebSSH2"', - AUTH_REQUIRED: "Authentication required.", - COOKIE: "basicauth", - PATH: "/ssh/host/", - SAMESITE: "Strict", - SESSION_SID: "webssh2_sid", - CREDS_CLEARED: "Credentials cleared." + AUTH_REQUIRED: 'Authentication required.', + COOKIE: 'basicauth', + PATH: '/ssh/host/', + SAMESITE: 'Strict', + SESSION_SID: 'webssh2_sid', + CREDS_CLEARED: 'Credentials cleared.', } diff --git a/app/crypto-utils.js b/app/crypto-utils.js index b77e4710..aae238f0 100644 --- a/app/crypto-utils.js +++ b/app/crypto-utils.js @@ -1,11 +1,11 @@ // server // app/crypto-utils.js -import crypto from "crypto" +import crypto from 'crypto' /** * Generates a secure random session secret * @returns {string} A random 32-byte hex string */ export function generateSecureSecret() { - return crypto.randomBytes(32).toString("hex") + return crypto.randomBytes(32).toString('hex') } diff --git a/app/errors.js b/app/errors.js index 626ef48c..1d8161bb 100644 --- a/app/errors.js +++ b/app/errors.js @@ -4,7 +4,7 @@ import { logError, createNamespacedDebug } from './logger.js' import { HTTP, MESSAGES } from './constants.js' -const debug = createNamespacedDebug("errors") +const debug = createNamespacedDebug('errors') /** * Custom error for WebSSH2 @@ -49,17 +49,13 @@ function handleError(err, res) { logError(err.message, err) debug(err.message) if (res) { - res - .status(HTTP.INTERNAL_SERVER_ERROR) - .json({ error: err.message, code: err.code }) + res.status(HTTP.INTERNAL_SERVER_ERROR).json({ error: err.message, code: err.code }) } } else { logError(MESSAGES.UNEXPECTED_ERROR, err) debug(`handleError: ${MESSAGES.UNEXPECTED_ERROR}: %O`, err) if (res) { - res - .status(HTTP.INTERNAL_SERVER_ERROR) - .json({ error: MESSAGES.UNEXPECTED_ERROR }) + res.status(HTTP.INTERNAL_SERVER_ERROR).json({ error: MESSAGES.UNEXPECTED_ERROR }) } } } diff --git a/app/io.js b/app/io.js index 9e44bd14..b1b25823 100644 --- a/app/io.js +++ b/app/io.js @@ -1,9 +1,9 @@ -import socketIo from "socket.io" -import sharedsession from "express-socket.io-session" -import { createNamespacedDebug } from "./logger.js" -import { DEFAULTS } from "./constants.js" +import socketIo from 'socket.io' +import sharedsession from 'express-socket.io-session' +import { createNamespacedDebug } from './logger.js' +import { DEFAULTS } from './constants.js' -const debug = createNamespacedDebug("app") +const debug = createNamespacedDebug('app') /** * Configures Socket.IO with the given server @@ -18,17 +18,17 @@ export function configureSocketIO(server, sessionMiddleware, config) { path: DEFAULTS.IO_PATH, pingTimeout: DEFAULTS.IO_PING_TIMEOUT, pingInterval: DEFAULTS.IO_PING_INTERVAL, - cors: config.getCorsConfig() + cors: config.getCorsConfig(), }) // Share session with io sockets io.use( sharedsession(sessionMiddleware, { - autoSave: true + autoSave: true, }) ) - debug("IO configured") + debug('IO configured') return io } diff --git a/app/middleware.js b/app/middleware.js index f0d0afde..3e3d9adf 100644 --- a/app/middleware.js +++ b/app/middleware.js @@ -1,20 +1,19 @@ // server // app/middleware.js - // Scenario 2: Basic Auth +// Scenario 2: Basic Auth -import createDebug from "debug" -import session from "express-session" +import createDebug from 'debug' +import session from 'express-session' import bodyParser from 'body-parser' const { urlencoded, json } = bodyParser -const debug = createDebug("webssh2:middleware") -import basicAuth from "basic-auth" +const debug = createDebug('webssh2:middleware') +import basicAuth from 'basic-auth' import validator from 'validator' -import { HTTP } from "./constants.js" - +import { HTTP } from './constants.js' /** * Middleware function that handles HTTP Basic Authentication for the application. @@ -35,12 +34,11 @@ import { HTTP } from "./constants.js" * with the appropriate WWW-Authenticate header. */ export function createAuthMiddleware(config) { - // eslint-disable-next-line consistent-return return (req, res, next) => { // Check if username and either password or private key is configured if (config.user.name && (config.user.password || config.user.privateKey)) { req.session.sshCredentials = { - username: config.user.name + username: config.user.name, } // Add credentials based on what's available @@ -57,7 +55,7 @@ export function createAuthMiddleware(config) { // Scenario 2: Basic Auth // If no configured credentials, fall back to Basic Auth - debug("auth: Basic Auth") + debug('auth: Basic Auth') const credentials = basicAuth(req) if (!credentials) { res.setHeader(HTTP.AUTHENTICATE, HTTP.REALM) @@ -67,7 +65,7 @@ export function createAuthMiddleware(config) { // Validate and sanitize credentials req.session.sshCredentials = { username: validator.escape(credentials.name), - password: credentials.pass + password: credentials.pass, } req.session.usedBasicAuth = true next() @@ -84,7 +82,7 @@ export function createSessionMiddleware(config) { secret: config.session.secret, resave: false, saveUninitialized: true, - name: config.session.name + name: config.session.name, }) } @@ -105,12 +103,12 @@ export function createCookieMiddleware() { if (req.session.sshCredentials) { const cookieData = { host: req.session.sshCredentials.host, - port: req.session.sshCredentials.port + port: req.session.sshCredentials.port, } res.cookie(HTTP.COOKIE, JSON.stringify(cookieData), { httpOnly: false, path: HTTP.PATH, - sameSite: HTTP.SAMESITE + sameSite: HTTP.SAMESITE, }) } next() @@ -130,7 +128,7 @@ export function applyMiddleware(app, config) { app.use(createBodyParserMiddleware()) app.use(createCookieMiddleware()) - debug("applyMiddleware") + debug('applyMiddleware') return { sessionMiddleware } } diff --git a/app/routes.js b/app/routes.js index 6cadcb33..f091b2fe 100644 --- a/app/routes.js +++ b/app/routes.js @@ -1,23 +1,29 @@ // server // app/routes.js -import express from "express" -import { getValidatedHost, getValidatedPort, maskSensitiveData, validateSshTerm, parseEnvVars } from "./utils.js" -import handleConnection from "./connectionHandler.js" -import { createNamespacedDebug } from "./logger.js" -import { createAuthMiddleware } from "./middleware.js" -import { ConfigError, handleError } from "./errors.js" -import { HTTP } from "./constants.js" - -const debug = createNamespacedDebug("routes") +import express from 'express' +import { + getValidatedHost, + getValidatedPort, + maskSensitiveData, + validateSshTerm, + parseEnvVars, +} from './utils.js' +import handleConnection from './connectionHandler.js' +import { createNamespacedDebug } from './logger.js' +import { createAuthMiddleware } from './middleware.js' +import { ConfigError, handleError } from './errors.js' +import { HTTP } from './constants.js' + +const debug = createNamespacedDebug('routes') export function createRoutes(config) { const router = express.Router() const auth = createAuthMiddleware(config) // Scenario 1: No auth required, uses websocket authentication instead - router.get("/", (req, res) => { - debug("router.get./: Accessed / route") + router.get('/', (req, res) => { + debug('router.get./: Accessed / route') handleConnection(req, res) }) @@ -35,19 +41,17 @@ export function createRoutes(config) { * @param {Object} req - The Express request object * @param {Object} res - The Express response object */ - router.get("/host/", auth, (req, res) => { + router.get('/host/', auth, (req, res) => { debug(`router.get.host: /ssh/host/ route`) const envVars = parseEnvVars(req.query.env) if (envVars) { req.session.envVars = envVars - debug("routes: Parsed environment variables: %O", envVars) + debug('routes: Parsed environment variables: %O', envVars) } try { if (!config.ssh.host) { - throw new ConfigError( - "Host parameter required when default host not configured" - ) + throw new ConfigError('Host parameter required when default host not configured') } const { host } = config.ssh @@ -65,7 +69,7 @@ export function createRoutes(config) { const sanitizedCredentials = maskSensitiveData( JSON.parse(JSON.stringify(req.session.sshCredentials)) ) - debug("/ssh/host/ Credentials: ", sanitizedCredentials) + debug('/ssh/host/ Credentials: ', sanitizedCredentials) handleConnection(req, res, { host: host }) } catch (err) { @@ -75,12 +79,12 @@ export function createRoutes(config) { }) // Scenario 2: Auth required, uses HTTP Basic Auth - router.get("/host/:host?", auth, (req, res) => { + router.get('/host/:host?', auth, (req, res) => { debug(`router.get.host: /ssh/host/${req.params.host} route`) const envVars = parseEnvVars(req.query.env) if (envVars) { req.session.envVars = envVars - debug("routes: Parsed environment variables: %O", envVars) + debug('routes: Parsed environment variables: %O', envVars) } try { @@ -102,7 +106,7 @@ export function createRoutes(config) { const sanitizedCredentials = maskSensitiveData( JSON.parse(JSON.stringify(req.session.sshCredentials)) ) - debug("/ssh/host/ Credentials: ", sanitizedCredentials) + debug('/ssh/host/ Credentials: ', sanitizedCredentials) handleConnection(req, res, { host: host }) } catch (err) { @@ -112,12 +116,12 @@ export function createRoutes(config) { }) // Clear credentials route - router.get("/clear-credentials", (req, res) => { + router.get('/clear-credentials', (req, res) => { req.session.sshCredentials = null res.status(HTTP.OK).send(HTTP.CREDENTIALS_CLEARED) }) - router.get("/force-reconnect", (req, res) => { + router.get('/force-reconnect', (req, res) => { req.session.sshCredentials = null res.status(HTTP.UNAUTHORIZED).send(HTTP.AUTH_REQUIRED) }) diff --git a/app/server.js b/app/server.js index bc6f2719..d94e3698 100644 --- a/app/server.js +++ b/app/server.js @@ -1,4 +1,4 @@ -import http from "http" +import http from 'http' // const { createNamespacedDebug } = require("./logger") // const debug = createNamespacedDebug("server") @@ -16,7 +16,7 @@ export function createServer(app) { * @param {Error} err - The error object */ function handleServerError(err) { - console.error("HTTP Server ERROR: %O", err) + console.error('HTTP Server ERROR: %O', err) } /** @@ -26,10 +26,8 @@ function handleServerError(err) { */ export function startServer(server, config) { server.listen(config.listen.port, config.listen.ip, () => { - console.log( - `startServer: listening on ${config.listen.ip}:${config.listen.port}` - ) + console.log(`startServer: listening on ${config.listen.ip}:${config.listen.port}`) }) - server.on("error", handleServerError) + server.on('error', handleServerError) } diff --git a/app/socket.js b/app/socket.js index e8135532..a897ef4a 100644 --- a/app/socket.js +++ b/app/socket.js @@ -1,19 +1,15 @@ // server // app/socket.js -import validator from "validator" -import { EventEmitter } from "events" -import SSHConnection from "./ssh.js" -import { createNamespacedDebug } from "./logger.js" -import { SSHConnectionError, handleError } from "./errors.js" - -const debug = createNamespacedDebug("socket") -import { - isValidCredentials, - maskSensitiveData, - validateSshTerm -} from "./utils.js" -import { MESSAGES } from "./constants.js" +import validator from 'validator' +import { EventEmitter } from 'events' +import SSHConnection from './ssh.js' +import { createNamespacedDebug } from './logger.js' +import { SSHConnectionError, handleError } from './errors.js' + +const debug = createNamespacedDebug('socket') +import { isValidCredentials, maskSensitiveData, validateSshTerm } from './utils.js' +import { MESSAGES } from './constants.js' class WebSSH2Socket extends EventEmitter { constructor(socket, config) { @@ -31,7 +27,7 @@ class WebSSH2Socket extends EventEmitter { port: null, term: null, cols: null, - rows: null + rows: null, } this.initializeSocketEvents() @@ -54,25 +50,25 @@ class WebSSH2Socket extends EventEmitter { // Check if interactive auth is disabled if (this.config.ssh.disableInteractiveAuth) { debug(`handleConnection: ${this.socket.id}, interactive auth disabled`) - this.handleError("Interactive Auth Disabled") + this.handleError('Interactive Auth Disabled') return } debug(`handleConnection: ${this.socket.id}, emitting request_auth`) - this.socket.emit("authentication", { action: "request_auth" }) + this.socket.emit('authentication', { action: 'request_auth' }) } - this.ssh.on("keyboard-interactive", data => { + this.ssh.on('keyboard-interactive', (data) => { this.handleKeyboardInteractive(data) }) - this.socket.on("authenticate", creds => { + this.socket.on('authenticate', (creds) => { this.handleAuthenticate(creds) }) - this.socket.on("terminal", data => { + this.socket.on('terminal', (data) => { this.handleTerminal(data) }) - this.socket.on("disconnect", reason => { + this.socket.on('disconnect', (reason) => { this.handleConnectionClose(reason) }) } @@ -83,27 +79,24 @@ class WebSSH2Socket extends EventEmitter { // Send the keyboard-interactive request to the client this.socket.emit( - "authentication", + 'authentication', Object.assign( { - action: "keyboard-interactive" + action: 'keyboard-interactive', }, data ) ) // Set up a one-time listener for the client's response - this.socket.once("authentication", clientResponse => { + this.socket.once('authentication', (clientResponse) => { const maskedclientResponse = maskSensitiveData(clientResponse, { - properties: ["responses"] + properties: ['responses'], }) - debug( - "handleKeyboardInteractive: Client response masked %O", - maskedclientResponse - ) - if (clientResponse.action === "keyboard-interactive") { + debug('handleKeyboardInteractive: Client response masked %O', maskedclientResponse) + if (clientResponse.action === 'keyboard-interactive') { // Forward the client's response to the SSH connection - self.ssh.emit("keyboard-interactive-response", clientResponse.responses) + self.ssh.emit('keyboard-interactive-response', clientResponse.responses) } }) } @@ -113,16 +106,14 @@ class WebSSH2Socket extends EventEmitter { if (isValidCredentials(creds)) { // Set term if provided, otherwise use config default - this.sessionState.term = validateSshTerm(creds.term) - ? creds.term - : this.config.ssh.term + this.sessionState.term = validateSshTerm(creds.term) ? creds.term : this.config.ssh.term this.initializeConnection(creds) } else { debug(`handleAuthenticate: ${this.socket.id}, CREDENTIALS INVALID`) - this.socket.emit("authentication", { + this.socket.emit('authentication', { success: false, - message: "Invalid credentials format" + message: 'Invalid credentials format', }) } } @@ -148,40 +139,38 @@ class WebSSH2Socket extends EventEmitter { privateKey: creds.privateKey, passphrase: creds.passphrase, host: creds.host, - port: creds.port + port: creds.port, }) - const authResult = { action: "auth_result", success: true } - this.socket.emit("authentication", authResult) + const authResult = { action: 'auth_result', success: true } + this.socket.emit('authentication', authResult) const permissions = { autoLog: this.config.options.autoLog || false, allowReplay: this.config.options.allowReplay || false, allowReconnect: this.config.options.allowReconnect || false, - allowReauth: this.config.options.allowReauth || false + allowReauth: this.config.options.allowReauth || false, } - this.socket.emit("permissions", permissions) + this.socket.emit('permissions', permissions) - this.updateElement("footer", `ssh://${creds.host}:${creds.port}`) + this.updateElement('footer', `ssh://${creds.host}:${creds.port}`) if (this.config.header && this.config.header.text !== null) { - this.updateElement("header", this.config.header.text) + this.updateElement('header', this.config.header.text) } - this.socket.emit("getTerminal", true) + this.socket.emit('getTerminal', true) }) .catch((err) => { debug( `initializeConnection: SSH CONNECTION ERROR: ${this.socket.id}, Host: ${creds.host}, Error: ${err.message}` ) const errorMessage = - err instanceof SSHConnectionError - ? err.message - : "SSH connection failed" - this.socket.emit("authentication", { - action: "auth_result", + err instanceof SSHConnectionError ? err.message : 'SSH connection failed' + this.socket.emit('authentication', { + action: 'auth_result', success: false, - message: errorMessage + message: errorMessage, }) }) } @@ -192,11 +181,15 @@ class WebSSH2Socket extends EventEmitter { */ handleTerminal(data) { const { term, rows, cols } = data - if (term && validateSshTerm(term)) this.sessionState.term = term - if (rows && validator.isInt(rows.toString())) + if (term && validateSshTerm(term)) { + this.sessionState.term = term + } + if (rows && validator.isInt(rows.toString())) { this.sessionState.rows = parseInt(rows, 10) - if (cols && validator.isInt(cols.toString())) + } + if (cols && validator.isInt(cols.toString())) { this.sessionState.cols = parseInt(cols, 10) + } this.createShell() } @@ -213,47 +206,49 @@ class WebSSH2Socket extends EventEmitter { { term: this.sessionState.term, cols: this.sessionState.cols, - rows: this.sessionState.rows + rows: this.sessionState.rows, }, envVars ) .then((stream) => { - stream.on("data", (data) => { - this.socket.emit("data", data.toString("utf-8")) + stream.on('data', (data) => { + this.socket.emit('data', data.toString('utf-8')) }) // stream.stderr.on("data", data => debug(`STDERR: ${data}`)) // needed for shell.exec - stream.on("close", (code, signal) => { - debug("close: SSH Stream closed") + stream.on('close', (code, signal) => { + debug('close: SSH Stream closed') this.handleConnectionClose(code, signal) }) - stream.on("end", () => { - debug("end: SSH Stream ended") + stream.on('end', () => { + debug('end: SSH Stream ended') }) - stream.on("error", err => { - debug("error: SSH Stream error %O", err) + stream.on('error', (err) => { + debug('error: SSH Stream error %O', err) }) - this.socket.on("data", data => { + this.socket.on('data', (data) => { stream.write(data) }) - this.socket.on("control", controlData => { + this.socket.on('control', (controlData) => { this.handleControl(controlData) }) - this.socket.on("resize", data => { + this.socket.on('resize', (data) => { this.handleResize(data) }) }) - .catch(err => this.handleError("createShell: ERROR", err)) + .catch((err) => this.handleError('createShell: ERROR', err)) } handleResize(data) { const { rows, cols } = data - if (rows && validator.isInt(rows.toString())) + if (rows && validator.isInt(rows.toString())) { this.sessionState.rows = parseInt(rows, 10) - if (cols && validator.isInt(cols.toString())) + } + if (cols && validator.isInt(cols.toString())) { this.sessionState.cols = parseInt(cols, 10) + } this.ssh.resizeTerminal(this.sessionState.rows, this.sessionState.cols) } @@ -262,19 +257,14 @@ class WebSSH2Socket extends EventEmitter { * @param {string} controlData - The control command received. */ handleControl(controlData) { - if ( - validator.isIn(controlData, ["replayCredentials", "reauth"]) && - this.ssh.stream - ) { - if (controlData === "replayCredentials") { + if (validator.isIn(controlData, ['replayCredentials', 'reauth']) && this.ssh.stream) { + if (controlData === 'replayCredentials') { this.replayCredentials() - } else if (controlData === "reauth") { + } else if (controlData === 'reauth') { this.handleReauth() } } else { - console.warn( - `handleControl: Invalid control command received: ${controlData}` - ) + console.warn(`handleControl: Invalid control command received: ${controlData}`) } } @@ -293,7 +283,7 @@ class WebSSH2Socket extends EventEmitter { handleReauth() { if (this.config.options.allowReauth) { this.clearSessionCredentials() - this.socket.emit("authentication", { action: "reauth" }) + this.socket.emit('authentication', { action: 'reauth' }) } } @@ -303,9 +293,9 @@ class WebSSH2Socket extends EventEmitter { * @param {Error} err - The error object. */ handleError(context, err) { - const errorMessage = err ? `: ${err.message}` : "" + const errorMessage = err ? `: ${err.message}` : '' handleError(new SSHConnectionError(`SSH ${context}${errorMessage}`)) - this.socket.emit("ssherror", `SSH ${context}${errorMessage}`) + this.socket.emit('ssherror', `SSH ${context}${errorMessage}`) this.handleConnectionClose() } @@ -315,7 +305,7 @@ class WebSSH2Socket extends EventEmitter { * @param {any} value - The new value for the element. */ updateElement(element, value) { - this.socket.emit("updateUI", { element, value }) + this.socket.emit('updateUI', { element, value }) } /** @@ -324,9 +314,7 @@ class WebSSH2Socket extends EventEmitter { */ handleConnectionClose(code, signal) { this.ssh.end() - debug( - `handleConnectionClose: ${this.socket.id}, Code: ${code}, Signal: ${signal}` - ) + debug(`handleConnectionClose: ${this.socket.id}, Code: ${code}, Signal: ${signal}`) this.socket.disconnect(true) } @@ -343,16 +331,17 @@ class WebSSH2Socket extends EventEmitter { this.sessionState.username = null this.sessionState.password = null - this.socket.handshake.session.save(err => { - if (err) + this.socket.handshake.session.save((err) => { + if (err) { console.error( `clearSessionCredentials: ${MESSAGES.FAILED_SESSION_SAVE} ${this.socket.id}:`, err ) + } }) } } -export default function(io, config) { - io.on("connection", socket => new WebSSH2Socket(socket, config)) +export default function (io, config) { + io.on('connection', (socket) => new WebSSH2Socket(socket, config)) } diff --git a/app/ssh.js b/app/ssh.js index 2b7f05bd..02d24c80 100644 --- a/app/ssh.js +++ b/app/ssh.js @@ -1,14 +1,14 @@ // server // app/ssh.js -import { Client as SSH } from "ssh2" -import { EventEmitter } from "events" -import { createNamespacedDebug } from "./logger.js" -import { SSHConnectionError, handleError } from "./errors.js" -import { maskSensitiveData } from "./utils.js" -import { DEFAULTS } from "./constants.js" +import { Client as SSH } from 'ssh2' +import { EventEmitter } from 'events' +import { createNamespacedDebug } from './logger.js' +import { SSHConnectionError, handleError } from './errors.js' +import { maskSensitiveData } from './utils.js' +import { DEFAULTS } from './constants.js' -const debug = createNamespacedDebug("ssh") +const debug = createNamespacedDebug('ssh') /** * SSHConnection class handles SSH connections and operations. @@ -41,7 +41,7 @@ class SSHConnection extends EventEmitter { * @returns {Promise} - A promise that resolves with the SSH connection */ connect(creds) { - debug("connect: %O", maskSensitiveData(creds)) + debug('connect: %O', maskSensitiveData(creds)) this.creds = creds return new Promise((resolve, reject) => { if (this.conn) { @@ -53,7 +53,7 @@ class SSHConnection extends EventEmitter { // First try with key authentication if available const sshConfig = this.getSSHConfig(creds, true) - debug("Initial connection config: %O", maskSensitiveData(sshConfig)) + debug('Initial connection config: %O', maskSensitiveData(sshConfig)) this.setupConnectionHandlers(resolve, reject) @@ -71,24 +71,22 @@ class SSHConnection extends EventEmitter { * @param {Function} reject - Promise reject function */ setupConnectionHandlers(resolve, reject) { - this.conn.on("ready", () => { + this.conn.on('ready', () => { debug(`connect: ready: ${this.creds.host}`) resolve(this.conn) }) - this.conn.on("error", (err) => { + this.conn.on('error', (err) => { debug(`connect: error: ${err.message}`) // Check if this is an authentication error and we haven't exceeded max attempts if (this.authAttempts < DEFAULTS.MAX_AUTH_ATTEMPTS) { this.authAttempts += 1 - debug( - `Authentication attempt ${this.authAttempts} failed, trying password authentication` - ) + debug(`Authentication attempt ${this.authAttempts} failed, trying password authentication`) // Only try password auth if we have a password if (this.creds.password) { - debug("Retrying with password authentication") + debug('Retrying with password authentication') // Disconnect current connection if (this.conn) { @@ -102,14 +100,14 @@ class SSHConnection extends EventEmitter { this.setupConnectionHandlers(resolve, reject) this.conn.connect(passwordConfig) } else { - debug("No password available, requesting password from client") - this.emit("password-prompt", { + debug('No password available, requesting password from client') + this.emit('password-prompt', { host: this.creds.host, - username: this.creds.username + username: this.creds.username, }) // Listen for password response one time - this.once("password-response", (password) => { + this.once('password-response', (password) => { this.creds.password = password const newConfig = this.getSSHConfig(this.creds, false) this.setupConnectionHandlers(resolve, reject) @@ -118,26 +116,15 @@ class SSHConnection extends EventEmitter { } } else { // We've exhausted all authentication attempts - const error = new SSHConnectionError( - "All authentication methods failed" - ) + const error = new SSHConnectionError('All authentication methods failed') handleError(error) reject(error) } }) - this.conn.on( - "keyboard-interactive", - (name, instructions, lang, prompts, finish) => { - this.handleKeyboardInteractive( - name, - instructions, - lang, - prompts, - finish - ) - } - ) + this.conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { + this.handleKeyboardInteractive(name, instructions, lang, prompts, finish) + }) } /** @@ -156,19 +143,19 @@ class SSHConnection extends EventEmitter { readyTimeout: this.config.ssh.readyTimeout, keepaliveInterval: this.config.ssh.keepaliveInterval, keepaliveCountMax: this.config.ssh.keepaliveCountMax, - debug: createNamespacedDebug("ssh2") + debug: createNamespacedDebug('ssh2'), } // Try private key first if available and useKey is true if (useKey && (creds.privateKey || this.config.user.privateKey)) { - debug("Using private key authentication") + debug('Using private key authentication') const privateKey = creds.privateKey || this.config.user.privateKey if (!this.validatePrivateKey(privateKey)) { - throw new SSHConnectionError("Invalid private key format") + throw new SSHConnectionError('Invalid private key format') } config.privateKey = privateKey } else if (creds.password) { - debug("Using password authentication") + debug('Using password authentication') config.password = creds.password } @@ -183,7 +170,7 @@ class SSHConnection extends EventEmitter { */ shell(options, envVars) { const shellOptions = Object.assign({}, options, { - env: this.getEnvironment(envVars) + env: this.getEnvironment(envVars), }) return new Promise((resolve, reject) => { @@ -230,7 +217,7 @@ class SSHConnection extends EventEmitter { */ getEnvironment(envVars) { const env = { - TERM: this.config.ssh.term + TERM: this.config.ssh.term, } if (envVars) { @@ -243,4 +230,4 @@ class SSHConnection extends EventEmitter { } } -export default SSHConnection \ No newline at end of file +export default SSHConnection diff --git a/app/utils.js b/app/utils.js index 2f5976f7..1afa32c0 100644 --- a/app/utils.js +++ b/app/utils.js @@ -7,7 +7,7 @@ import { createNamespacedDebug } from './logger.js' import { DEFAULTS, MESSAGES } from './constants.js' import configSchema from './configSchema.js' -const debug = createNamespacedDebug("utils") +const debug = createNamespacedDebug('utils') /** * Deep merges two objects @@ -17,13 +17,9 @@ const debug = createNamespacedDebug("utils") */ export function deepMerge(target, source) { const output = Object.assign({}, target) - Object.keys(source).forEach(key => { + Object.keys(source).forEach((key) => { if (Object.hasOwnProperty.call(source, key)) { - if ( - source[key] instanceof Object && - !Array.isArray(source[key]) && - source[key] !== null - ) { + if (source[key] instanceof Object && !Array.isArray(source[key]) && source[key] !== null) { output[key] = deepMerge(output[key] || {}, source[key]) } else { output[key] = source[key] @@ -64,17 +60,14 @@ export function getValidatedHost(host) { export function getValidatedPort(portInput) { const defaultPort = DEFAULTS.SSH_PORT const port = defaultPort - debug("getValidatedPort: input: %O", portInput) + debug('getValidatedPort: input: %O', portInput) if (portInput) { if (validator.isInt(portInput, { min: 1, max: 65535 })) { return parseInt(portInput, 10) } } - debug( - "getValidatedPort: port not specified or is invalid, setting port to: %O", - port - ) + debug('getValidatedPort: port not specified or is invalid, setting port to: %O', port) return port } @@ -95,9 +88,9 @@ export function getValidatedPort(portInput) { export function isValidCredentials(creds) { const hasRequiredFields = !!( creds && - typeof creds.username === "string" && - typeof creds.host === "string" && - typeof creds.port === "number" + typeof creds.username === 'string' && + typeof creds.host === 'string' && + typeof creds.port === 'number' ) if (!hasRequiredFields) { @@ -105,9 +98,8 @@ export function isValidCredentials(creds) { } // Must have either password or privateKey/privateKey - const hasPassword = typeof creds.password === "string" - const hasPrivateKey = - typeof creds.privateKey === "string" || typeof creds.privateKey === "string" + const hasPassword = typeof creds.password === 'string' + const hasPrivateKey = typeof creds.privateKey === 'string' || typeof creds.privateKey === 'string' return hasPassword || hasPrivateKey } @@ -128,8 +120,7 @@ export function validateSshTerm(term) { } const validatedSshTerm = - validator.isLength(term, { min: 1, max: 30 }) && - validator.matches(term, /^[a-zA-Z0-9.-]+$/) + validator.isLength(term, { min: 1, max: 30 }) && validator.matches(term, /^[a-zA-Z0-9.-]+$/) return validatedSshTerm ? term : null } @@ -146,9 +137,7 @@ export function validateConfig(config) { const validate = ajv.compile(configSchema) const valid = validate(config) if (!valid) { - throw new Error( - `${MESSAGES.CONFIG_VALIDATION_ERROR}: ${ajv.errorsText(validate.errors)}` - ) + throw new Error(`${MESSAGES.CONFIG_VALIDATION_ERROR}: ${ajv.errorsText(validate.errors)}`) } return config } @@ -160,14 +149,11 @@ export function validateConfig(config) { * @returns {string} - The modified HTML content. */ export function modifyHtml(html, config) { - debug("modifyHtml") - const modifiedHtml = html.replace( - /(src|href)="(?!http|\/\/)/g, - '$1="/ssh/assets/' - ) + debug('modifyHtml') + const modifiedHtml = html.replace(/(src|href)="(?!http|\/\/)/g, '$1="/ssh/assets/') return modifiedHtml.replace( - "window.webssh2Config = null;", + 'window.webssh2Config = null;', `window.webssh2Config = ${JSON.stringify(config)};` ) } @@ -186,7 +172,7 @@ export function modifyHtml(html, config) { */ export function maskSensitiveData(obj, options) { const defaultOptions = {} - debug("maskSensitiveData") + debug('maskSensitiveData') const maskingOptions = Object.assign({}, defaultOptions, options || {}) const maskedObject = maskObject(obj, maskingOptions) @@ -219,14 +205,18 @@ export function isValidEnvValue(value) { * @returns {Object|null} - Object containing validated env vars or null if invalid */ export function parseEnvVars(envString) { - if (!envString) return null + if (!envString) { + return null + } const envVars = {} - const pairs = envString.split(",") + const pairs = envString.split(',') for (let i = 0; i < pairs.length; i += 1) { - const pair = pairs[i].split(":") - if (pair.length !== 2) continue + const pair = pairs[i].split(':') + if (pair.length !== 2) { + continue + } const key = pair[0].trim() const value = pair[1].trim()