Initial commit baby!
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import * as net from 'net';
|
||||
import * as crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface RouterOSSentence {
|
||||
type: string;
|
||||
words: Record<string, string>;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export class RouterOSError extends Error {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'RouterOSError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RouterOSClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
private connected = false;
|
||||
private authenticated = false;
|
||||
|
||||
// Sequential command queue: each command waits for previous to complete
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Pending read resolver (one outstanding read at a time)
|
||||
private pendingRead: {
|
||||
resolve: (s: RouterOSSentence) => void;
|
||||
reject: (e: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
} | null = null;
|
||||
private sentenceQueue: RouterOSSentence[] = [];
|
||||
|
||||
private tagCounter = 0;
|
||||
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly port: number = 8728,
|
||||
private readonly username: string,
|
||||
private readonly password: string,
|
||||
private readonly connectTimeoutMs: number = 15000,
|
||||
private readonly readTimeoutMs: number = 30000
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new RouterOSError(`Connection timeout to ${this.host}:${this.port}`));
|
||||
this.socket?.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
|
||||
this.socket = net.createConnection({ host: this.host, port: this.port });
|
||||
|
||||
this.socket.on('connect', async () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
await this.authenticate();
|
||||
this.connected = true;
|
||||
this.authenticated = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
this.socket?.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError(`Socket error: ${err.message}`));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError('Connection closed'));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enqueue a command so concurrent calls don't interleave
|
||||
async execute(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
queries: string[] = []
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeRaw(command, params, queries);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
queries: string[]
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
for (const q of queries) {
|
||||
words.push(q);
|
||||
}
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
while (true) {
|
||||
const sentence = await this.readNextSentence();
|
||||
if (sentence.type === '!done') break;
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!trap') {
|
||||
// RouterOS always sends !done after !trap — drain it to keep the stream in sync.
|
||||
// Without this, the stale !done is consumed by the next command, causing it to
|
||||
// exit immediately with empty results before its real response arrives.
|
||||
await this.readNextSentence().catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.sentenceQueue = [];
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.authenticated;
|
||||
}
|
||||
|
||||
// ─── RouterOS API Protocol ────────────────────────────────────────────────
|
||||
|
||||
private async authenticate(): Promise<void> {
|
||||
// Try plain-text login (RouterOS 6.49+ and RouterOS 7.x)
|
||||
await this.sendSentence(['/login', `=name=${this.username}`, `=password=${this.password}`]);
|
||||
const first = await this.readNextSentence();
|
||||
|
||||
if (first.type === '!done') {
|
||||
return; // v7 / newer v6 plain-text login succeeded
|
||||
}
|
||||
|
||||
if (first.type === '!re' && first.words['ret']) {
|
||||
// Legacy v6 challenge-response
|
||||
const challengeHex = first.words['ret'];
|
||||
// Drain the !done that follows the !re
|
||||
await this.readNextSentence();
|
||||
|
||||
const challenge = Buffer.from(challengeHex, 'hex');
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([0x00]));
|
||||
md5.update(Buffer.from(this.password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const responseHex = '00' + md5.digest('hex');
|
||||
|
||||
await this.sendSentence([
|
||||
'/login',
|
||||
`=name=${this.username}`,
|
||||
`=response=${responseHex}`,
|
||||
]);
|
||||
const result = await this.readNextSentence();
|
||||
if (result.type !== '!done') {
|
||||
throw new RouterOSError(
|
||||
result.words['message'] || 'Authentication failed (legacy MD5)',
|
||||
result.words['category']
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === '!trap') {
|
||||
throw new RouterOSError(
|
||||
first.words['message'] || 'Authentication failed',
|
||||
first.words['category']
|
||||
);
|
||||
}
|
||||
|
||||
throw new RouterOSError(`Unexpected login response: ${first.type}`);
|
||||
}
|
||||
|
||||
private encodeLength(len: number): Buffer {
|
||||
if (len < 0x80) {
|
||||
return Buffer.from([len]);
|
||||
} else if (len < 0x4000) {
|
||||
return Buffer.from([(len >> 8) | 0x80, len & 0xff]);
|
||||
} else if (len < 0x200000) {
|
||||
return Buffer.from([(len >> 16) | 0xc0, (len >> 8) & 0xff, len & 0xff]);
|
||||
} else if (len < 0x10000000) {
|
||||
return Buffer.from([
|
||||
(len >> 24) | 0xe0,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
} else {
|
||||
return Buffer.from([
|
||||
0xf0,
|
||||
(len >> 24) & 0xff,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeLength(
|
||||
buf: Buffer,
|
||||
offset: number
|
||||
): { length: number; bytesConsumed: number } | null {
|
||||
if (offset >= buf.length) return null;
|
||||
const b = buf[offset];
|
||||
|
||||
if (b < 0x80) {
|
||||
return { length: b, bytesConsumed: 1 };
|
||||
} else if (b < 0xc0) {
|
||||
if (offset + 1 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x3f) << 8) | buf[offset + 1],
|
||||
bytesConsumed: 2,
|
||||
};
|
||||
} else if (b < 0xe0) {
|
||||
if (offset + 2 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x1f) << 16) | (buf[offset + 1] << 8) | buf[offset + 2],
|
||||
bytesConsumed: 3,
|
||||
};
|
||||
} else if (b < 0xf0) {
|
||||
if (offset + 3 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
((b & 0x0f) << 24) |
|
||||
(buf[offset + 1] << 16) |
|
||||
(buf[offset + 2] << 8) |
|
||||
buf[offset + 3],
|
||||
bytesConsumed: 4,
|
||||
};
|
||||
} else {
|
||||
if (offset + 4 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
(buf[offset + 1] << 24) |
|
||||
(buf[offset + 2] << 16) |
|
||||
(buf[offset + 3] << 8) |
|
||||
buf[offset + 4],
|
||||
bytesConsumed: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendSentence(words: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) return reject(new RouterOSError('Socket not available'));
|
||||
const parts: Buffer[] = [];
|
||||
for (const word of words) {
|
||||
const wb = Buffer.from(word, 'utf8');
|
||||
parts.push(this.encodeLength(wb.length), wb);
|
||||
}
|
||||
parts.push(Buffer.from([0x00])); // end-of-sentence
|
||||
this.socket.write(Buffer.concat(parts), (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(): void {
|
||||
while (true) {
|
||||
const sentence = this.tryParseSentence();
|
||||
if (!sentence) break;
|
||||
|
||||
if (this.pendingRead) {
|
||||
clearTimeout(this.pendingRead.timeout);
|
||||
const { resolve } = this.pendingRead;
|
||||
this.pendingRead = null;
|
||||
resolve(sentence);
|
||||
} else {
|
||||
this.sentenceQueue.push(sentence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tryParseSentence(): RouterOSSentence | null {
|
||||
const words: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < this.buffer.length) {
|
||||
const dec = this.decodeLength(this.buffer, offset);
|
||||
if (!dec) return null; // need more bytes
|
||||
|
||||
const { length, bytesConsumed } = dec;
|
||||
|
||||
if (length === 0) {
|
||||
// End of sentence — consume it
|
||||
this.buffer = this.buffer.slice(offset + 1);
|
||||
|
||||
if (words.length === 0) return null;
|
||||
|
||||
const type = words[0];
|
||||
const parsed: Record<string, string> = {};
|
||||
let tag: string | undefined;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
if (w.startsWith('=')) {
|
||||
const eq = w.indexOf('=', 1);
|
||||
if (eq > 0) parsed[w.slice(1, eq)] = w.slice(eq + 1);
|
||||
} else if (w.startsWith('.tag=')) {
|
||||
tag = w.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
return { type, words: parsed, tag };
|
||||
}
|
||||
|
||||
if (offset + bytesConsumed + length > this.buffer.length) {
|
||||
return null; // need more bytes
|
||||
}
|
||||
|
||||
words.push(
|
||||
this.buffer
|
||||
.slice(offset + bytesConsumed, offset + bytesConsumed + length)
|
||||
.toString('utf8')
|
||||
);
|
||||
offset += bytesConsumed + length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNextSentence(timeoutMs = this.readTimeoutMs): Promise<RouterOSSentence> {
|
||||
// Check queue first
|
||||
if (this.sentenceQueue.length > 0) {
|
||||
return Promise.resolve(this.sentenceQueue.shift()!);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRead = null;
|
||||
reject(new RouterOSError('Read timeout waiting for API response'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRead = {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Execute a streaming/generator command (one that never sends !done on its own,
|
||||
// e.g. /tool/ip-scan). Collects !re rows until maxDurationMs elapses, then
|
||||
// sends /cancel to terminate the command and drains the cleanup response.
|
||||
async executeStreaming(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
maxDurationMs = 30_000
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeStreamingRaw(command, params, maxDurationMs);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeStreamingRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
maxDurationMs: number
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const tag = String(++this.tagCounter);
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
words.push(`.tag=${tag}`);
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
const deadline = Date.now() + maxDurationMs;
|
||||
|
||||
// Collect !re sentences until deadline or until !done / !fatal arrives
|
||||
while (true) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
|
||||
let sentence: RouterOSSentence;
|
||||
try {
|
||||
sentence = await this.readNextSentence(Math.min(remaining, this.readTimeoutMs));
|
||||
} catch {
|
||||
// Timeout or socket error — fall through to cancel
|
||||
break;
|
||||
}
|
||||
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!done') {
|
||||
// Command finished on its own (shouldn't happen for generators, but handle it)
|
||||
return results;
|
||||
} else if (sentence.type === '!trap') {
|
||||
// Drain the !done that follows !trap
|
||||
await this.readNextSentence(5_000).catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
|
||||
// Send /cancel and drain responses (RouterOS replies !trap + !done to cancel)
|
||||
try {
|
||||
await this.sendSentence(['/cancel', `=tag=${tag}`]);
|
||||
// Drain up to 2 sentences (!trap and/or !done) with a short timeout each
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const s = await this.readNextSentence(5_000).catch(() => null);
|
||||
if (!s || s.type === '!done') break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user