webssh2/app/socket.js
2024-08-15 18:40:23 +00:00

467 lines
14 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
/**
* 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 authenticated = false
let isConnectionClosed = false
debug(`CONNECT: ${socket.id}, URL: ${socket.handshake.url}`)
// removeExistingListeners(socket)
setupInitialSocketListeners(socket, config)
debug(
`handleConnection: ${socket.id}, credentials: ${JSON.stringify(socket.handshake.session.sshCredentials)}`
)
// HTTP Basic Auth credentials
if (socket.handshake.session.sshCredentials) {
const creds = socket.handshake.session.sshCredentials
const { username, password, host, port } = creds
debug(`Credentials from session: ${socket.id}, Host: ${host}`, creds)
if (username && password && host && port) {
handleAuthentication(socket, creds, config)
return
}
}
// Emit an event to the client to request authentication
if (!authenticated) {
debug(
`Requesting authentication for ${socket.id} and authenticated is ${authenticated}`
)
socket.emit("authentication", { action: "request_auth" })
}
/**
* Removes existing listeners to prevent duplicates
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function removeExistingListeners(socket) {
;["authenticate", "data", "resize", "disconnect", "control"].forEach(
(event) => {
socket.removeAllListeners(event)
}
)
}
/**
* 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, config) {
socket.on("error", (error) =>
console.error(`Socket error for ${socket.id}:`, error)
)
socket.on("authenticate", (creds) =>
handleAuthentication(socket, creds, config)
)
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 handleAuthentication(socket, creds, config) {
debug("AUTHENTICATING: ", JSON.stringify(creds))
if (!creds.username && !creds.password) {
debug(`username and password isnt set: ${socket.id}, Host: ${creds.host}`)
creds.username = sshCredentials.username
creds.password = sshCredentials.password
creds.host = sshCredentials.host
creds.port = sshCredentials.port
}
// If reauth, creds from this function should take precedence
if (
!socket.handshake.session.sshCredentials &&
creds &&
isValidCredentials(creds)
) {
debug(
`REAUTH CREDENTIALS VALID: ${socket.id}, socket.handshake.session.sshCredentials: ${JSON.stringify(socket.handshake.session.sshCredentials)}`
)
// Store new credentials in session, overriding any existing ones
socket.handshake.session.sshCredentials = {
username: creds.username,
password: creds.password,
host: creds.host,
port: creds.port
}
// Save the session after updating
socket.handshake.session.save((err) => {
if (err) {
console.error(`Failed to save session for ${socket.id}:`, err)
}
})
// Proceed with connection initialization using the new credentials
initializeConnection(socket, creds, config)
return
}
if (isValidCredentials(socket.handshake.session.sshCredentials)) {
debug(`CREDENTIALS VALID: ${socket.id}, Host: ${creds.host}`)
initializeConnection(socket, creds, config)
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, config) {
debug(`INITIALIZING SSH CONNECTION: ${socket.id}, Host: ${creds.host}`)
if (conn) {
conn.end()
}
conn = new SSH()
conn.on("ready", () => {
authenticated = true
debug(`SSH CONNECTION 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
})
if (config.header && config.header.text !== null) {
debug("header:", config.header)
socket.emit(
"updateUI",
{ header: config.header } || { header: { text: "", background: "" } }
)
}
setupSSHListeners(socket, creds)
initializeShell(socket, creds)
})
conn.on("error", (err) => {
console.error(
`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)
}
})
conn.connect(getSSHConfig(creds, config))
}
/**
* 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, creds) {
conn.on("banner", (data) => handleBanner(socket, data))
conn.on("end", () => handleSSHEnd(socket))
conn.on("close", () => handleSSHClose(socket))
socket.on("data", (data) => handleData(socket, stream, data))
socket.on("resize", (data) => handleResize(stream, data))
socket.on("control", (controlData) =>
handleControl(socket, stream, creds, controlData, config)
)
}
/**
* Initializes the SSH shell
* @param {import('socket.io').Socket} socket - The Socket.IO socket
* @param {Credentials} creds - The user credentials
*/
function initializeShell(socket, creds) {
debug(`INITIALIZING SHELL: ${socket.id}, creds: ${JSON.stringify(creds)}`)
conn.shell(
{
term: creds.term, // config.ssh.term,
cols: creds.cols,
rows: creds.rows
},
(err, str) => {
if (err) {
return handleError(socket, "EXEC ERROR", err)
}
stream = str
stream.on("data", (data) => socket.emit("data", data.toString("utf-8")))
stream.on("close", (code, signal) => {
handleError(socket, "STREAM CLOSE", {
message:
code || signal ? `CODE: ${code} SIGNAL: ${signal}` : undefined
})
})
stream.stderr.on("data", (data) => debug("STDERR: " + data))
}
)
}
/**
* 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) {
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(`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(`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) {
isConnectionClosed = true
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 handleData(socket, stream, data) {
if (stream && !isConnectionClosed) {
try {
stream.write(data)
} catch (error) {
debug("Error writing to stream:", error.message)
handleConnectionClose(socket)
}
} else if (isConnectionClosed) {
debug("Attempted to write to closed connection")
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) {
debug(`Resizing terminal to ${data.rows}x${data.cols}`)
if (stream) {
stream.setWindow(data.rows, data.cols)
return
}
socket.handshake.session.sshCredentials.rows = data.rows
socket.handshake.session.sshCredentials.cols = data.cols
// Save the session after modification
socket.handshake.session.save((err) => {
if (err) {
console.error(`Failed to save session for ${socket.id}:`, err)
}
})
}
/**
* 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 {Credentials} credentials - The user credentials
* @param {string} controlData - The control command
* @param {Object} config - The configuration object
*/
function handleControl(socket, stream, creds, controlData, config) {
debug(`Received control data: ${controlData}`)
if (controlData === "replayCredentials" && stream && creds) {
replayCredentials(socket, stream, creds, config)
} else if (controlData === "reauth" && config.options.allowReauth) {
handleReauth(socket)
}
}
/**
* 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, credentials, config) {
let allowReplay = config.options.allowReplay || false
if (allowReplay) {
debug(`Replaying credentials for ${socket.id}`)
stream.write(credentials.password + "\n")
} else {
debug(`Credential replay not allowed for ${socket.id}`)
}
}
/**
* Handles reauthentication request
* @param {import('socket.io').Socket} socket - The Socket.IO socket
*/
function handleReauth(socket) {
debug(`Reauthentication requested for ${socket.id}`)
// Clear existing session credentials
socket.handshake.session.sshCredentials = null
// Save the session after modification
socket.handshake.session.save((err) => {
if (err) {
console.error(`Failed to save session for ${socket.id}:`, err)
}
// Notify client to reauthenticate
socket.emit("authentication", { action: "reauth" })
// Close the current connection to enforce reauthentication
handleConnectionClose(socket)
})
}
/**
* 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)
}
/**
* Validates the provided credentials
* @param {Credentials} credentials - The credentials to validate
* @returns {boolean} Whether the credentials are valid
*/
function isValidCredentials(credentials) {
// Basic format validation
return (
credentials &&
typeof credentials.username === "string" &&
typeof credentials.password === "string" &&
typeof credentials.host === "string" &&
typeof credentials.port === "number"
)
}
/**
* 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(credentials, config) {
return {
host: credentials.host,
port: credentials.port,
username: credentials.username,
password: credentials.password,
tryKeyboard: true,
algorithms: credentials.algorithms,
readyTimeout: credentials.readyTimeout,
keepaliveInterval: credentials.keepaliveInterval,
keepaliveCountMax: credentials.keepaliveCountMax,
debug: createDebug("ssh")
}
}
}