feat: implement ssh private key auth #379
This commit is contained in:
parent
03d286a56a
commit
402b678b37
3 changed files with 216 additions and 101 deletions
|
@ -153,13 +153,25 @@ class WebSSH2Socket extends EventEmitter {
|
||||||
|
|
||||||
this.socket.emit("getTerminal", true)
|
this.socket.emit("getTerminal", true)
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
debug(
|
debug(
|
||||||
`initializeConnection: SSH CONNECTION ERROR: ${this.socket.id}, Host: ${creds.host}, Error: ${err.message}`
|
`initializeConnection: SSH CONNECTION ERROR: ${this.socket.id}, Host: ${creds.host}, Error: ${err.message}`
|
||||||
)
|
)
|
||||||
handleError(new SSHConnectionError(`${err.message}`))
|
handleError(new SSHConnectionError(`${err.message}`))
|
||||||
this.socket.emit("ssherror", `${err.message}`)
|
this.socket.emit("ssherror", `${err.message}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set up password prompt handler
|
||||||
|
this.ssh.on("password-prompt", (data) => {
|
||||||
|
this.socket.emit("authentication", {
|
||||||
|
action: "password_prompt",
|
||||||
|
message: `Key authentication failed. Please enter password for ${data.username}@${data.host}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on("password_response", (password) => {
|
||||||
|
this.ssh.emit("password-response", password)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
219
app/ssh.js
219
app/ssh.js
|
@ -14,21 +14,35 @@ const debug = createNamespacedDebug("ssh")
|
||||||
* @extends EventEmitter
|
* @extends EventEmitter
|
||||||
*/
|
*/
|
||||||
class SSHConnection extends EventEmitter {
|
class SSHConnection extends EventEmitter {
|
||||||
/**
|
|
||||||
* Create an SSHConnection.
|
|
||||||
* @param {Object} config - Configuration object for the SSH connection.
|
|
||||||
*/
|
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
super()
|
super()
|
||||||
this.config = config
|
this.config = config
|
||||||
this.conn = null
|
this.conn = null
|
||||||
this.stream = null
|
this.stream = null
|
||||||
this.creds = null
|
this.creds = null
|
||||||
|
this.authAttempts = 0
|
||||||
|
this.maxAuthAttempts = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the format of an RSA private key
|
||||||
|
* @param {string} key - The private key string to validate
|
||||||
|
* @returns {boolean} - Whether the key appears to be valid
|
||||||
|
*/
|
||||||
|
validatePrivateKey(key) {
|
||||||
|
const keyStart = "-----BEGIN RSA PRIVATE KEY-----"
|
||||||
|
const keyEnd = "-----END RSA PRIVATE KEY-----"
|
||||||
|
return (
|
||||||
|
typeof key === "string" &&
|
||||||
|
key.includes(keyStart) &&
|
||||||
|
key.includes(keyEnd) &&
|
||||||
|
key.trim().length > keyStart.length + keyEnd.length
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to the SSH server using the provided credentials.
|
* Connects to the SSH server using the provided credentials.
|
||||||
* @param {Object} creds - The credentials object containing host, port, username, and password.
|
* @param {Object} creds - The credentials object containing host, port, username, and optional password.
|
||||||
* @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance.
|
* @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance.
|
||||||
*/
|
*/
|
||||||
connect(creds) {
|
connect(creds) {
|
||||||
|
@ -40,115 +54,116 @@ class SSHConnection extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.conn = new SSH()
|
this.conn = new SSH()
|
||||||
|
this.authAttempts = 0
|
||||||
|
|
||||||
const sshConfig = this.getSSHConfig(creds)
|
// First try with key authentication if available
|
||||||
|
const sshConfig = this.getSSHConfig(creds, true)
|
||||||
|
|
||||||
this.conn.on("ready", () => {
|
this.setupConnectionHandlers(resolve, reject)
|
||||||
debug(`connect: ready: ${creds.host}`)
|
|
||||||
resolve(this.conn)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.conn.on("end", () => {
|
try {
|
||||||
debug(`connect: end: `)
|
this.conn.connect(sshConfig)
|
||||||
})
|
} catch (err) {
|
||||||
|
reject(new SSHConnectionError(`Connection failed: ${err.message}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.conn.on("close", () => {
|
/**
|
||||||
debug(`connect: close: `)
|
* Sets up SSH connection event handlers
|
||||||
})
|
* @param {Function} resolve - Promise resolve function
|
||||||
|
* @param {Function} reject - Promise reject function
|
||||||
|
*/
|
||||||
|
setupConnectionHandlers(resolve, reject) {
|
||||||
|
this.conn.on("ready", () => {
|
||||||
|
debug(`connect: ready: ${this.creds.host}`)
|
||||||
|
resolve(this.conn)
|
||||||
|
})
|
||||||
|
|
||||||
this.conn.on("error", err => {
|
this.conn.on("end", () => {
|
||||||
const error = new SSHConnectionError(`${err.message}`)
|
debug("connect: end")
|
||||||
|
})
|
||||||
|
|
||||||
|
this.conn.on("close", () => {
|
||||||
|
debug("connect: close")
|
||||||
|
})
|
||||||
|
|
||||||
|
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 < this.maxAuthAttempts) {
|
||||||
|
this.authAttempts++
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Disconnect current connection
|
||||||
|
if (this.conn) {
|
||||||
|
this.conn.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new connection with password authentication
|
||||||
|
this.conn = new SSH()
|
||||||
|
const passwordConfig = this.getSSHConfig(this.creds, false)
|
||||||
|
|
||||||
|
this.setupConnectionHandlers(resolve, reject)
|
||||||
|
this.conn.connect(passwordConfig)
|
||||||
|
} else {
|
||||||
|
// No password available, emit event to request password
|
||||||
|
debug("No password available, requesting password from client")
|
||||||
|
this.emit("password-prompt", {
|
||||||
|
host: this.creds.host,
|
||||||
|
username: this.creds.username
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for password response one time
|
||||||
|
this.once("password-response", (password) => {
|
||||||
|
this.creds.password = password
|
||||||
|
const newConfig = this.getSSHConfig(this.creds, false)
|
||||||
|
this.setupConnectionHandlers(resolve, reject)
|
||||||
|
this.conn.connect(newConfig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We've exhausted all authentication attempts
|
||||||
|
const error = new SSHConnectionError(
|
||||||
|
"All authentication methods failed"
|
||||||
|
)
|
||||||
handleError(error)
|
handleError(error)
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
|
||||||
|
|
||||||
this.conn.on(
|
|
||||||
"keyboard-interactive",
|
|
||||||
(name, instructions, lang, prompts, finish) => {
|
|
||||||
this.handleKeyboardInteractive(
|
|
||||||
name,
|
|
||||||
instructions,
|
|
||||||
lang,
|
|
||||||
prompts,
|
|
||||||
finish
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
this.conn.connect(sshConfig)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles keyboard-interactive authentication prompts.
|
|
||||||
* @param {string} name - The name of the authentication request.
|
|
||||||
* @param {string} instructions - The instructions for the keyboard-interactive prompt.
|
|
||||||
* @param {string} lang - The language of the prompt.
|
|
||||||
* @param {Array<Object>} prompts - The list of prompts provided by the server.
|
|
||||||
* @param {Function} finish - The callback to complete the keyboard-interactive authentication.
|
|
||||||
*/
|
|
||||||
handleKeyboardInteractive(name, instructions, lang, prompts, finish) {
|
|
||||||
debug("handleKeyboardInteractive: Keyboard-interactive auth %O", prompts)
|
|
||||||
|
|
||||||
// Check if we should always send prompts to the client
|
|
||||||
if (this.config.ssh.alwaysSendKeyboardInteractivePrompts) {
|
|
||||||
this.sendPromptsToClient(name, instructions, prompts, finish)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const responses = []
|
|
||||||
let shouldSendToClient = false
|
|
||||||
|
|
||||||
for (let i = 0; i < prompts.length; i += 1) {
|
|
||||||
if (
|
|
||||||
prompts[i].prompt.toLowerCase().includes("password") &&
|
|
||||||
this.creds.password
|
|
||||||
) {
|
|
||||||
responses.push(this.creds.password)
|
|
||||||
} else {
|
|
||||||
shouldSendToClient = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSendToClient) {
|
|
||||||
this.sendPromptsToClient(name, instructions, prompts, finish)
|
|
||||||
} else {
|
|
||||||
finish(responses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends prompts to the client for keyboard-interactive authentication.
|
|
||||||
*
|
|
||||||
* @param {string} name - The name of the authentication method.
|
|
||||||
* @param {string} instructions - The instructions for the authentication.
|
|
||||||
* @param {Array<{ prompt: string, echo: boolean }>} prompts - The prompts to be sent to the client.
|
|
||||||
* @param {Function} finish - The callback function to be called when the client responds.
|
|
||||||
*/
|
|
||||||
sendPromptsToClient(name, instructions, prompts, finish) {
|
|
||||||
this.emit("keyboard-interactive", {
|
|
||||||
name: name,
|
|
||||||
instructions: instructions,
|
|
||||||
prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.once("keyboard-interactive-response", responses => {
|
this.conn.on(
|
||||||
finish(responses)
|
"keyboard-interactive",
|
||||||
})
|
(name, instructions, lang, prompts, finish) => {
|
||||||
|
this.handleKeyboardInteractive(
|
||||||
|
name,
|
||||||
|
instructions,
|
||||||
|
lang,
|
||||||
|
prompts,
|
||||||
|
finish
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the SSH configuration object based on credentials.
|
* Generates the SSH configuration object based on credentials.
|
||||||
* @param {Object} creds - The credentials object containing host, port, username, and password.
|
* @param {Object} creds - The credentials object containing host, port, username, and optional password.
|
||||||
|
* @param {boolean} useKey - Whether to attempt key authentication
|
||||||
* @returns {Object} - The SSH configuration object.
|
* @returns {Object} - The SSH configuration object.
|
||||||
*/
|
*/
|
||||||
getSSHConfig(creds) {
|
getSSHConfig(creds, useKey) {
|
||||||
return {
|
const config = {
|
||||||
host: creds.host,
|
host: creds.host,
|
||||||
port: creds.port,
|
port: creds.port,
|
||||||
username: creds.username,
|
username: creds.username,
|
||||||
password: creds.password,
|
|
||||||
tryKeyboard: true,
|
tryKeyboard: true,
|
||||||
algorithms: this.config.ssh.algorithms,
|
algorithms: this.config.ssh.algorithms,
|
||||||
readyTimeout: this.config.ssh.readyTimeout,
|
readyTimeout: this.config.ssh.readyTimeout,
|
||||||
|
@ -156,6 +171,20 @@ class SSHConnection extends EventEmitter {
|
||||||
keepaliveCountMax: this.config.ssh.keepaliveCountMax,
|
keepaliveCountMax: this.config.ssh.keepaliveCountMax,
|
||||||
debug: createNamespacedDebug("ssh2")
|
debug: createNamespacedDebug("ssh2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If useKey is true and we have a private key, use it
|
||||||
|
if (useKey && this.config.user.privatekey) {
|
||||||
|
debug("Using private key authentication")
|
||||||
|
if (!this.validatePrivateKey(this.config.user.privatekey)) {
|
||||||
|
throw new SSHConnectionError("Invalid private key format")
|
||||||
|
}
|
||||||
|
config.privateKey = this.config.user.privatekey
|
||||||
|
} else if (creds.password) {
|
||||||
|
debug("Using password authentication")
|
||||||
|
config.password = creds.password
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -39,6 +39,11 @@ describe("SSHConnection", () => {
|
||||||
readyTimeout: 20000,
|
readyTimeout: 20000,
|
||||||
keepaliveInterval: 60000,
|
keepaliveInterval: 60000,
|
||||||
keepaliveCountMax: 10
|
keepaliveCountMax: 10
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
name: null,
|
||||||
|
password: null,
|
||||||
|
privatekey: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sshConnection = new SSHConnection(mockConfig)
|
sshConnection = new SSHConnection(mockConfig)
|
||||||
|
@ -56,8 +61,6 @@ describe("SSHConnection", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("connect", () => {
|
describe("connect", () => {
|
||||||
// ... previous tests ...
|
|
||||||
|
|
||||||
it("should handle connection errors", () => {
|
it("should handle connection errors", () => {
|
||||||
const mockCreds = {
|
const mockCreds = {
|
||||||
host: "example.com",
|
host: "example.com",
|
||||||
|
@ -77,6 +80,79 @@ describe("SSHConnection", () => {
|
||||||
expect(error.message).toBe("Connection failed")
|
expect(error.message).toBe("Connection failed")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should connect successfully with password", () => {
|
||||||
|
const mockCreds = {
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
username: "user",
|
||||||
|
password: "pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSSH2Client.on.mockImplementation((event, callback) => {
|
||||||
|
if (event === "ready") {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sshConnection.connect(mockCreds).then(() => {
|
||||||
|
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
username: "user",
|
||||||
|
password: "pass",
|
||||||
|
tryKeyboard: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("key authentication", () => {
|
||||||
|
it("should use privateKey when provided in config", () => {
|
||||||
|
mockConfig.user.privatekey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpA...etc\n-----END RSA PRIVATE KEY-----"
|
||||||
|
sshConnection = new SSHConnection(mockConfig)
|
||||||
|
|
||||||
|
const mockCreds = {
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
username: "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSSH2Client.on.mockImplementation((event, callback) => {
|
||||||
|
if (event === "ready") {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sshConnection.connect(mockCreds).then(() => {
|
||||||
|
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
privateKey: mockConfig.user.privatekey,
|
||||||
|
host: mockCreds.host,
|
||||||
|
port: mockCreds.port,
|
||||||
|
username: mockCreds.username
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid private key format", () => {
|
||||||
|
mockConfig.user.privatekey = "invalid-key-format"
|
||||||
|
sshConnection = new SSHConnection(mockConfig)
|
||||||
|
|
||||||
|
const mockCreds = {
|
||||||
|
host: "example.com",
|
||||||
|
port: 22,
|
||||||
|
username: "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sshConnection.connect(mockCreds).catch(error => {
|
||||||
|
expect(error).toBeInstanceOf(SSHConnectionError)
|
||||||
|
expect(error.message).toBe("Invalid private key format")
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("shell", () => {
|
describe("shell", () => {
|
||||||
|
@ -127,7 +203,6 @@ describe("SSHConnection", () => {
|
||||||
sshConnection.stream = null
|
sshConnection.stream = null
|
||||||
|
|
||||||
sshConnection.resizeTerminal(80, 24)
|
sshConnection.resizeTerminal(80, 24)
|
||||||
|
|
||||||
// No error should be thrown
|
// No error should be thrown
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -153,8 +228,7 @@ describe("SSHConnection", () => {
|
||||||
sshConnection.conn = null
|
sshConnection.conn = null
|
||||||
|
|
||||||
sshConnection.end()
|
sshConnection.end()
|
||||||
|
|
||||||
// No error should be thrown
|
// No error should be thrown
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
Loading…
Reference in a new issue