@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.
Files changed (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Host-OpenSSH plumbing shared by the SSH transports, plus the **execute** client.
3
+ *
4
+ * Like {@link ./sftp.ts}, this shells out to the host `ssh`/`sftp` so it inherits
5
+ * `~/.ssh/config`, the ssh-agent, `known_hosts`, and RouterOS's algorithm set for
6
+ * free. RouterOS's SSH server grants **no pseudo-tty**, but a single-line
7
+ * `ssh user@host "<command>"` *does* run on the console and return clean output —
8
+ * no prompt, no ANSI, no echo (CHR 7.23.1 grounded). So `execute / ssh` is a
9
+ * per-command batch client (one `ssh` invocation per command), not a persistent
10
+ * screen-emulating reader; the only post-processing is trimming the console's
11
+ * column padding. Validation reuses the mac-telnet `:put [:parse …]` gate verbatim
12
+ * — over SSH it returns the same `(evl …)` / `bad parameter <name>` strings.
13
+ *
14
+ * See `commands/execute/README.md` (ssh) and `commands/terminal/README.md`.
15
+ */
16
+
17
+ import { CentrsError } from "../errors.ts";
18
+
19
+ export interface SshConnectionConfig {
20
+ host: string;
21
+ port: number;
22
+ username?: string;
23
+ /** Private-key path (`-i`); when unset, the agent / `~/.ssh/config` is used. */
24
+ sshKey?: string;
25
+ /**
26
+ * Opt out of strict host-key checking. Default (false) is `accept-new`
27
+ * (trust-on-first-use into the user's `known_hosts`); `true` disables checking
28
+ * entirely (`StrictHostKeyChecking=no` + a null `known_hosts` sink —
29
+ * `/dev/null`, or `NUL` on Windows), so a *changed* / impersonated host key is
30
+ * accepted too.
31
+ */
32
+ insecure?: boolean;
33
+ /** Per-operation timeout (also the connect timeout, in whole seconds). */
34
+ timeoutMs: number;
35
+ /** Override the `known_hosts` file (tests / ephemeral runs). */
36
+ knownHostsFile?: string;
37
+ /**
38
+ * Interactive session (the `terminal` relay): **omit** `BatchMode=yes` so the
39
+ * host `ssh` may prompt on the inherited TTY for a key passphrase or a
40
+ * password. The batch clients (`sftp`, `execute`) leave this unset, keeping
41
+ * `BatchMode=yes` so a missing credential fails fast instead of hanging on a
42
+ * prompt no one can answer.
43
+ */
44
+ interactive?: boolean;
45
+ }
46
+
47
+ /**
48
+ * The `-i` / `-o` options common to the host `ssh` and `sftp` clients (the port
49
+ * flag differs — `ssh -p` vs `sftp -P` — so it stays with each caller). Keeping
50
+ * one builder means the key, batch-mode, connect-timeout, and host-key trust are
51
+ * identical across every SSH transport.
52
+ */
53
+ export function sshCommonOptions(config: SshConnectionConfig): string[] {
54
+ const connectSeconds = Math.max(1, Math.ceil(config.timeoutMs / 1000));
55
+ const options: string[] = [];
56
+ if (config.sshKey) {
57
+ options.push("-i", config.sshKey);
58
+ }
59
+ // Batch clients (sftp/execute) keep BatchMode so a missing/encrypted credential
60
+ // errors out rather than blocking on a prompt; the interactive terminal relay
61
+ // (`config.interactive`) omits it so ssh can prompt on the TTY for a passphrase
62
+ // or password.
63
+ if (!config.interactive) {
64
+ options.push("-o", "BatchMode=yes");
65
+ }
66
+ options.push("-o", `ConnectTimeout=${connectSeconds}`);
67
+ if (config.insecure) {
68
+ options.push("-o", "StrictHostKeyChecking=no");
69
+ // Null `known_hosts` sink: `/dev/null` on POSIX, `NUL` on Windows OpenSSH.
70
+ const nullSink = process.platform === "win32" ? "NUL" : "/dev/null";
71
+ options.push("-o", `UserKnownHostsFile=${nullSink}`);
72
+ } else {
73
+ // Trust-on-first-use: accept a new host key, refuse a *changed* one.
74
+ options.push("-o", "StrictHostKeyChecking=accept-new");
75
+ if (config.knownHostsFile) {
76
+ options.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
77
+ }
78
+ }
79
+ return options;
80
+ }
81
+
82
+ /** `[user@]host` for the ssh/sftp command line. */
83
+ export function sshUserHost(config: SshConnectionConfig): string {
84
+ return `${config.username ? `${config.username}@` : ""}${config.host}`;
85
+ }
86
+
87
+ /**
88
+ * Map a host-`ssh` connect/auth failure (non-zero exit) to a CentrsError. Mirrors
89
+ * the connect-level cases in {@link ./sftp.ts}'s `mapError` — both clients face
90
+ * the same OpenSSH stderr. RouterOS *command* failures do not land here: a valid
91
+ * SSH channel exits 0 and surfaces RouterOS errors in stdout (the execute
92
+ * orchestrator's `routerOsFailureFromResult` classifies those).
93
+ */
94
+ export function mapSshConnectError(
95
+ stderr: string,
96
+ exitCode: number,
97
+ ctx: { host: string; port: number; op: string },
98
+ ): CentrsError {
99
+ const { host, port, op } = ctx;
100
+ const trimmed = stderr.trim();
101
+ const lower = trimmed.toLowerCase();
102
+ const context = { via: "ssh", host, port, op };
103
+
104
+ if (lower.includes("host key verification failed")) {
105
+ return new CentrsError({
106
+ code: "transport/host-key-mismatch",
107
+ summary: `The SSH host key for ${host} did not match a known key.`,
108
+ remediation:
109
+ "The device's host key changed (or is impersonated). Remove the stale `known_hosts` entry after verifying the device, or pass `--insecure` to skip the check.",
110
+ context,
111
+ causeData: trimmed,
112
+ });
113
+ }
114
+ if (
115
+ lower.includes("permission denied") ||
116
+ lower.includes("authentication failed") ||
117
+ lower.includes("no such identity") ||
118
+ lower.includes("too many authentication failures")
119
+ ) {
120
+ return new CentrsError({
121
+ code: "auth/failed",
122
+ summary: `SSH authentication to ${host} failed.`,
123
+ remediation:
124
+ "Provide a key the device trusts via `--ssh-key` / the ssh-agent, and confirm the RouterOS user has `ssh` policy. RouterOS refuses password login once an SSH key is set for the user.",
125
+ context,
126
+ causeData: trimmed,
127
+ });
128
+ }
129
+ if (lower.includes("connection refused")) {
130
+ return new CentrsError({
131
+ code: "transport/connection-refused",
132
+ summary: `SSH connection to ${host}:${port} was refused.`,
133
+ remediation:
134
+ "Enable the RouterOS SSH service (`/ip service enable ssh`) and confirm the port / firewall.",
135
+ context,
136
+ causeData: trimmed,
137
+ });
138
+ }
139
+ if (
140
+ lower.includes("timed out") ||
141
+ lower.includes("operation timed out") ||
142
+ lower.includes("connection timed out")
143
+ ) {
144
+ return new CentrsError({
145
+ code: "transport/timeout",
146
+ summary: `SSH connection to ${host}:${port} timed out.`,
147
+ remediation:
148
+ "Raise `--timeout`, or confirm the host and port are reachable.",
149
+ context,
150
+ causeData: trimmed,
151
+ });
152
+ }
153
+ if (
154
+ lower.includes("could not resolve") ||
155
+ lower.includes("name or service not known") ||
156
+ lower.includes("nodename nor servname")
157
+ ) {
158
+ return new CentrsError({
159
+ code: "transport/dns",
160
+ summary: `Could not resolve ${host} for SSH.`,
161
+ remediation:
162
+ "Check the host spelling / DNS, or pass a literal address with `--host`.",
163
+ context,
164
+ causeData: trimmed,
165
+ });
166
+ }
167
+ return new CentrsError({
168
+ code: "transport/network",
169
+ summary: `SSH ${op} failed against ${host}:${port} (exit ${exitCode}).`,
170
+ remediation:
171
+ "Re-run with `--format json` for the structured cause, and confirm SSH access (`/ip service`) to the device.",
172
+ context,
173
+ causeData: trimmed || `exit ${exitCode}`,
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Normalize RouterOS console output captured over `ssh host "cmd"`: CRLF→LF, trim
179
+ * each line's trailing column padding, and drop leading/trailing blank lines.
180
+ * Leading indentation (RouterOS's `print` alignment) is preserved — it is content.
181
+ */
182
+ export function cleanConsoleOutput(stdout: string): string {
183
+ const lines = stdout
184
+ .split(/\r\n|\r|\n/)
185
+ .map((line) => line.replace(/\s+$/, ""));
186
+ while (lines.length > 0 && lines[0] === "") {
187
+ lines.shift();
188
+ }
189
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
190
+ lines.pop();
191
+ }
192
+ return lines.join("\n");
193
+ }
194
+
195
+ export interface SshExecResult {
196
+ exitCode: number;
197
+ stdout: string;
198
+ stderr: string;
199
+ }
200
+
201
+ /** Injectable runner (the default spawns the host `ssh`); tests pin behavior. */
202
+ export type SshExecRunner = (
203
+ argv: readonly string[],
204
+ timeoutMs: number,
205
+ ) => Promise<SshExecResult>;
206
+
207
+ export interface SshExecClientConfig extends SshConnectionConfig {
208
+ /** Override the `ssh` binary path (tests). */
209
+ binary?: string;
210
+ /** Injected runner (tests); defaults to {@link spawnSshExec}. */
211
+ runner?: SshExecRunner;
212
+ }
213
+
214
+ /**
215
+ * Run single-line RouterOS commands over `ssh user@host "<command>"`. One `ssh`
216
+ * invocation per command (a fresh login each time, like `sftp`'s batch model);
217
+ * RouterOS returns clean console output, so the only post-processing is
218
+ * {@link cleanConsoleOutput}.
219
+ */
220
+ export class SshExecClient {
221
+ private readonly run: SshExecRunner;
222
+
223
+ constructor(private readonly config: SshExecClientConfig) {
224
+ this.run = config.runner ?? spawnSshExec;
225
+ }
226
+
227
+ /** Build the `ssh` argv for one command (one place so tests can assert it). */
228
+ argv(command: string): string[] {
229
+ return [
230
+ this.config.binary ?? "ssh",
231
+ "-p",
232
+ String(this.config.port),
233
+ ...sshCommonOptions(this.config),
234
+ sshUserHost(this.config),
235
+ command,
236
+ ];
237
+ }
238
+
239
+ /** Run one command; returns cleaned stdout. Throws on connect/auth failure. */
240
+ async exec(command: string): Promise<string> {
241
+ const result = await this.run(this.argv(command), this.config.timeoutMs);
242
+ if (result.exitCode !== 0) {
243
+ throw mapSshConnectError(result.stderr, result.exitCode, {
244
+ host: this.config.host,
245
+ port: this.config.port,
246
+ op: "execute",
247
+ });
248
+ }
249
+ return cleanConsoleOutput(result.stdout);
250
+ }
251
+ }
252
+
253
+ function startSsh(argv: readonly string[]) {
254
+ try {
255
+ return Bun.spawn(argv as string[], {
256
+ stdin: "ignore",
257
+ stdout: "pipe",
258
+ stderr: "pipe",
259
+ });
260
+ } catch (cause) {
261
+ throw new CentrsError({
262
+ code: "transport/local-tool-missing",
263
+ summary: `Cannot launch the host \`${argv[0]}\` client for SSH execute.`,
264
+ remediation:
265
+ "Install an OpenSSH client so `ssh` is on PATH (macOS ships it; Debian/Ubuntu: `openssh-client`), or use `--via native-api` / `--via rest-api`.",
266
+ context: { binary: argv[0] },
267
+ cause,
268
+ });
269
+ }
270
+ }
271
+
272
+ /** Default runner: spawn the host `ssh` (no shell — argv is passed directly). */
273
+ async function spawnSshExec(
274
+ argv: readonly string[],
275
+ timeoutMs: number,
276
+ ): Promise<SshExecResult> {
277
+ const proc = startSsh(argv);
278
+ let timedOut = false;
279
+ const timer = setTimeout(() => {
280
+ timedOut = true;
281
+ proc.kill();
282
+ }, timeoutMs);
283
+ try {
284
+ const [stdout, stderr] = await Promise.all([
285
+ new Response(proc.stdout).text(),
286
+ new Response(proc.stderr).text(),
287
+ ]);
288
+ const exitCode = await proc.exited;
289
+ return {
290
+ exitCode,
291
+ stdout,
292
+ stderr: timedOut ? `${stderr}\nConnection timed out`.trim() : stderr,
293
+ };
294
+ } finally {
295
+ clearTimeout(timer);
296
+ }
297
+ }