@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,580 @@
1
+ /**
2
+ * CDB identity + comment-kv override resolution.
3
+ *
4
+ * This is the transport-agnostic seam every command (`retrieve`, and later
5
+ * `execute` / `devices`) shares for turning a `<router>` argument into a
6
+ * concrete target plus per-device option overrides. It reuses the devices CDB
7
+ * primitives (`loadCdb` / `showDevice`) so decode/decrypt logic lives in one
8
+ * place, then layers the comment kv-soup contract on top.
9
+ *
10
+ * Comment-kv values arrive as RAW strings from {@link parseCommentKv}. This
11
+ * module is where they are COERCED to typed settings and given `comment-kv`
12
+ * provenance:
13
+ *
14
+ * - `port` → integer in 1..65535
15
+ * - `timeout` → milliseconds (duration suffixes allowed)
16
+ * - `validate` → boolean
17
+ * - `via` → a known `RouterOsProtocol`
18
+ * - `source` → raw string (identity hint; not consumed yet — WP-2c seam)
19
+ *
20
+ * A malformed value is non-fatal: it emits a `cdb/invalid-option` warning
21
+ * (carrying the offending key + raw value) and the override is dropped, so the
22
+ * resolver falls through to the next precedence layer. The parser's own
23
+ * `cdb/reserved-option` / `cdb/unknown-option` warnings are surfaced verbatim.
24
+ *
25
+ * ARP / MNDP identity fallback and group fanout are out of scope here
26
+ * (WP-2c / WP-1b); the shape below leaves a clean seam for them.
27
+ */
28
+
29
+ import type { WinBoxCdbEntry } from "../data/winbox-cdb.ts";
30
+ import {
31
+ type DevicesWarning,
32
+ type LoadedCdb,
33
+ loadCdb,
34
+ resolveDevicesSettings,
35
+ showDevice,
36
+ } from "../devices.ts";
37
+ import { CentrsError } from "../errors.ts";
38
+ import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
39
+ import { parseCommentKv } from "./comment-kv.ts";
40
+ import { normalizeMac } from "./mac.ts";
41
+ import {
42
+ parseBoolean,
43
+ parseDuration,
44
+ type ResolvedSetting,
45
+ } from "./settings.ts";
46
+
47
+ const ENV_CDB_FILE = "CENTRS_CDB_FILE";
48
+ const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
49
+
50
+ export interface ResolverWarning {
51
+ code: string;
52
+ message: string;
53
+ context?: Record<string, unknown>;
54
+ }
55
+
56
+ /** Comment-kv overrides, coerced and tagged with `comment-kv` provenance. */
57
+ export interface CommentKvOverrides {
58
+ via?: ResolvedSetting<RouterOsProtocol>;
59
+ port?: ResolvedSetting<number>;
60
+ timeoutMs?: ResolvedSetting<number>;
61
+ validate?: ResolvedSetting<boolean>;
62
+ /** Identity-source hint (e.g. `arp`); parsed but not consumed yet. */
63
+ source?: ResolvedSetting<string>;
64
+ /** Per-device SSH private-key path (`ssh-key=`), raw string (path only). */
65
+ sshKey?: ResolvedSetting<string>;
66
+ /** Per-device TLS / SSH host-key trust opt-out (`insecure=`), boolean. */
67
+ insecure?: ResolvedSetting<boolean>;
68
+ }
69
+
70
+ export interface CdbResolution {
71
+ /** Resolved target string from the CDB record (host / IP / base URL). */
72
+ target: string;
73
+ /**
74
+ * Human-facing device handle: the `identity=` comment lookup key (mirroring
75
+ * RouterOS `/system/identity`), falling back to the matched record's target
76
+ * when the comment carries no `identity=`. Deliberately may repeat across
77
+ * records.
78
+ */
79
+ identity: string;
80
+ /**
81
+ * The record's MAC, for L2 transports (mac-telnet): the `mac=` comment lookup
82
+ * key, or the `target` itself when that is a MAC. Lets `--via mac-telnet`
83
+ * address a device resolved by `identity=`/`ip=` whose `target` is not a MAC.
84
+ */
85
+ mac?: string;
86
+ username: string;
87
+ password: string;
88
+ recordIndex: number;
89
+ overrides: CommentKvOverrides;
90
+ warnings: readonly ResolverWarning[];
91
+ /**
92
+ * Fallback credentials from the reserved `__default__` record, when present.
93
+ * {@link resolveAuth} fills a field from here only when the per-call args, env,
94
+ * and the matched device record all leave it unset (constitution: identity and
95
+ * CDB, `__default__`). Empty strings are treated as unset.
96
+ */
97
+ defaults?: DefaultCreds;
98
+ }
99
+
100
+ /** Credentials carried by the reserved `__default__` CDB record. */
101
+ export interface DefaultCreds {
102
+ username?: string;
103
+ password?: string;
104
+ /** CDB index of the `__default__` record, for provenance. */
105
+ recordIndex: number;
106
+ }
107
+
108
+ /** The reserved CDB target that supplies fallback credentials + metadata. */
109
+ export const DEFAULT_RECORD_TARGET = "__default__";
110
+
111
+ /**
112
+ * Find the reserved `__default__` record and return its credentials, or
113
+ * `undefined` when no such record exists. Empty username/password become
114
+ * `undefined` so {@link resolveAuth} treats them as unset.
115
+ */
116
+ function findDefaultCreds(cdb: LoadedCdb): DefaultCreds | undefined {
117
+ for (let index = 0; index < cdb.entries.length; index += 1) {
118
+ const entry = cdb.entries[index];
119
+ if (entry && entry.target === DEFAULT_RECORD_TARGET) {
120
+ return {
121
+ username: entry.user || undefined,
122
+ password: entry.password || undefined,
123
+ recordIndex: index,
124
+ };
125
+ }
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ export interface CdbResolveInput {
131
+ targetInput?: string;
132
+ cdbFile?: string;
133
+ cdbPassword?: string;
134
+ }
135
+
136
+ function warningFromDevices(warning: DevicesWarning): ResolverWarning {
137
+ return {
138
+ code: warning.code,
139
+ message: warning.message,
140
+ context: warning.context,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Resolve a `<router>` argument against the CDB.
146
+ *
147
+ * Returns `undefined` when no CDB is in play (no explicit CDB inputs and the
148
+ * default file is absent, or the default CDB has no matching record) — the
149
+ * caller then treats `<router>` as a literal host. When an explicit CDB is
150
+ * provided, a missing file/record surfaces as the underlying CDB error so the
151
+ * "name not found without credentials" contract stays intact upstream.
152
+ */
153
+ export async function resolveCdb(
154
+ input: CdbResolveInput,
155
+ env: Record<string, string | undefined>,
156
+ ): Promise<CdbResolution | undefined> {
157
+ if (!input.targetInput) {
158
+ return undefined;
159
+ }
160
+
161
+ const settings = resolveDevicesSettings({
162
+ cdbFile: input.cdbFile,
163
+ cdbPassword: input.cdbPassword,
164
+ env,
165
+ });
166
+ const explicitCdb =
167
+ input.cdbFile !== undefined ||
168
+ input.cdbPassword !== undefined ||
169
+ env[ENV_CDB_FILE] !== undefined ||
170
+ env[ENV_CDB_PASSWORD] !== undefined;
171
+ if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
172
+ return undefined;
173
+ }
174
+
175
+ let cdb: LoadedCdb;
176
+ try {
177
+ cdb = await loadCdb({
178
+ cdbFile: input.cdbFile,
179
+ cdbPassword: input.cdbPassword,
180
+ env,
181
+ });
182
+ } catch (error) {
183
+ if (
184
+ !explicitCdb &&
185
+ error instanceof CentrsError &&
186
+ error.code === "cdb/not-found"
187
+ ) {
188
+ return undefined;
189
+ }
190
+ throw error;
191
+ }
192
+
193
+ const defaults = findDefaultCreds(cdb);
194
+
195
+ try {
196
+ const envelope = showDevice({ cdb, target: input.targetInput });
197
+ const entry = envelope.data.entry;
198
+ const warnings: ResolverWarning[] =
199
+ envelope.warnings.map(warningFromDevices);
200
+ const overrides = coerceCommentKv(
201
+ entry.comment,
202
+ entry.cdbRecordIndex,
203
+ warnings,
204
+ );
205
+ return {
206
+ target: entry.target,
207
+ identity: identityFromComment(entry.comment, entry.target),
208
+ mac: macFromComment(entry.comment, entry.target),
209
+ username: entry.user,
210
+ password: entry.password,
211
+ recordIndex: entry.cdbRecordIndex,
212
+ overrides,
213
+ warnings,
214
+ ...(defaults ? { defaults } : {}),
215
+ };
216
+ } catch (error) {
217
+ if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
218
+ // A `__default__` record supplies fallback credentials even when the
219
+ // target has no record of its own. On the CLI/API this fills creds for
220
+ // an ad-hoc target; on MCP the allowlist gate upstream still rejects
221
+ // unregistered targets, so `__default__` never widens it. Without a
222
+ // `__default__`, preserve the prior contract: undefined for an implicit
223
+ // CDB (treat `<router>` as a literal host), rethrow for an explicit one.
224
+ if (defaults) {
225
+ return {
226
+ target: input.targetInput,
227
+ identity: input.targetInput,
228
+ mac: normalizeMac(input.targetInput),
229
+ username: "",
230
+ password: "",
231
+ recordIndex: defaults.recordIndex,
232
+ overrides: {},
233
+ warnings: [],
234
+ defaults,
235
+ };
236
+ }
237
+ if (!explicitCdb) {
238
+ return undefined;
239
+ }
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /** A single CDB group member resolved for fanout. */
246
+ export interface CdbGroupTarget {
247
+ resolution: CdbResolution;
248
+ recordIndex: number;
249
+ }
250
+
251
+ export interface CdbGroupExpansion {
252
+ /** Group members ordered by CDB record index, de-duped. */
253
+ targets: readonly CdbGroupTarget[];
254
+ /** CDB load warnings plus a `cdb/empty-group` warning when empty. */
255
+ warnings: readonly ResolverWarning[];
256
+ /** True when no entry matched the group (unknown and empty are the same). */
257
+ empty: boolean;
258
+ }
259
+
260
+ export interface CdbGroupResolveInput {
261
+ group: string;
262
+ cdbFile?: string;
263
+ cdbPassword?: string;
264
+ }
265
+
266
+ /**
267
+ * Expand a `--group` selector into N resolved CDB members for fanout. The CDB
268
+ * is loaded + decrypted ONCE; every matching entry is turned into a
269
+ * {@link CdbResolution} (identity + comment-kv overrides) ordered by record
270
+ * index and de-duped by index. CDB groups are derived from entries, so an
271
+ * unknown group is indistinguishable from an empty one — both return
272
+ * `empty: true` with a `cdb/empty-group` warning rather than an error. A decrypt
273
+ * or parse failure throws (the fanout's outer envelope reports it).
274
+ */
275
+ export async function expandCdbGroup(
276
+ input: CdbGroupResolveInput,
277
+ env: Record<string, string | undefined>,
278
+ ): Promise<CdbGroupExpansion> {
279
+ const cdb: LoadedCdb = await loadCdb({
280
+ cdbFile: input.cdbFile,
281
+ cdbPassword: input.cdbPassword,
282
+ env,
283
+ });
284
+
285
+ const warnings: ResolverWarning[] = cdb.warnings.map(warningFromDevices);
286
+ const targets: CdbGroupTarget[] = [];
287
+ const seen = new Set<number>();
288
+
289
+ for (let index = 0; index < cdb.entries.length; index += 1) {
290
+ const entry = cdb.entries[index];
291
+ if (!entry || entry.group !== input.group || seen.has(index)) {
292
+ continue;
293
+ }
294
+ seen.add(index);
295
+ targets.push({
296
+ recordIndex: index,
297
+ resolution: resolutionFromEntry(entry, index),
298
+ });
299
+ }
300
+
301
+ targets.sort((a, b) => a.recordIndex - b.recordIndex);
302
+
303
+ if (targets.length === 0) {
304
+ warnings.push({
305
+ code: "cdb/empty-group",
306
+ message: `No CDB entries matched group "${input.group}".`,
307
+ context: { group: input.group },
308
+ });
309
+ return { targets: [], warnings, empty: true };
310
+ }
311
+
312
+ return { targets, warnings, empty: false };
313
+ }
314
+
315
+ function resolutionFromEntry(
316
+ entry: WinBoxCdbEntry,
317
+ recordIndex: number,
318
+ ): CdbResolution {
319
+ const recordWarnings: ResolverWarning[] = [];
320
+ const overrides = coerceCommentKv(entry.comment, recordIndex, recordWarnings);
321
+ return {
322
+ target: entry.target,
323
+ identity: identityFromComment(entry.comment, entry.target),
324
+ mac: macFromComment(entry.comment, entry.target),
325
+ username: entry.user,
326
+ password: entry.password,
327
+ recordIndex,
328
+ overrides,
329
+ warnings: recordWarnings,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * The human-facing device handle for a record: the `identity=` comment lookup
335
+ * key when present, else the record's `target`. Mirrors RouterOS
336
+ * `/system/identity`; deliberately non-unique (collisions resolve through the
337
+ * ambiguity path). See `commands/devices/README.md` (Identity model).
338
+ */
339
+ export function identityFromComment(comment: string, target: string): string {
340
+ return parseCommentKv(comment).lookups.identity ?? target;
341
+ }
342
+
343
+ /**
344
+ * The record's MAC for L2 addressing: the `mac=` comment lookup key when present,
345
+ * else the `target` when it is itself a MAC. Normalized; `undefined` when neither
346
+ * is a MAC (the record is reachable only over IP transports).
347
+ */
348
+ export function macFromComment(
349
+ comment: string,
350
+ target: string,
351
+ ): string | undefined {
352
+ const lookup = parseCommentKv(comment).lookups.mac;
353
+ return normalizeMac(lookup ?? "") ?? normalizeMac(target);
354
+ }
355
+
356
+ /**
357
+ * Parse + coerce the comment kv-soup of a single CDB record. Mutates
358
+ * `warnings` with parser warnings and any `cdb/invalid-option` coercion
359
+ * failures.
360
+ */
361
+ export function coerceCommentKv(
362
+ comment: string,
363
+ recordIndex: number,
364
+ warnings: ResolverWarning[],
365
+ ): CommentKvOverrides {
366
+ const parsed = parseCommentKv(comment);
367
+ for (const warning of parsed.warnings) {
368
+ warnings.push({
369
+ code: warning.code,
370
+ message: warning.message,
371
+ context: warning.context,
372
+ });
373
+ }
374
+
375
+ const overrides: CommentKvOverrides = {};
376
+ const keyFor = (key: string): string => `record:${recordIndex}:${key}`;
377
+
378
+ const via = coerceVia(parsed.values.via, recordIndex, warnings);
379
+ if (via !== undefined) {
380
+ overrides.via = {
381
+ value: via,
382
+ source: { kind: "comment-kv", key: keyFor("via") },
383
+ };
384
+ }
385
+
386
+ const port = coercePort(parsed.values.port, recordIndex, warnings);
387
+ if (port !== undefined) {
388
+ overrides.port = {
389
+ value: port,
390
+ source: { kind: "comment-kv", key: keyFor("port") },
391
+ };
392
+ }
393
+
394
+ const timeoutMs = coerceTimeout(parsed.values.timeout, recordIndex, warnings);
395
+ if (timeoutMs !== undefined) {
396
+ overrides.timeoutMs = {
397
+ value: timeoutMs,
398
+ source: { kind: "comment-kv", key: keyFor("timeout") },
399
+ };
400
+ }
401
+
402
+ const validate = coerceValidate(
403
+ parsed.values.validate,
404
+ recordIndex,
405
+ warnings,
406
+ );
407
+ if (validate !== undefined) {
408
+ overrides.validate = {
409
+ value: validate,
410
+ source: { kind: "comment-kv", key: keyFor("validate") },
411
+ };
412
+ }
413
+
414
+ if (parsed.values.source !== undefined) {
415
+ overrides.source = {
416
+ value: parsed.values.source,
417
+ source: { kind: "comment-kv", key: keyFor("source") },
418
+ };
419
+ }
420
+
421
+ if (parsed.values["ssh-key"] !== undefined) {
422
+ overrides.sshKey = {
423
+ value: parsed.values["ssh-key"],
424
+ source: { kind: "comment-kv", key: keyFor("ssh-key") },
425
+ };
426
+ }
427
+
428
+ const insecure = coerceInsecure(
429
+ parsed.values.insecure,
430
+ recordIndex,
431
+ warnings,
432
+ );
433
+ if (insecure !== undefined) {
434
+ overrides.insecure = {
435
+ value: insecure,
436
+ source: { kind: "comment-kv", key: keyFor("insecure") },
437
+ };
438
+ }
439
+
440
+ return overrides;
441
+ }
442
+
443
+ function invalidOption(
444
+ key: string,
445
+ value: string,
446
+ recordIndex: number,
447
+ detail: string,
448
+ warnings: ResolverWarning[],
449
+ ): undefined {
450
+ warnings.push({
451
+ code: "cdb/invalid-option",
452
+ message: `Comment option "${key}=${value}" is invalid (${detail}); it is ignored.`,
453
+ context: { key, value, recordIndex },
454
+ });
455
+ return undefined;
456
+ }
457
+
458
+ function coerceVia(
459
+ raw: string | undefined,
460
+ recordIndex: number,
461
+ warnings: ResolverWarning[],
462
+ ): RouterOsProtocol | undefined {
463
+ if (raw === undefined) {
464
+ return undefined;
465
+ }
466
+ if (plannedProtocols.includes(raw as RouterOsProtocol)) {
467
+ return raw as RouterOsProtocol;
468
+ }
469
+ return invalidOption(
470
+ "via",
471
+ raw,
472
+ recordIndex,
473
+ `unknown protocol; expected one of ${plannedProtocols.join(", ")}`,
474
+ warnings,
475
+ );
476
+ }
477
+
478
+ function coercePort(
479
+ raw: string | undefined,
480
+ recordIndex: number,
481
+ warnings: ResolverWarning[],
482
+ ): number | undefined {
483
+ if (raw === undefined) {
484
+ return undefined;
485
+ }
486
+ if (!/^\d+$/.test(raw.trim())) {
487
+ return invalidOption(
488
+ "port",
489
+ raw,
490
+ recordIndex,
491
+ "expected an integer 1-65535",
492
+ warnings,
493
+ );
494
+ }
495
+ const parsed = Number.parseInt(raw.trim(), 10);
496
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
497
+ return invalidOption(
498
+ "port",
499
+ raw,
500
+ recordIndex,
501
+ "out of range 1-65535",
502
+ warnings,
503
+ );
504
+ }
505
+ return parsed;
506
+ }
507
+
508
+ function coerceTimeout(
509
+ raw: string | undefined,
510
+ recordIndex: number,
511
+ warnings: ResolverWarning[],
512
+ ): number | undefined {
513
+ if (raw === undefined) {
514
+ return undefined;
515
+ }
516
+ let parsed: number;
517
+ try {
518
+ parsed = parseDuration(raw);
519
+ } catch {
520
+ return invalidOption(
521
+ "timeout",
522
+ raw,
523
+ recordIndex,
524
+ "expected ms or a suffix like 5s / 500ms",
525
+ warnings,
526
+ );
527
+ }
528
+ if (parsed <= 0) {
529
+ return invalidOption(
530
+ "timeout",
531
+ raw,
532
+ recordIndex,
533
+ "must be greater than zero",
534
+ warnings,
535
+ );
536
+ }
537
+ return parsed;
538
+ }
539
+
540
+ function coerceValidate(
541
+ raw: string | undefined,
542
+ recordIndex: number,
543
+ warnings: ResolverWarning[],
544
+ ): boolean | undefined {
545
+ if (raw === undefined) {
546
+ return undefined;
547
+ }
548
+ try {
549
+ return parseBoolean(raw, "validate");
550
+ } catch {
551
+ return invalidOption(
552
+ "validate",
553
+ raw,
554
+ recordIndex,
555
+ "expected a boolean (true/false, yes/no, on/off, 1/0)",
556
+ warnings,
557
+ );
558
+ }
559
+ }
560
+
561
+ function coerceInsecure(
562
+ raw: string | undefined,
563
+ recordIndex: number,
564
+ warnings: ResolverWarning[],
565
+ ): boolean | undefined {
566
+ if (raw === undefined) {
567
+ return undefined;
568
+ }
569
+ try {
570
+ return parseBoolean(raw, "insecure");
571
+ } catch {
572
+ return invalidOption(
573
+ "insecure",
574
+ raw,
575
+ recordIndex,
576
+ "expected a boolean (true/false, yes/no, on/off, 1/0)",
577
+ warnings,
578
+ );
579
+ }
580
+ }