@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,405 @@
1
+ /**
2
+ * Pure MNDP (MikroTik Neighbor Discovery Protocol) wire codec.
3
+ *
4
+ * MNDP is the UDP/5678 broadcast protocol RouterOS (and WinBox) use to discover
5
+ * neighbors on a layer-2 segment. A response packet is a 4-byte header
6
+ * (`type` + `sequence`) followed by zero or more big-endian TLV records.
7
+ * This module is intentionally pure: {@link parseMndpPacket} turns bytes into a
8
+ * typed {@link MndpNeighbor} and {@link encodeMndpPacket} does the reverse, so
9
+ * the codec is unit-tested without any socket. The socket/IO layer lives in
10
+ * `src/discover.ts`.
11
+ *
12
+ * Wire facts (grounded on live RouterOS 7.23/7.24 packet captures — see the
13
+ * `parses a real captured RouterOS announcement (big-endian TLVs)` fixture
14
+ * test — and aligned with the `routeros-mndp` skill, which documents the same
15
+ * big-endian TLV byte order and the type→field mapping for types 10/11/12. An
16
+ * earlier revision of the skill table stated little-endian TLVs and an
17
+ * off-by-one type mapping; the constitution requires grounding on live-router
18
+ * evidence, and the skill has since been corrected to match this codec):
19
+ * - Header: 4 bytes (a per-device sequence/type word then a `sequence` field).
20
+ * TLV records start at offset 4.
21
+ * - Each TLV: `type` (uint16 **big-endian**), `length` (uint16 **big-endian**),
22
+ * then `length` value bytes whose interpretation depends on the type. (A
23
+ * little-endian reading turns `00 0a 00 04` into type 2560/length 1024 and
24
+ * overruns every buffer — which is why every real packet was being rejected.)
25
+ * - MAC (type 1) and IPv4 (type 17) are big-endian byte sequences; uptime
26
+ * (type 10) is a little-endian uint32; string fields are UTF-8.
27
+ * - Type mapping confirmed against the `bigdude` test router's REST facts:
28
+ * type 10 = uptime, type 11 = software-id, type 12 = board name.
29
+ * - Board may arrive as type 12 or, on some firmware, type 13; type 12 wins.
30
+ * - Unknown TLV types are preserved verbatim in {@link MndpNeighbor.unknownTlvs}
31
+ * and never cause a throw. Only a structurally malformed packet (too-short
32
+ * header, or a TLV whose declared length runs past the buffer) throws.
33
+ */
34
+
35
+ import { CentrsError } from "../errors.ts";
36
+
37
+ /** MNDP transport facts, exported so the IO layer does not re-declare them. */
38
+ export const MNDP_PORT = 5678;
39
+ export const MNDP_BROADCAST_ADDRESS = "255.255.255.255";
40
+ export const MNDP_IPV6_MULTICAST_ADDRESS = "ff02::1";
41
+
42
+ /** TLV type identifiers defined by MNDP. */
43
+ export const mndpTlvType = {
44
+ macAddress: 1,
45
+ identity: 5,
46
+ version: 7,
47
+ platform: 8,
48
+ uptime: 10,
49
+ softwareId: 11,
50
+ board: 12,
51
+ boardAlt: 13,
52
+ unpack: 14,
53
+ ipv6: 15,
54
+ interfaceName: 16,
55
+ ipv4: 17,
56
+ } as const;
57
+
58
+ /** A TLV record whose `type` the codec does not interpret; preserved verbatim. */
59
+ export interface MndpUnknownTlv {
60
+ type: number;
61
+ value: Uint8Array;
62
+ }
63
+
64
+ /**
65
+ * A decoded MNDP announcement. Every advertised field is optional because a
66
+ * device only sends what it has; `sequence` and `unknownTlvs` are always set so
67
+ * consumers never branch on their existence.
68
+ */
69
+ export interface MndpNeighbor {
70
+ /** Per-interface MAC, lower-case colon-separated (e.g. `e4:8d:8c:11:22:33`). */
71
+ macAddress?: string;
72
+ /** Hostname; shared across all interfaces of one device. */
73
+ identity?: string;
74
+ /** RouterOS version string, e.g. `7.18 (stable)`. */
75
+ version?: string;
76
+ /** Usually `MikroTik`. */
77
+ platform?: string;
78
+ /** Board model, e.g. `RB4011iGS+5HacQ2HnD` or `CHR`. */
79
+ board?: string;
80
+ /** Seconds since boot. */
81
+ uptimeSeconds?: number;
82
+ /** License identifier. */
83
+ softwareId?: string;
84
+ /** Firmware compression flag. */
85
+ unpack?: number;
86
+ /** Link-local or global IPv6 of the sending interface. */
87
+ ipv6?: string;
88
+ /** Sending interface name on the router, e.g. `ether1`. */
89
+ interfaceName?: string;
90
+ /** IPv4 of the sending interface, dotted-quad. */
91
+ ipv4?: string;
92
+ /** Per-device monotonically increasing sequence number from the header. */
93
+ sequence: number;
94
+ /** TLV records with an unrecognized type, preserved in arrival order. */
95
+ unknownTlvs: readonly MndpUnknownTlv[];
96
+ }
97
+
98
+ const MNDP_HEADER_LENGTH = 4;
99
+ const TLV_HEADER_LENGTH = 4;
100
+
101
+ function malformed(summary: string, context: Record<string, unknown>): never {
102
+ throw new CentrsError({
103
+ code: "mndp/malformed",
104
+ summary,
105
+ remediation:
106
+ "Drop the packet; a well-formed MNDP response is a 4-byte header followed by complete big-endian TLV records. This is likely a non-MNDP datagram on the port.",
107
+ context,
108
+ });
109
+ }
110
+
111
+ function formatMac(bytes: Uint8Array): string {
112
+ return Array.from(bytes)
113
+ .map((b) => b.toString(16).padStart(2, "0"))
114
+ .join(":");
115
+ }
116
+
117
+ function formatIpv4(bytes: Uint8Array): string {
118
+ return Array.from(bytes).join(".");
119
+ }
120
+
121
+ function formatIpv6(bytes: Uint8Array): string {
122
+ const groups: string[] = [];
123
+ for (let index = 0; index < bytes.length; index += 2) {
124
+ const hi = bytes[index] ?? 0;
125
+ const lo = bytes[index + 1] ?? 0;
126
+ groups.push(((hi << 8) | lo).toString(16));
127
+ }
128
+ return collapseIpv6(groups);
129
+ }
130
+
131
+ /** Collapse the longest run of zero groups into `::` per RFC 5952. */
132
+ function collapseIpv6(groups: readonly string[]): string {
133
+ let bestStart = -1;
134
+ let bestLen = 0;
135
+ let runStart = -1;
136
+ let runLen = 0;
137
+ for (let index = 0; index < groups.length; index += 1) {
138
+ if (groups[index] === "0") {
139
+ if (runStart === -1) {
140
+ runStart = index;
141
+ runLen = 1;
142
+ } else {
143
+ runLen += 1;
144
+ }
145
+ if (runLen > bestLen) {
146
+ bestLen = runLen;
147
+ bestStart = runStart;
148
+ }
149
+ } else {
150
+ runStart = -1;
151
+ runLen = 0;
152
+ }
153
+ }
154
+ if (bestLen < 2) {
155
+ return groups.join(":");
156
+ }
157
+ const head = groups.slice(0, bestStart).join(":");
158
+ const tail = groups.slice(bestStart + bestLen).join(":");
159
+ return `${head}::${tail}`;
160
+ }
161
+
162
+ /**
163
+ * Decode a raw MNDP datagram into a {@link MndpNeighbor}. Throws a
164
+ * `mndp/malformed` {@link CentrsError} on a structurally invalid packet
165
+ * (too-short header or a TLV whose length overruns the buffer). Unknown TLV
166
+ * types are preserved, not rejected.
167
+ */
168
+ export function parseMndpPacket(bytes: Uint8Array): MndpNeighbor {
169
+ if (bytes.length < MNDP_HEADER_LENGTH) {
170
+ malformed(
171
+ `MNDP packet too short: ${bytes.length} byte(s), need at least ${MNDP_HEADER_LENGTH} for the header.`,
172
+ { byteLength: bytes.length },
173
+ );
174
+ }
175
+
176
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
177
+ const sequence = view.getUint16(2, false);
178
+ const neighbor: {
179
+ sequence: number;
180
+ unknownTlvs: MndpUnknownTlv[];
181
+ } & Partial<MndpNeighbor> = {
182
+ sequence,
183
+ unknownTlvs: [],
184
+ };
185
+
186
+ let offset = MNDP_HEADER_LENGTH;
187
+ while (offset + TLV_HEADER_LENGTH <= bytes.length) {
188
+ const type = view.getUint16(offset, false);
189
+ const length = view.getUint16(offset + 2, false);
190
+ const valueStart = offset + TLV_HEADER_LENGTH;
191
+ const valueEnd = valueStart + length;
192
+ if (valueEnd > bytes.length) {
193
+ malformed(
194
+ `MNDP TLV (type ${type}) declares ${length} byte(s) but only ${bytes.length - valueStart} remain.`,
195
+ { tlvType: type, declaredLength: length, byteLength: bytes.length },
196
+ );
197
+ }
198
+ const value = bytes.subarray(valueStart, valueEnd);
199
+ applyTlv(neighbor, type, value, view, valueStart);
200
+ offset = valueEnd;
201
+ }
202
+
203
+ return neighbor as MndpNeighbor;
204
+ }
205
+
206
+ function applyTlv(
207
+ neighbor: { unknownTlvs: MndpUnknownTlv[] } & Partial<MndpNeighbor>,
208
+ type: number,
209
+ value: Uint8Array,
210
+ view: DataView,
211
+ valueStart: number,
212
+ ): void {
213
+ const text = (): string => new TextDecoder().decode(value);
214
+ switch (type) {
215
+ case mndpTlvType.macAddress:
216
+ neighbor.macAddress = formatMac(value);
217
+ break;
218
+ case mndpTlvType.identity:
219
+ neighbor.identity = text();
220
+ break;
221
+ case mndpTlvType.version:
222
+ neighbor.version = text();
223
+ break;
224
+ case mndpTlvType.platform:
225
+ neighbor.platform = text();
226
+ break;
227
+ case mndpTlvType.board:
228
+ neighbor.board = text();
229
+ break;
230
+ case mndpTlvType.boardAlt:
231
+ // Some firmware uses type 13; prefer type 12 when both are present.
232
+ if (neighbor.board === undefined) {
233
+ neighbor.board = text();
234
+ }
235
+ break;
236
+ case mndpTlvType.uptime:
237
+ if (value.length >= 4) {
238
+ neighbor.uptimeSeconds = view.getUint32(valueStart, true);
239
+ }
240
+ break;
241
+ case mndpTlvType.softwareId:
242
+ neighbor.softwareId = text();
243
+ break;
244
+ case mndpTlvType.unpack:
245
+ if (value.length >= 1) {
246
+ neighbor.unpack = value[0];
247
+ }
248
+ break;
249
+ case mndpTlvType.ipv6:
250
+ if (value.length === 16) {
251
+ neighbor.ipv6 = formatIpv6(value);
252
+ } else {
253
+ neighbor.unknownTlvs.push({ type, value: value.slice() });
254
+ }
255
+ break;
256
+ case mndpTlvType.interfaceName:
257
+ neighbor.interfaceName = text();
258
+ break;
259
+ case mndpTlvType.ipv4:
260
+ if (value.length === 4) {
261
+ neighbor.ipv4 = formatIpv4(value);
262
+ } else {
263
+ neighbor.unknownTlvs.push({ type, value: value.slice() });
264
+ }
265
+ break;
266
+ default:
267
+ neighbor.unknownTlvs.push({ type, value: value.slice() });
268
+ break;
269
+ }
270
+ }
271
+
272
+ /** A single TLV record for {@link encodeMndpPacket}. */
273
+ export interface MndpTlvInput {
274
+ type: number;
275
+ value: Uint8Array;
276
+ }
277
+
278
+ function macToBytes(mac: string): Uint8Array {
279
+ const parts = mac.split(":");
280
+ if (parts.length !== 6) {
281
+ throw new CentrsError({
282
+ code: "mndp/encode-failed",
283
+ summary: `Cannot encode MAC "${mac}": expected six colon-separated octets.`,
284
+ remediation: "Pass a MAC like `e4:8d:8c:11:22:33`.",
285
+ context: { mac },
286
+ });
287
+ }
288
+ return Uint8Array.from(parts.map((part) => Number.parseInt(part, 16) & 0xff));
289
+ }
290
+
291
+ function ipv4ToBytes(ipv4: string): Uint8Array {
292
+ const parts = ipv4.split(".");
293
+ if (parts.length !== 4) {
294
+ throw new CentrsError({
295
+ code: "mndp/encode-failed",
296
+ summary: `Cannot encode IPv4 "${ipv4}": expected dotted-quad.`,
297
+ remediation: "Pass an IPv4 like `192.0.2.5`.",
298
+ context: { ipv4 },
299
+ });
300
+ }
301
+ return Uint8Array.from(parts.map((part) => Number.parseInt(part, 10) & 0xff));
302
+ }
303
+
304
+ function utf8(value: string): Uint8Array {
305
+ return new TextEncoder().encode(value);
306
+ }
307
+
308
+ /**
309
+ * Encode a {@link MndpNeighbor} (plus any extra raw TLVs) back into a wire
310
+ * packet. Primarily a round-trip aid for tests and for crafting fixtures; the
311
+ * field order mirrors the TLV type ordering RouterOS emits.
312
+ */
313
+ export function encodeMndpPacket(
314
+ neighbor: Partial<MndpNeighbor> & { sequence?: number },
315
+ extraTlvs: readonly MndpTlvInput[] = [],
316
+ ): Uint8Array {
317
+ const tlvs: MndpTlvInput[] = [];
318
+ if (neighbor.macAddress !== undefined) {
319
+ tlvs.push({
320
+ type: mndpTlvType.macAddress,
321
+ value: macToBytes(neighbor.macAddress),
322
+ });
323
+ }
324
+ if (neighbor.identity !== undefined) {
325
+ tlvs.push({ type: mndpTlvType.identity, value: utf8(neighbor.identity) });
326
+ }
327
+ if (neighbor.version !== undefined) {
328
+ tlvs.push({ type: mndpTlvType.version, value: utf8(neighbor.version) });
329
+ }
330
+ if (neighbor.platform !== undefined) {
331
+ tlvs.push({ type: mndpTlvType.platform, value: utf8(neighbor.platform) });
332
+ }
333
+ if (neighbor.board !== undefined) {
334
+ tlvs.push({ type: mndpTlvType.board, value: utf8(neighbor.board) });
335
+ }
336
+ if (neighbor.uptimeSeconds !== undefined) {
337
+ const value = new Uint8Array(4);
338
+ new DataView(value.buffer).setUint32(0, neighbor.uptimeSeconds, true);
339
+ tlvs.push({ type: mndpTlvType.uptime, value });
340
+ }
341
+ if (neighbor.softwareId !== undefined) {
342
+ tlvs.push({
343
+ type: mndpTlvType.softwareId,
344
+ value: utf8(neighbor.softwareId),
345
+ });
346
+ }
347
+ if (neighbor.unpack !== undefined) {
348
+ tlvs.push({
349
+ type: mndpTlvType.unpack,
350
+ value: Uint8Array.from([neighbor.unpack & 0xff]),
351
+ });
352
+ }
353
+ if (neighbor.interfaceName !== undefined) {
354
+ tlvs.push({
355
+ type: mndpTlvType.interfaceName,
356
+ value: utf8(neighbor.interfaceName),
357
+ });
358
+ }
359
+ if (neighbor.ipv4 !== undefined) {
360
+ tlvs.push({ type: mndpTlvType.ipv4, value: ipv4ToBytes(neighbor.ipv4) });
361
+ }
362
+ for (const tlv of extraTlvs) {
363
+ tlvs.push(tlv);
364
+ }
365
+
366
+ let bodyLength = 0;
367
+ for (const tlv of tlvs) {
368
+ bodyLength += TLV_HEADER_LENGTH + tlv.value.length;
369
+ }
370
+ const out = new Uint8Array(MNDP_HEADER_LENGTH + bodyLength);
371
+ const view = new DataView(out.buffer);
372
+ view.setUint16(0, 0, false);
373
+ view.setUint16(2, neighbor.sequence ?? 0, false);
374
+
375
+ let offset = MNDP_HEADER_LENGTH;
376
+ for (const tlv of tlvs) {
377
+ view.setUint16(offset, tlv.type, false);
378
+ view.setUint16(offset + 2, tlv.value.length, false);
379
+ out.set(tlv.value, offset + TLV_HEADER_LENGTH);
380
+ offset += TLV_HEADER_LENGTH + tlv.value.length;
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * MNDP discovery request that prompts immediate replies. The reply trigger is
387
+ * the leading zeroed 4-byte header (the same minimal form MAC-Telnet sends);
388
+ * RouterOS accepts longer forms too, so this emits the 9-byte all-zero variant.
389
+ * See the `routeros-mndp` skill "Refresh Packet" section.
390
+ */
391
+ export function mndpRefreshPacket(): Uint8Array {
392
+ return new Uint8Array(9);
393
+ }
394
+
395
+ /**
396
+ * Stable cache/identity key for a neighbor: the lower-cased MAC when present,
397
+ * else a synthetic key from identity + interface so MAC-less packets still slot
398
+ * deterministically.
399
+ */
400
+ export function mndpNeighborKey(neighbor: MndpNeighbor): string {
401
+ if (neighbor.macAddress) {
402
+ return neighbor.macAddress.toLowerCase();
403
+ }
404
+ return `noaddr:${neighbor.identity ?? ""}:${neighbor.interfaceName ?? ""}`;
405
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Crash-safe WinBox CDB writer.
3
+ *
4
+ * The CDB at `~/.config/tikoci/winbox.cdb` is the canonical device datastore —
5
+ * there is no SQLite cache, so a partially-written CDB is data loss WinBox
6
+ * would refuse to open. This module is the single seam that turns a record set
7
+ * into bytes on disk, and it does so atomically:
8
+ *
9
+ * 1. Snapshot the current file to a timestamped backup beside it.
10
+ * 2. Prune backups beyond the retention count (newest kept).
11
+ * 3. Write the new bytes to a temp file in the SAME directory, `fsync` it.
12
+ * 4. `rename()` the temp file over the target (atomic on one filesystem).
13
+ *
14
+ * Serialization reuses {@link encodeOpenWinBoxCdb}, so unknown-tcode `rawTail`
15
+ * fields round-trip byte-for-byte. When `encryptWith` is supplied, the open
16
+ * bytes are wrapped via {@link encryptWinBoxCdb} (fresh random salt per write
17
+ * unless the caller pins one for tests) before they hit disk; the backup
18
+ * snapshot copies the existing on-disk bytes verbatim, so an encrypted CDB
19
+ * stays encrypted across backup + write.
20
+ */
21
+
22
+ import {
23
+ copyFile,
24
+ mkdir,
25
+ open,
26
+ readdir,
27
+ rename,
28
+ stat,
29
+ unlink,
30
+ } from "node:fs/promises";
31
+ import { basename, dirname, join } from "node:path";
32
+ import {
33
+ encodeOpenWinBoxCdb,
34
+ encryptWinBoxCdb,
35
+ type WinBoxCdbRecord,
36
+ } from "./winbox-cdb.ts";
37
+
38
+ /** Default number of timestamped backups retained next to the CDB. */
39
+ export const WINBOX_CDB_DEFAULT_BACKUP_RETENTION = 5;
40
+
41
+ const BACKUP_INFIX = ".bak.";
42
+ const TEMP_INFIX = ".tmp.";
43
+
44
+ export interface WriteWinBoxCdbOptions {
45
+ /** Number of `*.bak.*` snapshots to keep; older ones are pruned. */
46
+ backupRetention?: number;
47
+ /** Clock injection for deterministic backup timestamps in tests. */
48
+ now?: Date;
49
+ /** Skip the backup snapshot entirely (used for first-time creation). */
50
+ skipBackup?: boolean;
51
+ /**
52
+ * When set, the serialized open bytes are wrapped via {@link encryptWinBoxCdb}
53
+ * before atomic write. `salt` is optional — omit to let the codec roll a
54
+ * fresh 32-byte salt per write (the normal path); pin it only in tests that
55
+ * need byte-exact output. The caller is responsible for matching the source
56
+ * password (centrs reads it from the loaded CDB's settings).
57
+ */
58
+ encryptWith?: { password: string; salt?: Uint8Array };
59
+ }
60
+
61
+ export interface WriteWinBoxCdbResult {
62
+ /** Absolute/target path that now holds the new bytes. */
63
+ path: string;
64
+ /** Number of record(s) serialized. */
65
+ recordCount: number;
66
+ /** Number of bytes written to the target. */
67
+ byteLength: number;
68
+ /** Backup path created before the overwrite, when one was written. */
69
+ backupPath?: string;
70
+ /** Backup paths pruned by retention, in deletion order. */
71
+ prunedBackups: readonly string[];
72
+ }
73
+
74
+ /** Filesystem-safe, lexicographically-sortable timestamp for backup names. */
75
+ function backupStamp(now: Date): string {
76
+ return now.toISOString().replace(/[:.]/g, "-");
77
+ }
78
+
79
+ function backupPrefix(target: string): string {
80
+ return `${basename(target)}${BACKUP_INFIX}`;
81
+ }
82
+
83
+ /**
84
+ * List existing backups for `target`, newest first (names embed a sortable
85
+ * sortable ISO 8601 timestamp variant, so lexicographic order is chronological).
86
+ */
87
+ export async function listWinBoxCdbBackups(
88
+ target: string,
89
+ ): Promise<readonly string[]> {
90
+ const dir = dirname(target);
91
+ const prefix = backupPrefix(target);
92
+ let names: string[];
93
+ try {
94
+ names = await readdir(dir);
95
+ } catch {
96
+ return [];
97
+ }
98
+ return names
99
+ .filter((name) => name.startsWith(prefix))
100
+ .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
101
+ .map((name) => join(dir, name));
102
+ }
103
+
104
+ async function pathExists(path: string): Promise<boolean> {
105
+ try {
106
+ await stat(path);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Write `records` to `target` atomically, snapshotting and pruning backups.
115
+ *
116
+ * The records are serialized with {@link encodeOpenWinBoxCdb}; pass the full
117
+ * record set (including untouched records) so the whole file is rewritten.
118
+ */
119
+ export async function writeWinBoxCdb(
120
+ target: string,
121
+ records: readonly WinBoxCdbRecord[],
122
+ options: WriteWinBoxCdbOptions = {},
123
+ ): Promise<WriteWinBoxCdbResult> {
124
+ const retention =
125
+ options.backupRetention ?? WINBOX_CDB_DEFAULT_BACKUP_RETENTION;
126
+ const dir = dirname(target);
127
+ await mkdir(dir, { recursive: true });
128
+
129
+ const bytes = options.encryptWith
130
+ ? encryptWinBoxCdb(
131
+ encodeOpenWinBoxCdb(records),
132
+ options.encryptWith.password,
133
+ options.encryptWith.salt ? { salt: options.encryptWith.salt } : {},
134
+ )
135
+ : encodeOpenWinBoxCdb(records);
136
+
137
+ let backupPath: string | undefined;
138
+ const prunedBackups: string[] = [];
139
+ const targetExists = await pathExists(target);
140
+ if (targetExists && !options.skipBackup) {
141
+ const stamp = backupStamp(options.now ?? new Date());
142
+ backupPath = join(dir, `${backupPrefix(target)}${stamp}`);
143
+ await copyFile(target, backupPath);
144
+
145
+ if (retention >= 0) {
146
+ const backups = await listWinBoxCdbBackups(target);
147
+ for (const stale of backups.slice(retention)) {
148
+ try {
149
+ await unlink(stale);
150
+ prunedBackups.push(stale);
151
+ } catch {
152
+ // A backup that vanished concurrently is already "pruned".
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ const tempPath = join(
159
+ dir,
160
+ `${basename(target)}${TEMP_INFIX}${process.pid}.${Date.now().toString(36)}`,
161
+ );
162
+ const handle = await open(tempPath, "w");
163
+ try {
164
+ await handle.write(bytes);
165
+ await handle.sync();
166
+ } finally {
167
+ await handle.close();
168
+ }
169
+
170
+ try {
171
+ await rename(tempPath, target);
172
+ } catch (error) {
173
+ await unlink(tempPath).catch(() => undefined);
174
+ throw error;
175
+ }
176
+
177
+ await syncDirectory(dir);
178
+
179
+ return {
180
+ path: target,
181
+ recordCount: records.length,
182
+ byteLength: bytes.length,
183
+ backupPath,
184
+ prunedBackups,
185
+ };
186
+ }
187
+
188
+ /** Best-effort directory fsync so the rename is durable across a crash. */
189
+ async function syncDirectory(dir: string): Promise<void> {
190
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
191
+ try {
192
+ handle = await open(dir, "r");
193
+ await handle.sync();
194
+ } catch {
195
+ // Directory fsync is unsupported on some platforms; the rename itself is
196
+ // still atomic, so this is best-effort durability only.
197
+ } finally {
198
+ await handle?.close();
199
+ }
200
+ }