UNPKG

nodemiko

Version:

A Node.js library for managing network devices, inspired by Netmiko.

385 lines (339 loc) 12.2 kB
import { Client } from 'ssh2'; import fs from 'fs'; const DEFAULT_PROMPT = /([a-zA-Z0-9.\-@()_:\s]+[#>$%])\s*$/; const STRIP_ANSI = /[\u001b\u009b][[()#;?]*.{0,2}(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; export default class BaseConnection { constructor(device) { this.device = device; this.client = new Client(); this.stream = null; this.prompt = DEFAULT_PROMPT; this.config_prompt = /\(config.*\)#\s*$/; this.loggedIn = false; this.base_prompt = ''; // The detected prompt string, without regex formatting // Timeouts this.conn_timeout = device.conn_timeout || 20000; // Default: 20 seconds this.read_timeout = device.read_timeout || 10000; // Default: 10 seconds this.global_delay_factor = device.global_delay_factor || 1; this.config_error_pattern = /(?:Invalid|Incomplete|Ambiguous) command/i; // Aliases for netmiko compatibility this.send_command = this.sendCommand; this.send_command_timing = this.sendCommandTiming; this.send_config = this.sendConfig; this.find_prompt = this.findPrompt; this.check_enable_mode = this.checkEnableMode; this.config_mode = this.configMode; this.check_config_mode = this.checkConfigMode; this.exit_config_mode = this.exitConfigMode; this.session_preparation = this.sessionPreparation; this.file_transfer = this.fileTransfer; } _delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } _checkError(command, output) { if (this.config_error_pattern.test(output)) { throw new Error(`Configuration failed: Error while sending command: "${command}"\nOutput: ${output}`); } } connect() { return new Promise((resolve, reject) => { this.client.on('ready', () => { this.client.shell((err, stream) => { if (err) return reject(err); this.stream = stream; this.loggedIn = true; this.sessionPreparation() .then(() => { this.findPrompt() .then(() => resolve(this)) .catch(reject); }) .catch(reject); }); }).on('error', (err) => { reject(err); }); const connectParams = { host: this.device.host, port: this.device.port || 22, username: this.device.username, readyTimeout: this.conn_timeout, }; if (this.device.use_keys && this.device.key_file) { if (!fs.existsSync(this.device.key_file)) { return reject(new Error(`Key file not found at path: ${this.device.key_file}`)); } connectParams.privateKey = fs.readFileSync(this.device.key_file); if (this.device.passphrase) { connectParams.passphrase = this.device.passphrase; } } else if (this.device.password) { connectParams.password = this.device.password; } else { return reject(new Error('Authentication method required: please provide either a password or SSH key.')); } this.client.connect(connectParams); }); } async disconnect() { return new Promise((resolve) => { if (!this.loggedIn) { resolve(); return; } this.client.on('close', () => { this.loggedIn = false; resolve(); }); this.client.end(); }); } readUntilTimeout(timeout) { return new Promise((resolve) => { let output = ''; const dataHandler = (data) => { output += data.toString().replace(STRIP_ANSI, ''); }; this.stream.on('data', dataHandler); setTimeout(() => { this.stream.removeListener('data', dataHandler); resolve(output); }, timeout); }); } async readUntilPrompt(promptRegex = this.prompt, timeout = this.read_timeout) { return new Promise((resolve, reject) => { let buffer = ''; const timeoutId = setTimeout(() => { this.stream.removeListener('data', onData); reject(new Error(`Read timeout (${timeout}ms) looking for prompt: ${promptRegex}`)); }, timeout); const onData = (data) => { const received = data.toString().replace(STRIP_ANSI, ''); buffer += received; const match = buffer.match(promptRegex); if (match) { this.base_prompt = match[0].trim(); clearTimeout(timeoutId); this.stream.removeListener('data', onData); resolve(buffer); } }; this.stream.on('data', onData); }); } async findPrompt() { return new Promise((resolve, reject) => { if (!this.stream) { return reject(new Error('Connection not established')); } this.stream.write('\n', async (err) => { if (err) return reject(err); try { await this._delay(300 * this.global_delay_factor); const output = await this.readUntilTimeout(1500); const lines = output.trim().split('\n'); const new_prompt = lines[lines.length - 1].trim(); if (new_prompt && new_prompt.length > 1) { this.base_prompt = new_prompt; this.prompt = new RegExp(this.escapeRegExp(this.base_prompt) + '\\s*$'); resolve(this.prompt); } else { // Fallback to a generic prompt if detection fails this.prompt = DEFAULT_PROMPT; resolve(this.prompt); } } catch (e) { // Fallback to a generic prompt on error this.prompt = DEFAULT_PROMPT; resolve(this.prompt); } }); }); } async sessionPreparation() { // This method is intended to be overridden by subclasses for initial setup // like disabling paging. The base implementation does nothing. return Promise.resolve(); } checkEnableMode() { // Privileged EXEC mode on most devices includes a '#' return this.base_prompt.includes('#'); } async sendCommand(command, options = {}) { const { expect_string = null, strip_prompt = true, strip_command = true, } = options; return new Promise((resolve, reject) => { if (!this.stream) { return reject(new Error('Connection not established')); } this.stream.write(`${command}\n`, async (err) => { if (err) return reject(err); await this._delay(50 * this.global_delay_factor); try { const promptRegex = expect_string ? new RegExp(expect_string) : this.prompt; // Use the default timeout for this method let output = await this.readUntilPrompt(promptRegex, this.read_timeout); if (strip_command) { output = output.replace(new RegExp(`^${command}\\s*\\r?\\n`), ''); } if (strip_prompt) { output = output.replace(promptRegex, ''); } resolve(output.trim()); } catch (e) { reject(e); } }); }); } async sendCommandTiming(command, options = {}) { const { expect_string = null, strip_prompt = true, strip_command = true, delay_factor = 1, } = options; return new Promise((resolve, reject) => { if (!this.stream) { return reject(new Error('Connection not established')); } this.stream.write(`${command}\n`, async (err) => { if (err) return reject(err); await this._delay(50 * this.global_delay_factor); try { const promptRegex = expect_string ? new RegExp(expect_string) : this.prompt; const command_timeout = this.read_timeout * delay_factor; let output = await this.readUntilPrompt(promptRegex, command_timeout); if (strip_command) { output = output.replace(new RegExp(`^${command}\\s*\\r?\\n`), ''); } if (strip_prompt) { output = output.replace(promptRegex, ''); } resolve(output.trim()); } catch (e) { reject(e); } }); }); } async enable() { if (this.checkEnableMode()) { return ''; } if (!this.device.secret) { return 'No secret provided. Cannot enter enable mode.'; } return new Promise((resolve, reject) => { this.stream.write('enable\n', async (err) => { if (err) return reject(err); try { let output = await this.readUntilPrompt(/Password:/i); this.stream.write(`${this.device.secret}\n`, async (writeErr) => { if (writeErr) return reject(writeErr); await this.findPrompt(); const remainingOutput = await this.readUntilPrompt(); output += remainingOutput; if (this.checkEnableMode()) { resolve(output); } else { reject(new Error('Failed to enter enable mode. Please check the secret.')); } }); } catch (e) { reject(new Error(`Failed to enter enable mode: ${e.message}`)); } }); }); } checkConfigMode() { return this.config_prompt.test(this.base_prompt); } async configMode(config_command = 'configure terminal') { let output = ''; if (!this.checkConfigMode()) { this.stream.write(`${config_command}\n`); output = await this.readUntilPrompt(this.config_prompt); this._checkError(config_command, output); if (!this.checkConfigMode()) { throw new Error('Failed to enter configuration mode.'); } } return output; } async exitConfigMode(exit_command = 'end') { let output = ''; if (this.checkConfigMode()) { this.stream.write(`${exit_command}\n`); output = await this.readUntilPrompt(this.prompt); this._checkError(exit_command, output); if (this.checkConfigMode()) { throw new Error('Failed to exit configuration mode.'); } } return output; } async commit() { // This method is for transactional devices and does nothing by default. return ''; } async sendConfig(commands) { const configCmds = Array.isArray(commands) ? commands : [commands]; let output = ''; output += await this.configMode(); for (const command of configCmds) { this.stream.write(`${command}\n`); const cmdOutput = await this.readUntilPrompt(this.config_prompt); this._checkError(command, cmdOutput); output += cmdOutput; } // Commit if needed (for transactional devices) output += await this.commit(); output += await this.exitConfigMode(); return output; } async fileTransfer(source_file, dest_file, direction = 'put') { return new Promise((resolve, reject) => { this.client.sftp((sftpErr, sftp) => { if (sftpErr) { return reject(new Error(`SFTP session error: ${sftpErr.message}`)); } const sftpError = (err) => { sftp.end(); reject(new Error(`SFTP operation failed: ${err.message}`)); }; if (direction === 'put') { if (!fs.existsSync(source_file)) { return reject(new Error(`Source file not found: ${source_file}`)); } const readStream = fs.createReadStream(source_file); const writeStream = sftp.createWriteStream(dest_file); writeStream.on('close', () => { sftp.end(); resolve(`File ${source_file} uploaded to ${dest_file}`); }).on('error', sftpError); readStream.pipe(writeStream); } else if (direction === 'get') { const readStream = sftp.createReadStream(source_file); const writeStream = fs.createWriteStream(dest_file); writeStream.on('close', () => { sftp.end(); resolve(`File ${source_file} downloaded to ${dest_file}`); }).on('error', sftpError); readStream.on('error', sftpError).pipe(writeStream); } else { sftp.end(); reject(new Error('Invalid direction. Must be "put" or "get".')); } }); }); } }