@vermaysha/routeros
Version:
NodeJS / Bun RouterOS API
1,576 lines (1,568 loc) • 52.5 kB
JavaScript
'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