@tikoci/centrs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterOS interactive-console reader over a MAC-Telnet session.
|
|
3
|
+
*
|
|
4
|
+
* MAC-Telnet (see `mac-telnet.ts`) gives a raw, bidirectional terminal stream —
|
|
5
|
+
* not a request/response API. To run a command and capture clean output, this
|
|
6
|
+
* layer drives the RouterOS console the way a terminal would and reconstructs
|
|
7
|
+
* the "screen", then strips the echoed command and the trailing prompt.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is grounded on stock CHR 7.23.1 (probes in `.scratch/`,
|
|
10
|
+
* captured while wiring `execute / mac-telnet`):
|
|
11
|
+
*
|
|
12
|
+
* - **Terminal-size probe at login.** The console emits a multi-step ANSI
|
|
13
|
+
* cursor probe: `ESC[9999B`/`ESC[9999C` (move to find the edges), `ESC[H`,
|
|
14
|
+
* `ESC Z` (DECID), mode sets, and repeated `ESC[6n` (DSR cursor-position
|
|
15
|
+
* report). Answering each `ESC[6n` with `ESC[<rows>;<cols>R` makes the
|
|
16
|
+
* console use that width (otherwise it falls back to 80 cols and *wraps* the
|
|
17
|
+
* echo, which breaks output extraction). We report a tall/wide screen so the
|
|
18
|
+
* console neither paginates nor wraps in capture mode.
|
|
19
|
+
* - **~10s login stall.** The MOTD/prompt does not appear until ~10s after
|
|
20
|
+
* login, on *every* login (not just the first). Answering the probe does not
|
|
21
|
+
* remove it — the multi-step probe seems to want a real terminal's clamped
|
|
22
|
+
* cursor tracking, which a fixed DSR answer does not satisfy, so the console
|
|
23
|
+
* waits out a negotiation timeout. This is a latency cost, not a correctness
|
|
24
|
+
* problem; `primeTimeoutMs` budgets for it. (Killing it would need a cursor
|
|
25
|
+
* emulator that answers each DSR with the real clamped position.)
|
|
26
|
+
* - **First-login license gate.** A fresh device prints the banner and
|
|
27
|
+
* `Do you want to see the software license? [Y/n]:` and blocks until
|
|
28
|
+
* answered. mac-telnet's whole point is reaching fresh/unconfigured gear, so
|
|
29
|
+
* `open()` auto-answers it (`n`). Later logins skip it.
|
|
30
|
+
* - **Prompt.** `[user@identity] > ` (root) or `[user@identity] /path> `,
|
|
31
|
+
* redrawn with CR + space-padding + CR. {@link ROUTEROS_PROMPT_RE}.
|
|
32
|
+
* - **Response shape** (after CR/LF terminal-emulation): line 0 is the echoed
|
|
33
|
+
* `[prompt] > <command>`, the middle lines are the output, and the last line
|
|
34
|
+
* is the trailing prompt. No ANSI appears in command responses — only in the
|
|
35
|
+
* login probe. A successful **write** (add/set/remove) prints nothing — no
|
|
36
|
+
* `.id`, no confirmation — so the middle is empty and only the bracketing
|
|
37
|
+
* prompt frames it (the same silent-write behavior `execute / ssh` sees).
|
|
38
|
+
* - **`:put [:parse "<cmd>"]`** returns a human string the console can be
|
|
39
|
+
* scraped for: `(evl …)` = ok, `syntax error (line …)` = malformed, and
|
|
40
|
+
* `bad parameter <name> (line …)` = unknown attribute. A single console
|
|
41
|
+
* `:parse` therefore covers both the syntax and the unknown-attribute
|
|
42
|
+
* (semantic) gate — no `/console/inspect` table parsing needed.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { parseRouterOsPosition } from "../core/routeros-errors.ts";
|
|
46
|
+
import { CentrsError } from "../errors.ts";
|
|
47
|
+
import {
|
|
48
|
+
type MacAddress,
|
|
49
|
+
type MacTelnetDatagramSink,
|
|
50
|
+
MacTelnetSession,
|
|
51
|
+
} from "./mac-telnet.ts";
|
|
52
|
+
|
|
53
|
+
const ESC = "\x1b";
|
|
54
|
+
const enc = new TextEncoder();
|
|
55
|
+
|
|
56
|
+
// ANSI strippers, built from the ESC constant so no literal control char sits in
|
|
57
|
+
// a regex source (which the linter forbids). CSI: `ESC [ <params> <inter> final`;
|
|
58
|
+
// ESC2: a two-byte escape `ESC <0x40-0x5f>` (e.g. `ESC Z`, `ESC D`).
|
|
59
|
+
const ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
60
|
+
const ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
|
|
61
|
+
|
|
62
|
+
/** RouterOS readline prompt: `[user@identity] > ` / `[user@identity] /ip/address> `. */
|
|
63
|
+
export const ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
|
|
64
|
+
|
|
65
|
+
/** How often the console drives {@link MacTelnetSession.tick} (retransmit/keepalive). */
|
|
66
|
+
const TICK_INTERVAL_MS = 15;
|
|
67
|
+
|
|
68
|
+
/** The first-login one-time license question. */
|
|
69
|
+
const LICENSE_RE = /do you want to see the software license/i;
|
|
70
|
+
|
|
71
|
+
export interface MacTelnetConsoleOptions {
|
|
72
|
+
/** Datagram sink the underlying session writes to (UDP socket, or a test bridge). */
|
|
73
|
+
sink: MacTelnetDatagramSink;
|
|
74
|
+
sourceMac: MacAddress;
|
|
75
|
+
destinationMac: MacAddress;
|
|
76
|
+
username: string;
|
|
77
|
+
password: string;
|
|
78
|
+
sessionKey?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Rows/cols reported to the console's DSR probe. Defaults are deliberately
|
|
81
|
+
* huge so the console never paginates (rows) or wraps the echo (cols) in
|
|
82
|
+
* capture mode. An interactive `terminal` would pass the real PTY size.
|
|
83
|
+
*/
|
|
84
|
+
rows?: number;
|
|
85
|
+
cols?: number;
|
|
86
|
+
/** Budget to reach the first prompt (covers banner/license + the ~10s stall). */
|
|
87
|
+
primeTimeoutMs?: number;
|
|
88
|
+
/** Per-command response budget. */
|
|
89
|
+
commandTimeoutMs?: number;
|
|
90
|
+
/** Quiescence after a prompt is seen before a response is considered complete. */
|
|
91
|
+
settleMs?: number;
|
|
92
|
+
/** Auto-answer the first-login software-license prompt with `n`. Default true. */
|
|
93
|
+
acceptLicense?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ConsoleCommandResult {
|
|
97
|
+
/** Clean output: echoed command and trailing prompt stripped, CRLF→\n. */
|
|
98
|
+
output: string;
|
|
99
|
+
/** The raw console bytes captured for this command (debugging). */
|
|
100
|
+
raw: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type Waiter = {
|
|
104
|
+
predicate: (buffer: string) => boolean;
|
|
105
|
+
resolve: () => void;
|
|
106
|
+
reject: (error: CentrsError) => void;
|
|
107
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
108
|
+
settle?: ReturnType<typeof setTimeout>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Emulate a CR/LF terminal: CR → column 0 (overwrite), LF → next line,
|
|
113
|
+
* BS → back one column, printable → write at the cursor. ANSI/CSI escapes are
|
|
114
|
+
* stripped (they appear only in the login probe). Returns the displayed lines,
|
|
115
|
+
* right-trimmed. This collapses the console's CR-based prompt redraws and
|
|
116
|
+
* space-padding into the single line a user would see.
|
|
117
|
+
*/
|
|
118
|
+
export function emulateScreen(text: string): string[] {
|
|
119
|
+
const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
|
|
120
|
+
const lines: string[][] = [[]];
|
|
121
|
+
let row = 0;
|
|
122
|
+
let col = 0;
|
|
123
|
+
for (const ch of clean) {
|
|
124
|
+
const code = ch.charCodeAt(0);
|
|
125
|
+
if (ch === "\n") {
|
|
126
|
+
row += 1;
|
|
127
|
+
if (!lines[row]) lines[row] = [];
|
|
128
|
+
col = 0;
|
|
129
|
+
} else if (ch === "\r") {
|
|
130
|
+
col = 0;
|
|
131
|
+
} else if (code === 8) {
|
|
132
|
+
col = Math.max(0, col - 1);
|
|
133
|
+
} else if (code >= 32 && code !== 0x7f) {
|
|
134
|
+
const line = lines[row] as string[];
|
|
135
|
+
line[col] = ch;
|
|
136
|
+
col += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return lines.map((line) =>
|
|
140
|
+
Array.from(line, (c) => c ?? " ")
|
|
141
|
+
.join("")
|
|
142
|
+
.replace(/\s+$/, ""),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Extract the command's clean output from its raw console response: drop the
|
|
148
|
+
* echoed-command line(s) and any trailing prompt / blank lines.
|
|
149
|
+
*
|
|
150
|
+
* The echo is `[prompt] > <command>`. With a normal-length command it is one
|
|
151
|
+
* line, but a command longer than the terminal width wraps onto further lines.
|
|
152
|
+
* When `command` is supplied, consume exactly as many echoed lines as the command
|
|
153
|
+
* spans (so a wrapped echo — e.g. a long `:put [:parse "…"]` — does not leak into
|
|
154
|
+
* the output); otherwise fall back to dropping the single first line.
|
|
155
|
+
*/
|
|
156
|
+
export function extractCommandOutput(raw: string, command?: string): string {
|
|
157
|
+
const lines = emulateScreen(raw);
|
|
158
|
+
let start = 1;
|
|
159
|
+
if (command !== undefined && lines.length > 0) {
|
|
160
|
+
const first = lines[0] ?? "";
|
|
161
|
+
const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
|
|
162
|
+
const echoedOnFirst = promptMatch
|
|
163
|
+
? first.slice(promptMatch[0].length)
|
|
164
|
+
: first;
|
|
165
|
+
let consumed = echoedOnFirst.length;
|
|
166
|
+
while (consumed < command.length && start < lines.length) {
|
|
167
|
+
consumed += (lines[start] ?? "").length;
|
|
168
|
+
start += 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const body = lines.slice(start);
|
|
172
|
+
while (body.length > 0) {
|
|
173
|
+
const last = body[body.length - 1] as string;
|
|
174
|
+
if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
|
|
175
|
+
body.pop();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
return body.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Drives one MAC-Telnet console session: login, terminal-probe negotiation,
|
|
185
|
+
* license, prompt sync, and command run/capture. The owner feeds inbound
|
|
186
|
+
* datagrams via {@link handlePacket} and provides the outbound {@link sink}.
|
|
187
|
+
*/
|
|
188
|
+
export class MacTelnetConsole {
|
|
189
|
+
private readonly options: Required<
|
|
190
|
+
Omit<MacTelnetConsoleOptions, "sink" | "sessionKey">
|
|
191
|
+
> &
|
|
192
|
+
Pick<MacTelnetConsoleOptions, "sink" | "sessionKey">;
|
|
193
|
+
private readonly session: MacTelnetSession;
|
|
194
|
+
private buffer = "";
|
|
195
|
+
private ready = false;
|
|
196
|
+
private closed = false;
|
|
197
|
+
private closeError?: CentrsError;
|
|
198
|
+
private waiter?: Waiter;
|
|
199
|
+
private tickTimer?: ReturnType<typeof setInterval>;
|
|
200
|
+
private readyWaiters: Array<{
|
|
201
|
+
resolve: () => void;
|
|
202
|
+
reject: (e: CentrsError) => void;
|
|
203
|
+
}> = [];
|
|
204
|
+
private readonly decoder = new TextDecoder();
|
|
205
|
+
/** Rolling tail across chunks so a terminal probe split across packets is still matched. */
|
|
206
|
+
private probeTail = "";
|
|
207
|
+
/** Interactive relay: when set, raw device bytes are forwarded here verbatim. */
|
|
208
|
+
private outputSink?: (bytes: Uint8Array) => void;
|
|
209
|
+
/** In an interactive TTY relay the downstream terminal answers the size probe. */
|
|
210
|
+
private suppressProbeAnswer = false;
|
|
211
|
+
/** Listeners notified once the session closes (drives a terminal relay's exit). */
|
|
212
|
+
private closeListeners: Array<(error?: CentrsError) => void> = [];
|
|
213
|
+
|
|
214
|
+
constructor(options: MacTelnetConsoleOptions) {
|
|
215
|
+
this.options = {
|
|
216
|
+
rows: 9999,
|
|
217
|
+
cols: 512,
|
|
218
|
+
primeTimeoutMs: 30_000,
|
|
219
|
+
commandTimeoutMs: 15_000,
|
|
220
|
+
settleMs: 150,
|
|
221
|
+
acceptLicense: true,
|
|
222
|
+
...options,
|
|
223
|
+
};
|
|
224
|
+
this.session = new MacTelnetSession({
|
|
225
|
+
sink: options.sink,
|
|
226
|
+
sourceMac: options.sourceMac,
|
|
227
|
+
destinationMac: options.destinationMac,
|
|
228
|
+
username: options.username,
|
|
229
|
+
password: options.password,
|
|
230
|
+
sessionKey: options.sessionKey,
|
|
231
|
+
terminalType: "vt102",
|
|
232
|
+
terminalWidth: this.options.cols,
|
|
233
|
+
terminalHeight: this.options.rows,
|
|
234
|
+
onReady: () => this.onReady(),
|
|
235
|
+
onData: (bytes) => this.onData(bytes),
|
|
236
|
+
onClose: (error) => this.onClose(error),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Feed an inbound datagram into the session. */
|
|
241
|
+
handlePacket(bytes: Uint8Array): void {
|
|
242
|
+
// This is called from a socket `data`/`message` event — it must never throw
|
|
243
|
+
// back to the emitter. Outbound sends are already fault-tolerant; an
|
|
244
|
+
// unexpected processing error here closes the console (rejecting waiters)
|
|
245
|
+
// rather than crashing the caller's event loop.
|
|
246
|
+
try {
|
|
247
|
+
this.session.handlePacket(bytes);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.onClose(
|
|
250
|
+
error instanceof CentrsError
|
|
251
|
+
? error
|
|
252
|
+
: new CentrsError({
|
|
253
|
+
code: "routeros/mac-telnet-protocol",
|
|
254
|
+
summary: "Failed to process a MAC-Telnet datagram.",
|
|
255
|
+
remediation: "Re-open the session and retry.",
|
|
256
|
+
cause: error,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Log in, negotiate the terminal, clear the license, and synchronize on the
|
|
264
|
+
* first prompt. Resolves once the console is ready to take commands.
|
|
265
|
+
*/
|
|
266
|
+
async open(): Promise<void> {
|
|
267
|
+
// Drive the session's retransmit + keepalive timers. The tick interval is
|
|
268
|
+
// small enough to honor the reference retransmit backoff (15ms first step) —
|
|
269
|
+
// a coarse tick would collapse the early 15/20/30ms steps and delay recovery
|
|
270
|
+
// from packet loss during session start/auth. `unref` so a dangling console
|
|
271
|
+
// never keeps the process alive.
|
|
272
|
+
this.tickTimer = setInterval(() => {
|
|
273
|
+
try {
|
|
274
|
+
this.session.tick(Date.now());
|
|
275
|
+
} catch {
|
|
276
|
+
/* timer callback must not throw to the event loop */
|
|
277
|
+
}
|
|
278
|
+
}, TICK_INTERVAL_MS);
|
|
279
|
+
this.tickTimer.unref?.();
|
|
280
|
+
this.session.start();
|
|
281
|
+
await this.waitReady(this.options.primeTimeoutMs);
|
|
282
|
+
// Wait for the banner / license / first prompt (covers the ~10s stall).
|
|
283
|
+
await this.waitFor(
|
|
284
|
+
(buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer),
|
|
285
|
+
this.options.primeTimeoutMs,
|
|
286
|
+
"waiting for the RouterOS console prompt",
|
|
287
|
+
);
|
|
288
|
+
if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
|
|
289
|
+
this.buffer = "";
|
|
290
|
+
this.session.sendInput(enc.encode("n\r"));
|
|
291
|
+
await this.waitFor(
|
|
292
|
+
(buffer) => this.endsWithPrompt(buffer),
|
|
293
|
+
this.options.primeTimeoutMs,
|
|
294
|
+
"waiting for the prompt after the license screen",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// Land on a clean, empty prompt and discard the banner.
|
|
298
|
+
this.buffer = "";
|
|
299
|
+
this.session.sendInput(enc.encode("\r"));
|
|
300
|
+
await this.waitFor(
|
|
301
|
+
(buffer) => this.endsWithPrompt(buffer),
|
|
302
|
+
this.options.commandTimeoutMs,
|
|
303
|
+
"waiting for a clean prompt",
|
|
304
|
+
);
|
|
305
|
+
this.buffer = "";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Run one CLI command and return its clean output. */
|
|
309
|
+
async run(cli: string): Promise<ConsoleCommandResult> {
|
|
310
|
+
this.assertOpen();
|
|
311
|
+
this.buffer = "";
|
|
312
|
+
this.session.sendInput(enc.encode(`${cli}\r`));
|
|
313
|
+
await this.waitFor(
|
|
314
|
+
(buffer) => this.endsWithPrompt(buffer),
|
|
315
|
+
this.options.commandTimeoutMs,
|
|
316
|
+
`running over mac-telnet: ${cli}`,
|
|
317
|
+
);
|
|
318
|
+
const raw = this.buffer;
|
|
319
|
+
return { output: extractCommandOutput(raw, cli), raw };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validate a command without running it, using a single console `:parse`.
|
|
324
|
+
* Throws `validation/syntax` for malformed CLI and
|
|
325
|
+
* `validation/unknown-attribute` for a rejected parameter; returns the parsed
|
|
326
|
+
* form on success. One gate covers both because the console's `:parse`
|
|
327
|
+
* reports `bad parameter <name>` as well as `syntax error`.
|
|
328
|
+
*/
|
|
329
|
+
async parseGate(cli: string): Promise<string> {
|
|
330
|
+
const { output } = await this.run(parseScriptFor(cli));
|
|
331
|
+
classifyParseResult(output, cli);
|
|
332
|
+
return output;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Switch the (already-open) console into interactive raw passthrough: device
|
|
337
|
+
* bytes are forwarded verbatim to `sink` instead of being captured, and the
|
|
338
|
+
* capture buffer is dropped. Call after {@link open} resolves.
|
|
339
|
+
*
|
|
340
|
+
* `answerProbe` defaults true (batch relay: no downstream terminal, so the
|
|
341
|
+
* console still auto-answers the size probe). An interactive TTY relay passes
|
|
342
|
+
* `false` so the user's real terminal answers it instead of double-replying.
|
|
343
|
+
*/
|
|
344
|
+
attachInteractive(
|
|
345
|
+
sink: (bytes: Uint8Array) => void,
|
|
346
|
+
options: { answerProbe?: boolean } = {},
|
|
347
|
+
): void {
|
|
348
|
+
this.outputSink = sink;
|
|
349
|
+
this.suppressProbeAnswer = options.answerProbe === false;
|
|
350
|
+
// Cancel any pending capture waiter; interactive mode never resolves one.
|
|
351
|
+
if (this.waiter) {
|
|
352
|
+
clearTimeout(this.waiter.timeout);
|
|
353
|
+
if (this.waiter.settle) clearTimeout(this.waiter.settle);
|
|
354
|
+
this.waiter = undefined;
|
|
355
|
+
}
|
|
356
|
+
this.buffer = "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Forward raw input bytes (keystrokes / piped commands) to the device. */
|
|
360
|
+
write(bytes: Uint8Array): void {
|
|
361
|
+
if (this.closed) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.session.sendInput(bytes);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Update the reported terminal size (e.g. on `SIGWINCH`). Best-effort: the new
|
|
369
|
+
* size is used for any subsequent device size probe; RouterOS does not renegotiate
|
|
370
|
+
* mac-telnet terminal dimensions mid-session, so a live resize is advisory.
|
|
371
|
+
*/
|
|
372
|
+
reportSize(rows: number, cols: number): void {
|
|
373
|
+
this.options.rows = rows;
|
|
374
|
+
this.options.cols = cols;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Register a listener fired once the session closes (drives a relay's exit). */
|
|
378
|
+
onClosed(listener: (error?: CentrsError) => void): void {
|
|
379
|
+
if (this.closed) {
|
|
380
|
+
listener(this.closeError);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.closeListeners.push(listener);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Send END and close the session. */
|
|
387
|
+
close(): void {
|
|
388
|
+
if (this.tickTimer) {
|
|
389
|
+
clearInterval(this.tickTimer);
|
|
390
|
+
this.tickTimer = undefined;
|
|
391
|
+
}
|
|
392
|
+
if (!this.closed) {
|
|
393
|
+
this.session.end();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True once login + prime completed and the console accepts commands. */
|
|
398
|
+
get isReady(): boolean {
|
|
399
|
+
return this.ready && !this.closed;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── internals ──────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
private onReady(): void {
|
|
405
|
+
this.ready = true;
|
|
406
|
+
for (const w of this.readyWaiters.splice(0)) {
|
|
407
|
+
w.resolve();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private onData(bytes: Uint8Array): void {
|
|
412
|
+
// Stream-decode so a multi-byte char split across datagrams is not corrupted.
|
|
413
|
+
const chunk = this.decoder.decode(bytes, { stream: true });
|
|
414
|
+
// Match the size probe against `(carry || prev tail) + chunk` so an `ESC[6n`
|
|
415
|
+
// straddling a packet boundary is still answered. The carry is exactly the
|
|
416
|
+
// longest probe (`ESC[6n`, 4 bytes) minus one, so a full probe never fits in
|
|
417
|
+
// the carry alone — i.e. a match always includes a fresh byte and is answered
|
|
418
|
+
// once, never re-answered as the tail slides.
|
|
419
|
+
const combined = `${this.probeTail}${chunk}`;
|
|
420
|
+
this.answerSizeProbe(combined);
|
|
421
|
+
this.probeTail = combined.slice(-3);
|
|
422
|
+
if (this.outputSink) {
|
|
423
|
+
// Interactive relay: forward raw device bytes verbatim and do not grow a
|
|
424
|
+
// capture buffer (a held-open terminal would accumulate without bound).
|
|
425
|
+
this.outputSink(bytes);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
this.buffer += chunk;
|
|
429
|
+
this.checkWaiter();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private onClose(error?: CentrsError): void {
|
|
433
|
+
this.closed = true;
|
|
434
|
+
this.closeError = error;
|
|
435
|
+
if (this.tickTimer) {
|
|
436
|
+
clearInterval(this.tickTimer);
|
|
437
|
+
this.tickTimer = undefined;
|
|
438
|
+
}
|
|
439
|
+
for (const listener of this.closeListeners.splice(0)) {
|
|
440
|
+
listener(error);
|
|
441
|
+
}
|
|
442
|
+
const failure =
|
|
443
|
+
error ??
|
|
444
|
+
new CentrsError({
|
|
445
|
+
code: "transport/connection-closed",
|
|
446
|
+
summary: "The MAC-Telnet console session closed.",
|
|
447
|
+
remediation: "Re-open the session and retry.",
|
|
448
|
+
});
|
|
449
|
+
for (const w of this.readyWaiters.splice(0)) {
|
|
450
|
+
w.reject(failure);
|
|
451
|
+
}
|
|
452
|
+
if (this.waiter) {
|
|
453
|
+
const waiter = this.waiter;
|
|
454
|
+
this.waiter = undefined;
|
|
455
|
+
clearTimeout(waiter.timeout);
|
|
456
|
+
if (waiter.settle) clearTimeout(waiter.settle);
|
|
457
|
+
waiter.reject(failure);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Answer the console's terminal-size probe. The operative query is DSR
|
|
463
|
+
* (`ESC[6n`); replying with our reported rows/cols stops the console wrapping
|
|
464
|
+
* at 80. We also answer DECID (`ESC Z`) / primary DA (`ESC[c`) with a VT102
|
|
465
|
+
* identification. (This does not remove the ~10s negotiation stall.)
|
|
466
|
+
*/
|
|
467
|
+
private answerSizeProbe(chunk: string): void {
|
|
468
|
+
// In an interactive TTY relay the user's real terminal answers the probe;
|
|
469
|
+
// answering it ourselves too would double-reply. Batch mode (no TTY) keeps
|
|
470
|
+
// answering so the console still gets a wide screen.
|
|
471
|
+
if (this.suppressProbeAnswer) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (chunk.includes(`${ESC}[6n`)) {
|
|
475
|
+
// The console probes height and width with several DSR queries; answer
|
|
476
|
+
// every one (a fixed reply still sets the width and avoids 80-col wrap).
|
|
477
|
+
this.session.sendInput(
|
|
478
|
+
enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
|
|
482
|
+
this.session.sendInput(enc.encode(`${ESC}[?6c`));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private endsWithPrompt(buffer: string): boolean {
|
|
487
|
+
const lines = emulateScreen(buffer).filter((line) => line.length > 0);
|
|
488
|
+
return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private waitReady(timeoutMs: number): Promise<void> {
|
|
492
|
+
if (this.ready) return Promise.resolve();
|
|
493
|
+
if (this.closed) {
|
|
494
|
+
return Promise.reject(
|
|
495
|
+
this.closeError ??
|
|
496
|
+
new CentrsError({
|
|
497
|
+
code: "transport/connection-closed",
|
|
498
|
+
summary: "The MAC-Telnet session closed before login completed.",
|
|
499
|
+
remediation: "Confirm the device and credentials, then retry.",
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
// Bound the wait: if the device never responds (or auth never completes
|
|
504
|
+
// without closing), open() would otherwise hang forever, ignoring --timeout.
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
const entry = {
|
|
507
|
+
resolve: () => {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
resolve();
|
|
510
|
+
},
|
|
511
|
+
reject: (error: CentrsError) => {
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
reject(error);
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
const timer = setTimeout(() => {
|
|
517
|
+
this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
|
|
518
|
+
reject(
|
|
519
|
+
new CentrsError({
|
|
520
|
+
code: "transport/timeout",
|
|
521
|
+
summary:
|
|
522
|
+
"MAC-Telnet login did not complete — no console response from the device.",
|
|
523
|
+
remediation:
|
|
524
|
+
"Confirm the device is reachable over mac-telnet (mac-server interface list) and the credentials are correct.",
|
|
525
|
+
context: { timeoutMs },
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
this.readyWaiters.push(entry);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Resolve once `predicate(buffer)` holds and the stream has been quiet for
|
|
535
|
+
* `settleMs` (so a prompt seen mid-redraw captures the full final prompt).
|
|
536
|
+
*/
|
|
537
|
+
private waitFor(
|
|
538
|
+
predicate: (buffer: string) => boolean,
|
|
539
|
+
timeoutMs: number,
|
|
540
|
+
label: string,
|
|
541
|
+
): Promise<void> {
|
|
542
|
+
if (this.closed) {
|
|
543
|
+
return Promise.reject(
|
|
544
|
+
this.closeError ??
|
|
545
|
+
new CentrsError({
|
|
546
|
+
code: "transport/connection-closed",
|
|
547
|
+
summary: `MAC-Telnet session closed while ${label}.`,
|
|
548
|
+
remediation: "Re-open the session and retry.",
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return new Promise<void>((resolve, reject) => {
|
|
553
|
+
const timeout = setTimeout(() => {
|
|
554
|
+
this.waiter = undefined;
|
|
555
|
+
reject(
|
|
556
|
+
new CentrsError({
|
|
557
|
+
code: "transport/timeout",
|
|
558
|
+
summary: `Timed out ${label}.`,
|
|
559
|
+
remediation:
|
|
560
|
+
"The RouterOS console did not return a prompt in time; raise the timeout or confirm the device is responsive over mac-telnet.",
|
|
561
|
+
context: { label, timeoutMs },
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
}, timeoutMs);
|
|
565
|
+
this.waiter = { predicate, resolve, reject, timeout };
|
|
566
|
+
this.checkWaiter();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private checkWaiter(): void {
|
|
571
|
+
const waiter = this.waiter;
|
|
572
|
+
if (!waiter) return;
|
|
573
|
+
if (!waiter.predicate(this.buffer)) {
|
|
574
|
+
// New data invalidated a pending settle (still mid-response).
|
|
575
|
+
if (waiter.settle) {
|
|
576
|
+
clearTimeout(waiter.settle);
|
|
577
|
+
waiter.settle = undefined;
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
// Predicate holds; (re)start the settle timer so trailing redraw lands.
|
|
582
|
+
if (waiter.settle) clearTimeout(waiter.settle);
|
|
583
|
+
waiter.settle = setTimeout(() => {
|
|
584
|
+
if (this.waiter !== waiter) return;
|
|
585
|
+
this.waiter = undefined;
|
|
586
|
+
clearTimeout(waiter.timeout);
|
|
587
|
+
waiter.resolve();
|
|
588
|
+
}, this.options.settleMs);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private assertOpen(): void {
|
|
592
|
+
if (!this.ready || this.closed) {
|
|
593
|
+
throw new CentrsError({
|
|
594
|
+
code: "routeros/mac-telnet-not-ready",
|
|
595
|
+
summary: "The MAC-Telnet console is not open.",
|
|
596
|
+
remediation: "Call open() and await it before running commands.",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The `:put [:parse "<cli>"]` script that validates `cli` over the console. */
|
|
603
|
+
export function parseScriptFor(cli: string): string {
|
|
604
|
+
return `:put [:parse ${routerOsStringLiteral(cli)}]`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Classify the output of a console `:put [:parse "<cli>"]`. A single console
|
|
609
|
+
* `:parse` reports both forms (grounded on CHR 7.23.1): `bad parameter <name>`
|
|
610
|
+
* for an unknown attribute and `syntax error` / `bad command name` for malformed
|
|
611
|
+
* CLI. Throws the matching `validation/*` error; returns on a clean parse.
|
|
612
|
+
*/
|
|
613
|
+
export function classifyParseResult(output: string, cli: string): void {
|
|
614
|
+
// The console `:parse` rejection carries RouterOS's authoritative byte offset
|
|
615
|
+
// (`… (line N column M)`); surface it as the structured `error.position`.
|
|
616
|
+
const position = parseRouterOsPosition(output);
|
|
617
|
+
const unknown = output.match(/bad parameter\s+(\S+)/i);
|
|
618
|
+
if (unknown) {
|
|
619
|
+
throw new CentrsError({
|
|
620
|
+
code: "validation/unknown-attribute",
|
|
621
|
+
summary: `RouterOS rejected the parameter "${cleanToken(unknown[1] ?? "")}" while parsing the command.`,
|
|
622
|
+
remediation:
|
|
623
|
+
"Remove or rename the attribute; check it against the RouterOS path's parameters.",
|
|
624
|
+
context: {
|
|
625
|
+
command: cli,
|
|
626
|
+
parameter: cleanToken(unknown[1] ?? ""),
|
|
627
|
+
validationSource: ":put [:parse ...] over mac-telnet",
|
|
628
|
+
detail: output,
|
|
629
|
+
},
|
|
630
|
+
...(position ? { position } : {}),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (/syntax error|bad command name|expected\b/i.test(output)) {
|
|
634
|
+
throw new CentrsError({
|
|
635
|
+
code: "validation/syntax",
|
|
636
|
+
summary: "RouterOS rejected the command syntax while parsing it.",
|
|
637
|
+
remediation:
|
|
638
|
+
"Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
|
|
639
|
+
context: {
|
|
640
|
+
command: cli,
|
|
641
|
+
validationSource: ":put [:parse ...] over mac-telnet",
|
|
642
|
+
detail: output,
|
|
643
|
+
},
|
|
644
|
+
...(position ? { position } : {}),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function routerOsStringLiteral(value: string): string {
|
|
650
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function cleanToken(token: string): string {
|
|
654
|
+
return token.replace(/^["'`]+|["'`.,;:)]+$/g, "");
|
|
655
|
+
}
|