UNPKG

@vermaysha/routeros

Version:

NodeJS / Bun RouterOS API

1,576 lines (1,568 loc) 52.5 kB
'use strict'; var events = require('events'); var net = require('net'); var tls = require('tls'); var iconv = require('iconv-lite'); var debug = require('debug'); var crypto = require('crypto'); var timers = require('timers'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var iconv__default = /*#__PURE__*/_interopDefault(iconv); var debug__default = /*#__PURE__*/_interopDefault(debug); var crypto__default = /*#__PURE__*/_interopDefault(crypto); // src/Connector.ts var info = debug__default.default("routeros-api:connector:receiver:info"); var error = debug__default.default("routeros-api:connector:receiver:error"); var nullBuffer = Buffer.from([0]); var Receiver = class { /** * The socket which connects to the routerboard */ socket; /** * The registered tags to answer data to */ tags = /* @__PURE__ */ new Map(); /** * The length of the current data chain received from * the socket */ dataLength = 0; /** * A pipe of all responses received from the routerboard */ sentencePipe = []; /** * Flag if the sentencePipe is being processed to * prevent concurrent sentences breaking the pipe */ processingSentencePipe = false; /** * The current line being processed from the data chain */ currentLine = ""; /** * The current reply received for the tag */ currentReply = null; /** * The current tag which the routerboard responded */ currentTag = null; /** * The current data chain or packet */ currentPacket = []; /** * Used to store a partial segment of the * length descriptor if it gets split * between tcp transmissions. */ lengthDescriptorSegment = null; /** * Receives the socket so we are able to read * the data sent to it, separating each tag * to the according listener. * * @param socket */ constructor(socket) { this.socket = socket; } /** * Register the tag as a reader so when * the routerboard respond to the command * related to the tag, we know where to send * the data to * * @param {string} tag * @param {function} callback */ read(tag, callback) { info("Reader of %s tag is being set", tag); this.tags.set(tag, { name: tag, callback }); } /** * Stop reading from a tag, removing it * from the tag mapping. Usually it is closed * after the command has being !done, since each command * opens a new auto-generated tag * * @param {string} tag */ stop(tag) { info("Not reading from %s tag anymore", tag); this.tags.delete(tag); } /** * Process the raw buffer data received from the routerboard, * decode using win1252 encoded string from the routerboard to * utf-8, so languages with accentuation works out of the box. * * After reading each sentence from the raw packet, sends it * to be parsed * * @param {Buffer} data - Buffer containing the raw data * @returns {void} */ processRawData(data) { let buffer = data; if (this.lengthDescriptorSegment) { buffer = Buffer.concat([this.lengthDescriptorSegment, buffer]); this.lengthDescriptorSegment = null; } while (buffer.length > 0) { if (this.dataLength > 0) { if (buffer.length <= this.dataLength) { this.dataLength -= buffer.length; this.currentLine += iconv__default.default.decode(buffer, "win1252"); if (this.dataLength === 0) { this.sentencePipe.push({ sentence: this.currentLine, hadMore: buffer.length !== this.dataLength }); this.processSentence(); this.currentLine = ""; } break; } const tmpBuffer = buffer.subarray(0, this.dataLength); const tmpStr = iconv__default.default.decode(tmpBuffer, "win1252"); this.currentLine += tmpStr; const line = this.currentLine; this.currentLine = ""; buffer = buffer.subarray(this.dataLength); const [descriptor_length, length] = this.decodeLength(buffer); if (descriptor_length > buffer.length) { this.lengthDescriptorSegment = buffer; } this.dataLength = length; buffer = buffer.subarray(descriptor_length); if (this.dataLength === 1 && buffer.equals(nullBuffer)) { this.dataLength = 0; buffer = buffer.subarray(1); } this.sentencePipe.push({ sentence: line, hadMore: buffer.length > 0 }); this.processSentence(); } else { const [descriptor_length, length] = this.decodeLength(buffer); this.dataLength = length; buffer = buffer.subarray(descriptor_length); if (this.dataLength === 1 && buffer.equals(nullBuffer)) { this.dataLength = 0; buffer = buffer.subarray(1); } } } } /** * Process each sentence from the data packet received. * * Detects the .tag of the packet, sending the data to the * related tag when another reply is detected or if * the packet had no more lines to be processed. * */ processSentence() { if (this.processingSentencePipe) { return; } info("Got asked to process sentence pipe"); this.processingSentencePipe = true; while (this.sentencePipe.length > 0) { const line = this.sentencePipe.shift(); if (!line || !line?.hadMore && this.currentReply === "!fatal") { this.socket.emit("fatal"); break; } info("Processing line %s", line.sentence); if (/^\.tag=/.test(line.sentence)) { this.currentTag = line.sentence.substring(5); } else if (/^!/.test(line.sentence)) { if (this.currentTag) { info( "Received another response, sending current data to tag %s", this.currentTag ); this.sendTagData(this.currentTag); } this.currentPacket.push(line.sentence); this.currentReply = line.sentence; } else { this.currentPacket.push(line.sentence); } if (this.sentencePipe.length === 0 && this.dataLength === 0) { if (!line.hadMore && this.currentTag) { info( "No more sentences to process, will send data to tag %s", this.currentTag ); this.sendTagData(this.currentTag); } else { info("No more sentences and no data to send"); } break; } } this.processingSentencePipe = false; } /** * Send the data collected from the tag to the * tag reader * @param {string} currentTag - The tag which will be used to find the callback * @returns {void} - No return, but the tag callback will be called with data */ sendTagData(currentTag) { const tag = this.tags.get(currentTag); if (tag) { info("Sending to tag %s the packet %O", tag.name, this.currentPacket); tag.callback(this.currentPacket); this.cleanUp(); return; } error("UNREGISTEREDTAG"); } /** * Clean the current packet, tag and reply state * to start over */ cleanUp() { this.currentPacket = []; this.currentTag = null; this.currentReply = null; } /** * Decodes the length of the buffer received. * * @param {Buffer} data - The data which the length should be decoded from. * @returns {[number, number]} - A tuple containing the index in the buffer where the length ends and the length itself. */ decodeLength(data) { let idx = 0; let len = data[idx++] || 0; if (len & 128) { len &= 127; let shift = 7; while (true) { const byte = data[idx++] || 0; len |= (byte & 127) << shift; if (!(byte & 128)) break; shift += 7; } } return [idx, len]; } }; var info2 = debug__default.default("routeros-api:connector:transmitter:info"); debug__default.default("routeros-api:connector:transmitter:error"); var Transmitter = class { /** * The socket which connects to the routerboard */ socket; /** * Pool of data to be sent after the socket connects */ pool = []; /** * Constructor * * @param socket */ constructor(socket) { this.socket = socket; } /** * Write data over the socket, if it not writable yet, * save over the pool to be ran after * * @param {string} data */ write(data) { const encodedData = this.encodeString(data); if (!this.socket.writable || this.pool.length > 0) { info2("Socket not writable, saving %o in the pool", data); this.pool.push(encodedData); } else { info2("Writing command %s over the socket", data); this.socket.write(encodedData); } } /** * Writes all data stored in the pool */ runPool() { info2("Running stacked command pool"); const datas = this.pool.splice(0, this.pool.length); datas.forEach((data) => this.socket.write(data)); } /** * Encode the string data that will * be sent over to the routerboard. * * It's encoded in win1252 so any accentuation on foreign languages * are displayed correctly when opened with winbox. * * Credits for George Joseph: https://github.com/gtjoseph * and for Brandon Myers: https://github.com/Trakkasure * * @param {string} str */ encodeString(str) { if (str === null) return Buffer.from([0]); const encoded = iconv__default.default.encode(str, "win1252"); const len = encoded.length; const data = Buffer.allocUnsafe(len + 1); data[0] = len; encoded.copy(data, 1); return data; } }; // src/messages.ts var messages_default = { UNREGISTEREDTAG: "Received data on unregistered tag. This is an error on this API itself and shouldn't have happened.", UNKNOWNREPLY: "Tried to process unknown reply: {{reply}}", CANTLOGIN: "Username or password is invalid", STREAMCLOSD: "Streaming is closed", ALRDYSTREAMING: "Already streaming", CANTWRTWHLSTRMG: "Cannot write over the same channel that is streaming", ALRDYCLOSNG: "Connection already closing", ALRDYCONNECTING: "Already connecting", SOCKTMOUT: "Timed out after {{seconds}} seconds", REFNOTFND: "Item not found with the reference provided in {{key}}", E2BIG: "Argument list too long.", EACCES: "Permission denied.", EADDRINUSE: "Address already in use.", EADDRNOTAVAIL: "Address not available.", EAFNOSUPPORT: "Address family not supported.", EAGAIN: "Resource temporarily unavailable.", EALREADY: "Connection already in progress.", EBADE: "Invalid exchange.", EBADF: "Bad file descriptor.", EBADFD: "File descriptor in bad state.", EBADMSG: "Bad message.", EBADR: "Invalid request descriptor.", EBADRQC: "Invalid request code.", EBADSLT: "Invalid slot.", EBUSY: "Device or resource busy.", ECANCELED: "Operation canceled.", ECHILD: "No child processes.", ECHRNG: "Channel number out of range.", ECOMM: "Communication error on send.", ECONNABORTED: "Connection aborted.", ECONNREFUSED: "Connection refused.", ECONNRESET: "Connection reset.", EDEADLK: "Resource deadlock avoided.", EDEADLOCK: "Resource deadlock avoided.", EDESTADDRREQ: "Destination address required.", EDOM: "Mathematics argument out of domain of function", EDQUOT: "Disk quota exceeded.", EEXIST: "File exists.", EFAULT: "Bad address.", EFBIG: "File too large.", EHOSTDOWN: "Host is down.", EHOSTUNREACH: "Host is unreachable.", EIDRM: "Identifier removed.", EILSEQ: "Invalid or incomplete multibyte or wide character", EINPROGRESS: "Operation in progress.", EINTR: "Interrupted function call.", EINVAL: "Invalid argument.", EIO: "Input/output error.", EISCONN: "Socket is connected.", EISDIR: "Is a directory.", EISNAM: "Is a named type file.", EKEYEXPIRED: "Key has expired.", EKEYREJECTED: "Key was rejected by service.", EKEYREVOKED: "Key has been revoked.", EL2HLT: "Level 2 halted.", EL2NSYNC: "Level 2 not synchronized.", EL3HLT: "Level 3 halted.", EL3RST: "Level 3 reset.", ELIBACC: "Cannot access a needed shared library.", ELIBBAD: "Accessing a corrupted shared library.", ELIBMAX: "Attempting to link in too many shared libraries.", ELIBSCN: ".lib section in a.out corrupted", ELIBEXEC: "Cannot exec a shared library directly.", ELOOP: "Too many levels of symbolic links.", EMEDIUMTYPE: "Wrong medium type.", EMFILE: "Too many open files.", EMLINK: "Too many links.", EMSGSIZE: "Message too long.", EMULTIHOP: "Multihop attempted.", ENAMETOOLONG: "Filename too long.", ENETDOWN: "Network is down.", ENETRESET: "Connection aborted by network.", ENETUNREACH: "Network unreachable.", ENFILE: "Too many open files in system.", ENOBUFS: "No buffer space available", ENODATA: "No message is available on the STREAM head read queue.", ENODEV: "No such device.", ENOENT: "No such file or directory.", ENOEXEC: "Exec format error.", ENOKEY: "Required key not available.", ENOLCK: "No locks available.", ENOLINK: "Link has been severed.", ENOMEDIUM: "No medium found.", ENOMEM: "Not enough space.", ENOMSG: "No message of the desired type.", ENONET: "Machine is not on the network.", ENOPKG: "Package not installed.", ENOPROTOOPT: "Protocol not available.", ENOSPC: "No space left on device.", ENOSR: "No STREAM resources.", ENOSTR: "Not a STREAM.", ENOSYS: "Function not implemented.", ENOTBLK: "Block device required.", ENOTCONN: "The socket is not connected.", ENOTDIR: "Not a directory.", ENOTEMPTY: "Directory not empty.", ENOTSOCK: "Not a socket.", ENOTSUP: "Operation not supported.", ENOTTY: "Inappropriate I/O control operation.", ENOTUNIQ: "Name not unique on network.", ENXIO: "No such device or address.", EOPNOTSUPP: "Operation not supported on socket.", EOVERFLOW: "Value too large to be stored in data type.", EPERM: "Operation not permitted.", EPFNOSUPPORT: "Protocol family not supported.", EPIPE: "Broken pipe.", EPROTO: "Protocol error.", EPROTONOSUPPORT: "Protocol not supported.", EPROTOTYPE: "Protocol wrong type for socket.", ERANGE: "Result too large.", EREMCHG: "Remote address changed.", EREMOTE: "Object is remote.", EREMOTEIO: "Remote I/O error.", ERESTART: "Interrupted system call should be restarted.", EROFS: "Read-only filesystem.", ESHUTDOWN: "Cannot send after transport endpoint shutdown.", ESPIPE: "Invalid seek.", ESOCKTNOSUPPORT: "Socket type not supported.", ESRCH: "No such process.", ESTALE: "Stale file handle.", ESTRPIPE: "Streams pipe error.", ETIME: "Timer expired.", ETIMEDOUT: "Connection timed out.", ETXTBSY: "Text file busy.", EUCLEAN: "Structure needs cleaning.", EUNATCH: "Protocol driver not attached.", EUSERS: "Too many users.", EWOULDBLOCK: "Resource temporarily unavailable.", EXDEV: "Improper link.", EXFULL: "Exchange full." }; // src/RosException.ts var RosException = class extends Error { errno; constructor(errno, extras) { super(); Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; this.errno = errno; let message = messages_default[errno]; if (message) { for (const key in extras) { if (Object.prototype.hasOwnProperty.call(extras, key)) { message = message.replace(`{{${key}}}`, extras[key]); } } this.message = message; } } }; var info3 = debug__default.default("routeros-api:connector:connector:info"); var error3 = debug__default.default("routeros-api:connector:connector:error"); var Connector = class extends events.EventEmitter { /** * The host or address of where to connect to */ host; /** * The port of the API */ port = 8728; /** * The timeout in seconds of the connection */ timeout = 10; /** * The socket of the connection */ socket; /** * The transmitter object to write commands */ transmitter; /** * The receiver object to read commands */ receiver; /** * Connected status */ connected = false; /** * Connecting status */ connecting = false; /** * Closing status */ closing = false; /** * TLS data */ tls = null; /** * Creates a new Connector object with the given options * * @param {IRosOptions} options - connection options * @param {string} options.host - The host to connect to * @param {number} [options.port=8728] - The port of the API * @param {number} [options.timeout=10] - The timeout of the connection * @param {TLSSocketOptions} [options.tls=null] - TLS Options to use, if any */ constructor(options) { super(); this.host = options.host; this.timeout = options.timeout ?? 0; this.port = options.port ?? 8728; if (typeof options.tls === "boolean" && options.tls) options.tls = {}; if (typeof options.tls === "object" && options.tls !== null) { if (!options.port) this.port = 8729; this.tls = options.tls; } } /** * Establishes a connection to the routerboard. If the connection is already * established or a connection is in progress, the function does nothing. * * @returns {this} - The current Connector instance */ connect() { if (!this.connected && !this.connecting) { this.connecting = true; const socket = new net.Socket(); this.transmitter = new Transmitter(socket); this.receiver = new Receiver(socket); this.socket = this.tls ? new tls.TLSSocket(socket, this.tls) : socket; this.socket.once("connect", this.onConnect.bind(this)); this.socket.once("end", this.onEnd.bind(this)); this.socket.once("timeout", this.onTimeout.bind(this)); this.socket.once("fatal", this.onEnd.bind(this)); this.socket.on("error", this.onError.bind(this)); this.socket.on("data", this.onData.bind(this)); this.socket.on("close", this.onEnd.bind(this)); info3( "Trying to connect to %s on port %s with timeout %s", this.host, this.port, this.timeout ); if (this.timeout > 0) { this.socket.setTimeout(this.timeout * 1e3); } else { this.socket.setTimeout(0); } this.socket.setKeepAlive(true); this.socket.connect(this.port, this.host); } return this; } /** * Writes the provided command parameters to the connector, appending a newline * after each command and at the end of the list. * * @param {string[]} data - The command parameters to send to the routerboard. * @returns {this} - The current Connector instance */ write(data) { for (const line of data) { this.transmitter?.write(line); } this.transmitter?.write(null); return this; } /** * Register a tag to receive data * * @param tag - The tag to register * @param callback - The callback function to handle the received packet */ read(tag, callback) { this.receiver?.read(tag, callback); } /** * Unregister a tag, so it no longer waits for data * @param tag - The tag to unregister */ stopRead(tag) { this.receiver?.stop(tag); } /** * Start closing the connection * Ensures the connection is properly closed */ close() { if (!this.closing) { this.closing = true; this.socket?.end(); } } /** * Destroy the socket, preventing any further data exchange. * This method also removes all event listeners. */ destroy() { this.socket?.destroy(); this.socket?.end(); this.removeAllListeners(); } /** * Socket connection event listener. * After the connection is stablished, * ask the transmitter to run any * command stored over the pool * * @returns {(this: Connector) => void} */ onConnect() { this.connecting = false; this.connected = true; info3("Connected on %s", this.host); this.transmitter?.runPool(); this.emit("connected", this); } /** * Socket end event listener. * Emits a "close" event and destroys the socket, * ensuring that the connection is properly terminated. */ onEnd() { this.emit("close", this); this.destroy(); } /** * Socket error event listener. * Emmits the error while trying to connect and * destroys the socket. * * @returns {function} */ onError(err) { const exception = new RosException(err.name ?? "ECONNABORTED", err ?? []); error3( "Problem while trying to connect to %s. Error: %s", this.host, exception ); this.emit("error", exception, this); this.destroy(); } /** * Socket timeout event listener * Emmits timeout error and destroys the socket * * @returns {function} */ onTimeout() { this.emit( "timeout", new RosException("SOCKTMOUT", { seconds: this.timeout }), this ); this.destroy(); } /** * Socket data event listener * Receives the data and sends it to processing * * @returns {function} */ onData(data) { info3("Got data from the socket, will process it"); this.receiver?.processRawData(data); } }; var info4 = debug__default.default("routeros-api:channel:info"); debug__default.default("routeros-api:channel:error"); var Channel = class extends events.EventEmitter { /** * Initializes a new Channel instance, generating a unique identifier * and setting up the connector for communicating with the routerboard. * Listens for unknown events to handle them appropriately. * * @param {Connector} connector - The connector instance to be used for communication. */ constructor(connector) { super(); this.connector = connector; this.id = crypto.randomBytes(4).toString("hex"); this.once("unknown", this.onUnknown.bind(this)); } /** * Id of the channel */ id; /** * Data received related to the channel */ data = []; /** * If received a trap instead of a positive response */ trapped = false; /** * If is streaming content */ streaming = false; /** * Writes the provided command parameters to the channel, appending a unique tag. * The function can handle both streaming and non-streaming scenarios and returns * a promise that resolves when the operation is complete. * * @param {string[]} params - The command parameters to send to the routerboard. * @param {boolean} [isStream=false] - Indicates if the channel is in streaming mode. * @param {boolean} [returnPromise=true] - If true, returns a promise that resolves * when the operation is done or rejects if a trap is encountered. * @returns {Promise<IRosGenericResponse[]>} - A promise that resolves with the response data * or rejects with an error message if a trap is received. */ write(params, isStream = false, returnPromise = true) { this.streaming = isStream; params.push(`.tag=${this.id}`); if (returnPromise) { this.on("data", (packet) => this.data.push(packet)); return new Promise((resolve, reject) => { this.once("done", (data) => resolve(data)); this.once("trap", (data) => reject(new Error(data.message))); this.readAndWrite(params); }); } this.readAndWrite(params); return Promise.resolve([]); } /** * Closes the channel, optionally forcing closure and removing all listeners. * Emits a "close" event and stops reading for the current channel tag. * * @param {boolean} [force=false] - If true, all listeners are removed even if streaming. */ close(force = false) { this.emit("close"); if (!this.streaming || force) { this.removeAllListeners(); } this.connector.stopRead(this.id); } /** * Initiates a read operation for the current channel's tag and writes * the provided parameters to the connector. The read operation sets up * a callback to process packets received for the channel, while the * write operation sends the parameters over the connection. * * @param {string[]} params - The parameters to be written to the connector. */ readAndWrite(params) { this.connector.read( this.id, (packet) => this.processPacket(packet) ); this.connector.write(params); } /** * Process a packet received from the connector, emitting "data" events if the packet is not a * stream packet and the channel is not streaming. If the packet is a stream packet, emits a * "stream" event. If the packet is a "!done" packet, emits a "done" event with the collected data * and closes the channel. If the packet is a "!trap" packet, sets the channel to a "trapped" * state and emits a "trap" event. If the packet is any other type of packet, emits an "unknown" * event and closes the channel. * * @private * @param {string[]} packet - The packet to be processed. */ processPacket(packet) { const reply = packet.shift(); info4("Processing reply %s with data %o", reply, packet); const parsed = this.parsePacket(packet); if (reply === "!trap") { this.trapped = true; this.emit("trap", parsed); return; } if (packet.length > 0 && !this.streaming) this.emit("data", parsed); switch (reply) { case "!re": if (this.streaming) this.emit("stream", parsed); break; case "!done": if (!this.trapped) this.emit("done", this.data); this.close(); break; default: this.emit("unknown", reply); this.close(); break; } } /** * Takes a packet and parses it into a key-value object. * It works by splitting each line by "=" and using the first part as the key * and the second part as the value. It ignores empty lines and lines with no * "=" character. * * @private * @param {string[]} packet - The packet to be parsed. * @returns {Record<string, string>} - The parsed packet as a key-value object. */ parsePacket(packet) { const obj = {}; for (const line of packet) { const linePair = line.split("="); linePair.shift(); const key = linePair.shift(); if (key) obj[key] = linePair.join("="); } info4("Parsed line, got %o as result", obj); return obj; } /** * Emits an error if the channel receives an unknown reply type. * @private * @param {string} reply - The reply type received from the routerboard. */ onUnknown(reply) { throw new RosException("UNKNOWNREPLY", { reply }); } }; // src/utils.ts var debounce = (callback, timeout = 0) => { let timeoutObj = void 0; return { /** * Schedules the callback to be called with the provided arguments after * the specified timeout. If this method is called again before the timeout * has expired, the previous call is cancelled and the timeout is reset. * @param {...any} args - The arguments to be passed to the callback. */ run: (...args) => { clearTimeout(timeoutObj); timeoutObj = setTimeout(() => callback.apply(void 0, args), timeout); }, /** * Cancels any pending call to the callback. If no callback is currently * scheduled, this method does nothing. */ cancel: () => { clearTimeout(timeoutObj); } }; }; // src/RStream.ts var RStream = class extends events.EventEmitter { /** * Creates a new stream to be sent to the routerboard * * @param channel - The channel which will be used to send the command * @param params - The command parameters to be sent * @param callback - The callback that will receive the data from the routerboard */ constructor(channel, params) { super(); this.channel = channel; this.params = params; } /** * Callback function to receive data * from the routerboard */ callback; /** * The function that will send empty data * unless debounced by real data from the command */ debounceSendingEmptyData; /** Flag for turning on empty data debouncing */ shouldDebounceEmptyData = false; /** * If is streaming flag */ streaming = true; /** * If is pausing flag */ pausing = false; /** * If is paused flag */ paused = false; /** * If is stopping flag */ stopping = false; /** * If is stopped flag */ stopped = false; /** * If got a trap error */ trapped = false; /** * Save the current section of the packet, if has any */ currentSection = null; /** * Forcely stop the stream */ forcelyStop = false; /** * Store the current section in a single * array before sending when another section comes */ currentSectionPacket = []; /** * Waiting timeout before sending received section packets */ sectionPacketSendingTimeout = null; /** * Function to receive the callback which * will receive data, if not provided over the * constructor or changed later after the streaming * have started. * * @param {function} callback */ data(callback) { this.callback = callback; } /** * Resumes the stream if it is not currently streaming. * If the stream is stopped or stopping, a rejection error * with "STREAMCLOSD" is returned. Otherwise, it resets the * pausing state, starts the stream, and sets the streaming * flag to true. * * @returns {Promise<void>} - A promise that resolves when the stream * is successfully resumed or rejects if the stream is closed. */ resume() { if (this.stopped || this.stopping) return Promise.reject(new RosException("STREAMCLOSD")); if (!this.streaming) { this.pausing = false; this.start(); this.streaming = true; } return Promise.resolve(); } /** * Pauses the stream if it is not currently paused or stopping. * If the stream is stopped or stopping, a rejection error * with "STREAMCLOSD" is returned. Otherwise, it sets the * pausing flag to true, stops the stream, and sets the paused * flag to true. * * @returns {Promise<void>} - A promise that resolves when the stream * is successfully paused or rejects if the stream is closed. */ pause() { if (this.stopped || this.stopping) return Promise.reject(new RosException("STREAMCLOSD")); if (this.pausing || this.paused) return Promise.resolve(); if (this.streaming) { this.pausing = true; return this.stop(true).then(() => { this.pausing = false; this.paused = true; return Promise.resolve(); }).catch((err) => { return Promise.reject(err); }); } return Promise.resolve(); } /** * Stops the stream if it is not currently stopped or stopping. * If the stream is stopped or stopping, a rejection error * with "STREAMCLOSD" is returned. Otherwise, it sets the * stopping flag to true and sends a /cancel command to the * channel. If the pausing flag is set to true, the stream is * paused and the stopped flag is set to false. * * @param {boolean} [pausing=false] - If true, the stream is paused instead of stopped. * @returns {Promise<void>} - A promise that resolves when the stream * is successfully stopped or rejects if the stream is closed. */ async stop(pausing = false) { if (this.stopped || this.stopping) return Promise.resolve(); if (!pausing) this.forcelyStop = true; if (this.paused) { this.streaming = false; this.stopping = false; this.stopped = true; if (this.channel) this.channel.close(true); return Promise.resolve(); } if (!this.pausing) this.stopping = true; let chann = new Channel(this.channel.connector); chann.on("close", () => { chann = null; }); if (this.debounceSendingEmptyData) this.debounceSendingEmptyData.cancel(); try { await chann.write(["/cancel", `=tag=${this.channel.id}`]); this.streaming = false; if (!this.pausing) { this.stopping = false; this.stopped = true; } this.emit("stopped"); return await Promise.resolve(); } catch (err) { return await Promise.reject(err); } } /** * Close the stream. Calls `stop()` internally * * @returns {Promise<void>} - A promise that resolves when the stream * is successfully closed or rejects if the stream is closed. */ close() { return this.stop(); } /** * Start the stream if it is not currently streaming. * If the stream is stopped or stopping, a rejection error * with "STREAMCLOSD" is returned. Otherwise, it resets the * stopping state, starts the stream, and sets the streaming * flag to true. * * @returns {void} */ start() { if (!(!this.stopped && !this.stopping)) { return; } this.channel.on("close", () => { if (this.forcelyStop || !this.pausing && !this.paused) { if (!this.trapped) this.emit("done"); this.emit("close"); } this.stopped = false; }); this.channel.on("stream", (packet) => { if (this.debounceSendingEmptyData) this.debounceSendingEmptyData.run(); this.onStream(packet); }); this.channel.once("trap", this.onTrap.bind(this)); this.channel.once("done", this.onDone.bind(this)); this.channel.write(this.params.slice(), true, false); this.emit("started"); if (this.shouldDebounceEmptyData) this.prepareDebounceEmptyData(); } /** * Prepares the debounce mechanism for sending empty data packets * at a determined interval. This is used to ensure that the stream * remains active by invoking the onStream method with an empty * packet when no data is being received. * * It checks for an `=interval=` parameter within the stream's * parameters to determine the debounce interval. If the parameter * is found, the interval is set based on its value, otherwise, * a default interval of 2000 milliseconds is used. The interval * is adjusted by adding 300 milliseconds to it. * * This method sets the `shouldDebounceEmptyData` flag to true * and initializes the `debounceSendingEmptyData` function * using the calculated interval. */ prepareDebounceEmptyData() { this.shouldDebounceEmptyData = true; const intervalParam = this.params.find((param) => { return /=interval=/.test(param); }); let interval = 2e3; if (intervalParam) { const val = intervalParam.split("=")[2]; if (val) { interval = Number.parseInt(val) * 1e3; } } this.debounceSendingEmptyData = debounce(() => { if (!this.stopped || !this.stopping || !this.paused || !this.pausing) { this.onStream([]); this.debounceSendingEmptyData.run(); } }, interval + 300); } /** * Called when the stream emits data. If the packet is a section packet, * it collects all packets with the same section name and sends them * to the callback after a 300ms delay. If the packet is not a section * packet, it is sent to the callback immediately. * @param {any} packet * @memberof RStream * @private */ onStream(packet) { this.emit("data", packet); if (this.callback) { if (packet[".section"]) { if (this.sectionPacketSendingTimeout) timers.clearTimeout(this.sectionPacketSendingTimeout); const sendData = () => { this.callback?.(null, this.currentSectionPacket.slice(), this); this.currentSectionPacket = []; }; this.sectionPacketSendingTimeout = timers.setTimeout(sendData.bind(this), 300); if (this.currentSectionPacket.length > 0 && packet[".section"] !== this.currentSection) { timers.clearTimeout(this.sectionPacketSendingTimeout); sendData(); } this.currentSection = packet[".section"]; this.currentSectionPacket.push(packet); } else { this.callback(null, packet, this); } } } /** * Handles the trap event, which is emitted when the stream is interrupted * or when an error occurs. If the trap is due to an interruption, it sets * the `streaming` flag to false. If the trap is due to an error, it sets * the `stopped` and `trapped` flags to true, calls the callback with the * error if it is defined, and emits an "error" event with the error. * @private * @param {any} data */ onTrap(data) { if (data.message === "interrupted") { this.streaming = false; return; } this.stopped = true; this.trapped = true; if (this.callback) { this.callback(new Error(data.message), null, this); } else { this.emit("error", data); } this.emit("trap", data); } /** * Handles the "done" event. If the stream is stopped and the channel is * defined, it closes the channel with the force flag set to true. * @private */ onDone() { if (this.stopped && this.channel) { this.channel.close(true); } } }; var info5 = debug__default.default("routeros-api:api:info"); var error5 = debug__default.default("routeros-api:api:error"); var RouterOSAPI = class extends events.EventEmitter { /** * Host to connect */ host; /** * Username to use */ user; /** * Password of the username */ password; /** * Port of the API */ port; /** * Timeout of the connection */ timeout; /** * TLS Options to use, if any */ tls = null; /** * Connected flag */ connected = false; /** * Connecting flag */ connecting = false; /** * Closing flag */ closing = false; /** * Keep connection alive */ keepalive = false; /** * The connector which will be used */ connector = null; /** * The function timeout that will keep the connection alive */ keptaliveby = null; /** * Counter for channels open */ channelsOpen = 0; /** * Flag if the connection was held by the keepalive parameter * or keepaliveBy function */ holdingConnectionWithKeepalive = false; /** * Store the timeout when holding the connection * when waiting for a channel response */ connectionHoldInterval = null; /** * List of streams registered * for handling continuous data * from the routeros * * @type {RStream[]} */ registeredStreams = []; /** * Creates a new RouterOSAPI connection object with the given options * * @param {IRosOptions} options - connection options * @param {string} options.host - The host to connect to * @param {string} [options.user='admin'] - The username to use * @param {string} [options.password=''] - The password of the username * @param {number} [options.port=8728] - The port of the API * @param {number} [options.timeout=10] - The timeout of the connection * @param {TLSSocketOptions} [options.tls=null] - TLS Options to use, if any * @param {boolean} [options.keepalive=false] - Keep connection alive */ constructor(options) { super(); this.host = options.host; this.user = options.user ?? "admin"; this.password = options.password ?? ""; this.port = options.port ?? 8728; this.timeout = options.timeout ?? 10; this.tls = options.tls ?? null; this.keepalive = options.keepalive ?? false; info5("Created new RouterOSAPI instance with options: %o", options); } /** * Connect to the routerboard * * @returns {Promise<RouterOSAPI>} - The current RouterOSAPI instance */ connect() { if (this.connecting) return Promise.reject("ALRDYCONNECTING"); if (this.connected) return Promise.resolve(this); info5("Connecting on %s", this.host); this.connecting = true; this.connected = false; this.connector = new Connector({ host: this.host, port: this.port, timeout: this.timeout, tls: this.tls }); return new Promise((resolve, reject) => { const endListener = (e) => { this.stopAllStreams(); this.connected = false; if (e) reject(e); }; this.connector?.once("error", endListener); this.connector?.once("timeout", endListener); this.connector?.once("close", () => { this.emit("close"); endListener(); }); this.connector?.once("connected", () => { this.login().then(() => { this.connecting = false; this.connected = true; this.connector?.removeListener("error", endListener); this.connector?.removeListener("timeout", endListener); const connectedErrorListener = (e) => { this.connected = false; this.connecting = false; this.emit("error", e); }; this.connector?.once("error", connectedErrorListener); this.connector?.once("timeout", connectedErrorListener); if (this.keepalive) this.keepaliveBy("#"); info5("Logged in on %s", this.host); resolve(this); }).catch((e) => { this.connecting = false; this.connected = false; reject(e); }); }); this.connector?.connect(); }); } /** * Sends the provided command(s) to the routerboard via a new channel. * Opens a new channel for communication, writes the command(s) over * the socket, and manages the connection hold state. * * @param {string | string[]} params - The primary command or an array of commands to send. * @param {...Array<string | string[]>} moreParams - Additional commands or parameters. * @returns {Promise<IRosGenericResponse[]>} - A promise that resolves with the response from the routerboard. */ write(params, ...moreParams) { if (!this.connected) { new RosException("ENOTCONN"); } let chann = this.openChannel(); this.holdConnection(); chann.once("close", () => { chann = null; this.decreaseChannelsOpen(); this.releaseConnectionHold(); }); return chann.write(this.concatParams(params, moreParams)); } /** * Sends the provided command(s) to the routerboard via a new stream channel. * Opens a new channel for communication, writes the command(s) over * the socket, and manages the connection hold state. * * @param {string | string[]} params - The primary command or an array of commands to send. * @param {...Array<string | string[]>} moreParams - Additional commands or parameters. * @returns {RStream} - A stream object for handling continuous data flow. */ writeStream(params, ...moreParams) { const stream = new RStream( this.openChannel(), this.concatParams(params, moreParams) ); stream.on("started", () => { this.holdConnection(); }); stream.on("stopped", () => { this.unregisterStream(stream); this.decreaseChannelsOpen(); this.releaseConnectionHold(); }); stream.start(); this.registerStream(stream); return stream; } /** * Starts a new stream with the provided command(s) and optional callback. * If no callback is provided, the function will return the stream object. * If a callback is provided, the function will call the callback with the packet data. * * @param {string | string[]} params - The primary command or an array of commands to send. * @returns {RStream} - The stream object. */ stream(params = [], callback) { const stream = new RStream( this.openChannel(), typeof params === "string" ? [params] : params ); if (callback) stream.data(callback); stream.on("started", () => { this.holdConnection(); }); stream.on("stopped", () => { this.unregisterStream(stream); this.decreaseChannelsOpen(); this.releaseConnectionHold(); stream.removeAllListeners(); }); stream.start(); stream.prepareDebounceEmptyData(); this.registerStream(stream); return stream; } /** * Initiates a keepalive mechanism by executing the provided command(s) at regular intervals. * This function ensures that the connection remains active by continuously sending commands * to the server. If a callback is provided, it will be called with the packet data on success * or an error object on failure. * * @param {string | string[]} params - The primary command or an array of commands to send. * @param {function} [callback] - The callback function to handle the response data. */ keepaliveBy(params = [], callback) { this.holdingConnectionWithKeepalive = true; if (this.keptaliveby) timers.clearTimeout(this.keptaliveby); const exec = () => { if (!this.closing) { if (this.keptaliveby) timers.clearTimeout(this.keptaliveby); this.keptaliveby = setTimeout(() => { this.write(typeof params === "string" ? [params] : params).then((data) => { if (typeof callback === "function") callback(null, data); exec(); }).catch((err) => { if (typeof callback === "function") callback(err, null); exec(); }); }, 1e3); } }; exec(); } /** * Close the connection to the routerboard. If the connection is already * being closed, rejects the promise with the "ALRDYCLOSNG" error. * * @returns {Promise<RouterOSAPI>} - The current RouterOSAPI instance on success */ close() { if (this.closing) { return Promise.reject(new RosException("ALRDYCLOSNG")); } if (!this.connected) { return Promise.resolve(this); } if (this.connectionHoldInterval) { timers.clearTimeout(this.connectionHoldInterval); } if (this.keptaliveby) timers.clearTimeout(this.keptaliveby); this.stopAllStreams(); return new Promise((resolve) => { this.closing = true; this.connector?.once("close", () => { this.connector?.destroy(); this.connector = null; this.closing = false; this.connected = false; resolve(this); }); this.connector?.close(); }); } /** * Creates a new Channel instance and increases the channelsOpen counter. * Should be used by methods that need to create a new channel to * communicate with the routerboard. * @returns {Channel} - The newly created Channel instance */ openChannel() { if (!this.connector) { throw new RosException("ENOTCONN"); } this.increaseChannelsOpen(); return new Channel(this.connector); } /** * Increments the count of open channels. * This function should be called whenever a new channel is opened * to keep track of the current number of active channels. */ increaseChannelsOpen() { this.channelsOpen++; } /** * Decreases the count of open channels. * This function should be called whenever a channel is closed * to keep track of the current number of active channels. */ decreaseChannelsOpen() { this.channelsOpen--; } /** * Registers a RStream instance to keep track of it. * This method is used internally to keep track of all * RStream instances created by the RouterOSAPI instance. * @param {RStream} stream - RStream instance to be registered */ registerStream(stream) { this.registeredStreams.push(stream); } /** * Unregisters a RStream instance from the list of registered streams. * This function is called internally when a RStream instance is stopped. * @param {RStream} stream - RStream instance to be unregistered */ unregisterStream(stream) { this.registeredStreams = this.registeredStreams.filter( (registeredStreams) => registeredStreams !== stream ); } /** * Stops all registered RStream instances. * This method is called internally when the connection is closed. */ stopAllStreams() { for (const registeredStream of this.registeredStreams) { registeredStream.stop(); } } /** * If there is only one channel open, holds the connection by sending * a keepalive message to the routerboard every half of the timeout * period. This is necessary because the routerboard will close the * connection if no data is received for the timeout period. */ holdConnection() { if (this.channelsOpen !== 1) return; if (!(this.connected && !this.holdingConnectionWithKeepalive)) { return; } if (this.connectionHoldInterval) timers.clearTimeout(this.connectionHoldInterval); const holdConnInterval = () => { this.connectionHoldInterval = setTimeout(() => { let chann = this.connector ? new Channel(this.connector) : null; chann?.on("close", () => { chann = null; }); chann?.write(["#"]).then(() => { holdConnInterval(); }).catch(() => { holdConnInterval(); }); }, 1e3); }; holdConnInterval(); } /** * Releases the connection hold by clearing the keepalive interval. * If there are no channels open, it clears the keepalive interval, * effectively stopping the periodic keepalive messages to the * routerboard. If there are still channels open, the function * returns early without taking any action. */ releaseConnectionHold() { if (this.channelsOpen > 0) return; if (this.connectionHoldInterval) timers.clearTimeout(this.connectionHoldInterval); } /** * Connects to the routerboard using the credentials provided * in the constructor. It will either login using the 6.43+ * method or the old method. * * @returns {Promise<RouterOSAPI>} - The current instance of the RouterOSAPI * class, after it has connected. If the connection fails, it will * reject the promise with an error. */ login() { this.connecting = true; info5("Sending 6.43+ login to %s", this.host); return this.write("/login", [ `=name=${this.user}`, `=password=${this.password}` ]).then((data) => { if (data.length === 0) { info5("6.43+ Credentials accepted on %s, we are connected", this.host); re