webssh2/app/socket.js
2024-08-16 19:22:17 +00:00

564 lines
17 KiB
JavaScript

// server
// app/socket.js
"use strict"
const createDebug = require("debug")
const { header } = require("./config")
const debug = createDebug("webssh2:socket")
const SSH = require("ssh2").Client
const { sanitizeObject } = require("./utils")
const session = require("express-session")
/**
* Handles WebSocket connections for SSH
* @param {import('socket.io').Server} io - The Socket.IO server instance
* @param {Object} config - The configuration object
*/
module.exports = function (io, config) {
io.on("connection", (socket) => handleConnection(socket, config))
}
/**
* Handles a new WebSocket connection
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Object} config - The configuration object
*/
function handleConnection(socket, config) {
let conn = null
let stream = null
let sessionState = {
connected: false,
authenticated: false,
host: null,
port: null,
username: null,
password: null,
term: null,
cols: null,
rows: null,
config: config
}
debug(`handleConnection: ${socket.id}, URL: ${socket.handshake.url}`)
// removeExistingListeners(socket)
setupInitialSocketListeners(socket, sessionState)
// Check for HTTP Basic Auth credentials
if (socket.handshake.session.sshCredentials) {
const creds = socket.handshake.session.sshCredentials
debug(
`handleConnection: creds from session: ${socket.id}, Host: ${creds.host}:`,
sanitizeObject(creds)
)
handleAuthenticate(socket, creds)
return
}
// Emit an event to the client to request authentication
const authenticated = sessionState.authenticated
if (!authenticated) {
debug(
`Requesting authentication for ${socket.id} and authenticated is ${authenticated}`
)
socket.emit("authentication", { action: "request_auth" })
}
/**
* Sets up initial socket event listeners
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Object} config - The configuration object
*/
function setupInitialSocketListeners(socket, sessionState) {
config = sessionState.config
socket.on("error", (error) =>
console.error(`Socket error for ${socket.id}:`, error)
)
socket.on("authenticate", (creds) =>
handleAuthenticate(socket, creds, sessionState)
)
socket.on("disconnect", (reason) => {
debug(`Client ${socket.id} disconnected. Reason: ${reason}`)
debug("Socket state at disconnect:", socket.conn.transport.readyState)
if (conn) {
conn.end()
conn = null
}
if (stream) {
stream.end()
stream = null
}
})
}
/**
* Handles authentication attempts
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*
* @param {Credentials} creds - The credentials for authentication
* @param {Object} config - The configuration object
*/
function handleAuthenticate(socket, creds) {
const config = sessionState.config
// {
// "host": "192.168.0.20",
// "port": 22,
// "username": "test123",
// "password": "Seven888!",
// "term": "xterm-color",
// "readyTimeout": 20000,
// "cursorBlink": "true",
// "cols": 151,
// "rows": 53
// }
debug("handleAuthenticate: ", JSON.stringify(sanitizeObject(creds)))
if (isValidCredentials(socket, creds)) {
creds.term !== null && (sessionState.term = creds.term)
initializeConnection(socket, creds)
return
}
// Handle invalid credentials scenario
debug(`CREDENTIALS INVALID: ${socket.id}, Host: ${creds.host}`)
socket.emit("authentication", {
success: false,
message: "Invalid credentials format"
})
}
/**
* Initializes an SSH connection
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Credentials} creds - The user credentials
* @param {Object} config - The configuration object
*/
function initializeConnection(socket, creds) {
const config = sessionState.config
debug(
`initializeConnection: INITIALIZING SSH CONNECTION: ${socket.id}, Host: ${creds.host}`
)
if (conn) {
conn.end()
}
conn = new SSH()
socket.on("terminal", (data) => handleTerminal(socket, stream, data))
socket.emit("getTerminal", true)
conn.connect(getSSHConfig(creds, config))
conn.on("ready", () => {
sessionState.authenticated = true
sessionState.connected = true
sessionState.username = creds.username
sessionState.password = creds.password
sessionState.host = creds.host
sessionState.port = creds.port
debug(
`initializeConnection conn.on ready: ${socket.id}, Host: ${creds.host}`
)
socket.emit("authentication", { action: "auth_result", success: true })
// Emit consolidated permissions
socket.emit("permissions", {
allowReplay: config.options.allowReplay || false,
allowReauth: config.options.allowReauth || false
})
updateElement(socket, "footer", `ssh://${creds.host}:${creds.port}`)
if (config.header && config.header.text !== null) {
debug(`initializeConnection header: ${config.header}`)
updateElement(socket, "header", config.header.text)
}
debug(`initializeConnection: ${socket.id}, sessionState: ${JSON.stringify(sanitizeObject(sessionState))}`)
setupSSHListeners(socket)
initializeShell(socket)
})
conn.on("error", (err) => {
console.error(
`initializeConnection: SSH CONNECTION ERROR: ${socket.id}, Host: ${creds.host}, Error: ${err.message}`
)
if (err.level === "client-authentication") {
socket.emit("authentication", {
action: "auth_result",
success: false,
message: "Authentication failed"
})
} else {
handleError(socket, "SSH CONNECTION ERROR", err)
}
})
}
/**
* Sets up SSH-specific event listeners
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Credentials} creds - The user credentials
*/
function setupSSHListeners(socket) {
conn.on("banner", (data) => handleBanner(socket, data))
conn.on("end", () => handleSSHEnd(socket))
conn.on("close", () => handleSSHClose(socket))
socket.on("data", (data) => handleSocketData(socket, stream, data))
socket.on("resize", (data) => handleResize(stream, data))
socket.on("control", (data) => handleControl(socket, stream, data))
}
/**
* Initializes the SSH shell
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Credentials} creds - The user credentials
*/
function initializeShell(socket) {
debug(`initializeShell: INITIALIZING SHELL: ${socket.id}`)
debug(`initializeShell: sessionState: ${JSON.stringify(sanitizeObject(sessionState))}`)
const { term, cols, rows } = sessionState
conn.shell(
{
term: term,
cols: cols,
rows: rows
},
(err, str) => {
if (err) {
return handleError(socket, "EXEC ERROR", err)
}
stream = str
setupStreamListeners(stream, socket)
}
)
}
/**
* Sets up listeners for a stream.
*
* @param {Stream} stream - The stream object to listen to.
* @param {Socket} socket - The socket object associated with the stream.
*/
function setupStreamListeners(stream, socket) {
debug(`setupStreamListeners: ${socket.id}`)
stream.on("data", (data) => handleStreamData(socket, stream, data))
stream.on("close", (code, signal) =>
handleStreamClose(stream, socket, code, signal)
)
stream.stderr.on("data", (data) => debug("STDERR: " + data))
}
/**
* Handles the close event of a stream.
*
* @param {Stream} stream - The stream object.
* @param {Socket} socket - The socket object.
* @param {number} code - The code associated with the close event.
* @param {string} signal - The signal associated with the close event.
*/
function handleStreamClose(stream, socket, code, signal) {
debug(`handleStreamClose: STREAM CLOSE: ${socket.id}`)
handleError(socket, "STREAM CLOSE", {
message: code || signal ? `CODE: ${code} SIGNAL: ${signal}` : undefined
})
}
/**
* Handles the stream data received from the socket.
*
* @param {Socket} socket - The socket object.
* @param {Stream} stream - The stream object.
* @param {Buffer} data - The data received from the stream.
* @returns {void}
*/
function handleStreamData(socket, stream, data) {
const connected = sessionState.connected
socket.emit("data", data.toString("utf-8"))
if (socket && connected) {
try {
socket.write(data)
} catch (error) {
console.error(
"handleStreamData: Error writing to socket:",
error.message
)
// todo: close stream like in handleSocketData?
}
return
}
console.warn("handleStreamData: Attempted to write to closed socket")
}
/**
* Handles the 'banner' event of the SSH connection
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {string} data - The banner data
*/
function handleBanner(socket, data) {
// todo: sanatize the data
socket.emit("data", data.replace(/\r?\n/g, "\r\n"))
}
/**
* Handles the SSH connection end event
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function handleSSHEnd(socket) {
debug(`handleSSHEnd: SSH CONNECTION ENDED: ${socket.id}`)
handleConnectionClose(socket)
}
/**
* Handles the SSH connection close event
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function handleSSHClose(socket) {
debug(`handleSSHClose: SSH CONNECTION CLOSED: ${socket.id}`)
handleConnectionClose(socket)
}
/**
* Handles the closure of the SSH connection
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function handleConnectionClose(socket) {
debug(`handleConnectionClose: Closing connection for ${socket.id}`)
sessionState.connected = false
if (stream) {
stream.end()
stream = null
}
if (conn) {
conn.end()
conn = null
}
socket.emit("connection_closed")
}
/**
* Handles socket disconnection
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {string} reason - The reason for disconnection
*/
function handleDisconnect(socket, reason) {
debug(`DISCONNECT: ${socket.id}, Reason: ${reason}`)
handleConnectionClose(socket)
}
/**
* Handles incoming data from the client
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {import('ssh2').Channel} stream - The SSH stream
* @param {string} data - The incoming data
*/
function handleSocketData(socket, stream, data) {
const connected = sessionState.connected
if (stream && connected) {
try {
stream.write(data)
} catch (error) {
console.error(
"handleSocketData: Error writing to stream:",
error.message
)
handleConnectionClose(socket)
}
return
}
console.warn("handleSocketData: Attempted to write to closed stream")
socket.emit("connection_closed")
}
/**
* Handles terminal resize events
* @param {import('ssh2').Channel} stream - The SSH stream
* @param {Object} data - The resize data
* @param {number} data.rows - The number of rows
* @param {number} data.cols - The number of columns
*/
function handleResize(stream, data) {
const { rows, cols } = data
if (stream) {
debug(`Resizing terminal to ${rows}x${cols}`)
sessionState.rows = rows
sessionState.cols = cols
stream.setWindow(rows, cols)
return
}
console.warn("handleResize: Attempted to resize closed connection")
}
/**
* Handles control commands from the client
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {import('ssh2').Channel} stream - The SSH stream
* @param {string} data - The control command
*/
function handleControl(socket, stream, data) {
debug(`handleControl: Received control data: ${data}`)
if (data === "replayCredentials" && stream) {
replayCredentials(socket, stream)
} else if (data === "reauth") {
handleReauth(socket)
}
}
function handleTerminal(socket, conn, data) {
debug(`handleTerminal: Received terminal data: ${JSON.stringify(data)}`)
const { term, rows, cols } = data
if (term != null) {
sessionState.term = term;
}
sessionState.rows = rows
sessionState.cols = cols
}
/**
* Replays the user credentials to the SSH stream
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {import('ssh2').Channel} stream - The SSH stream
* @param {Credentials} credentials - The user credentials
* @param {Object} config - The configuration object
*/
function replayCredentials(socket, stream) {
const password = sessionState.password
const allowReplay = sessionState.config.options.allowReplay || false
if (allowReplay) {
debug(`replayCredentials: Replaying credentials for ${socket.id}`)
stream.write(password + "\n")
} else {
// todo: add a warning message to the client
console.warn(
`replayCredentials: Credential replay not allowed for ${socket.id}`
)
}
}
/**
* Handles reauthentication request
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function handleReauth(socket) {
debug(`handleReauth: Reauthentication requested for ${socket.id}`)
if (config.options.allowReauth) {
clearSessionCredentials(socket)
debug(`handleReauth: Reauthenticating ${socket.id}`)
socket.emit("authentication", { action: "reauth" })
handleConnectionClose(socket)
} else {
// todo: add a warning message to the client
console.warn(
`handleReauth: Reauthentication not allowed for ${socket.id}`
)
}
}
/**
* Handles SSH errors
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {string} context - The context where the error occurred
* @param {Error} [err] - The error object
*/
function handleError(socket, context, err) {
const errorMessage = err ? `: ${err.message}` : ""
debug(`WebSSH2 error: ${context}${errorMessage}`)
socket.emit("ssherror", `SSH ${context}${errorMessage}`)
handleConnectionClose(socket)
}
/**
* Updates the specified element with the given value by emitting an "updateUI" event through the socket.
*
* @param {Socket} socket - The socket object used for communication.
* @param {string} element - The element to be updated.
* @param {string} value - The value to update the element with.
*/
function updateElement(socket, element, value) {
debug(`updateElement: ${socket.id}, Element: ${element}, Value: ${value}`)
socket.emit("updateUI", { element, value });
}
/**
* Validates the provided credentials and logs the result
* @param {Object} socket - The socket object containing the socket ID
* @param {Object} creds - The credentials to validate
* @returns {boolean} Whether the credentials are valid
*/
function isValidCredentials(socket, creds) {
// Basic format validation
const isValid =
creds &&
typeof creds.username === "string" &&
typeof creds.password === "string" &&
typeof creds.host === "string" &&
typeof creds.port === "number"
// Single line debug log with ternary operator
debug(
`isValidCredentials: CREDENTIALS ${isValid ? "VALID" : "INVALID"}: ${socket.id}${
isValid ? `, Host: ${creds.host}` : ""
}`
)
return isValid
}
/**
* Generates the SSH configuration object
* @param {Credentials} credentials - The user credentials
* @param {Object} config - The configuration object
* @returns {import('ssh2').ConnectConfig} The SSH configuration object
*/
function getSSHConfig(creds, config) {
debug(
`getSSHConfig: ${socket.id}, Host: ${JSON.stringify(sanitizeObject(creds))}`
)
const sshConfig = {
host: creds.host,
port: creds.port,
username: creds.username,
password: creds.password,
tryKeyboard: true,
algorithms: creds.algorithms || config.ssh.algorithms,
readyTimeout: creds.readyTimeout || config.ssh.readyTimeout,
keepaliveInterval:
creds.keepaliveInterval || config.ssh.keepaliveInterval,
keepaliveCountMax:
creds.keepaliveCountMax || config.ssh.keepaliveCountMax,
debug: createDebug("ssh")
}
debug(`getSSHConfig: ${JSON.stringify(sanitizeObject(sshConfig))}`)
return sshConfig
}
/**
* Clears the session credentials for a given socket.
*
* @param {Socket} socket - The socket object.
* @returns {void}
*/
function clearSessionCredentials(socket) {
debug(
`clearSessionCredentials: Clearing session credentials for ${socket.id}`
)
if (socket.handshake.session.sshCredentials) {
socket.handshake.session.sshCredentials.username = null
socket.handshake.session.sshCredentials.password = null
}
sessionState.authenticated = false
sessionState.username = null
sessionState.password = null
socket.handshake.session.save((err) => {
if (err) {
console.error(`Failed to save session for ${socket.id}:`, err)
}
})
}
}