@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,228 @@
1
+ /**
2
+ * Settings precedence primitives shared by every command resolver.
3
+ *
4
+ * The constitution (`docs/CONSTITUTION.md`, "Settings precedence") locks the
5
+ * order, lowest → highest:
6
+ *
7
+ * default < config < comment-kv < env < cli
8
+ *
9
+ * Each primitive picks the highest-precedence layer that supplied a value and
10
+ * records the winning {@link ResolverSettingSource}. `comment-kv` overrides come
11
+ * from the matched CDB entry's comment (see `./cdb.ts`); they are already
12
+ * coerced to the target type by the time they reach these helpers, so the
13
+ * `normalize` step never runs against them.
14
+ *
15
+ * The resolver tracks two extra source kinds beyond the core union —
16
+ * `explicit` and `target-input` — so callers can tell a `--host` flag apart
17
+ * from a bare `<router>` positional while resolving. {@link toCoreSource}
18
+ * collapses both to the canonical core `cli` kind for the envelope, because a
19
+ * positional argument is, precedence-wise, a CLI input.
20
+ */
21
+
22
+ import type {
23
+ SettingSource as CoreSettingSource,
24
+ SettingSourceKind as CoreSettingSourceKind,
25
+ } from "../core/envelope.ts";
26
+ import { CentrsError } from "../errors.ts";
27
+
28
+ /**
29
+ * Internal source kinds. A superset of the core union: `explicit` (an API/CLI
30
+ * value passed directly) and `target-input` (the bare `<router>` positional)
31
+ * both collapse to core `cli` via {@link toCoreSource}.
32
+ */
33
+ export type ResolverSettingSourceKind =
34
+ | CoreSettingSourceKind
35
+ | "explicit"
36
+ | "target-input";
37
+
38
+ export interface ResolverSettingSource {
39
+ kind: ResolverSettingSourceKind;
40
+ key: string;
41
+ }
42
+
43
+ export interface ResolvedSetting<T> {
44
+ value: T;
45
+ source: ResolverSettingSource;
46
+ }
47
+
48
+ /** A pre-coerced comment-kv override layer (sits below env, above config). */
49
+ export type CommentKvLayer<T> = ResolvedSetting<T> | undefined;
50
+
51
+ export function toCoreSource(source: ResolverSettingSource): CoreSettingSource {
52
+ switch (source.kind) {
53
+ case "explicit":
54
+ case "target-input":
55
+ return { kind: "cli", key: source.key };
56
+ default:
57
+ return { kind: source.kind, key: source.key };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve a string-valued setting across the precedence ladder.
63
+ *
64
+ * Order checked (highest first): explicit/cli → env → comment-kv → default.
65
+ * `normalize` runs only against explicit/env/default raw strings; the
66
+ * comment-kv layer is already coerced to `T`.
67
+ */
68
+ export function resolveStringSetting<T = string>(
69
+ explicit: string | undefined,
70
+ env: Record<string, string | undefined>,
71
+ envName: string,
72
+ defaultValue: string | undefined,
73
+ key: string,
74
+ normalize?: (value: string) => T,
75
+ commentKv?: CommentKvLayer<T>,
76
+ ): ResolvedSetting<T> | undefined {
77
+ if (explicit !== undefined) {
78
+ return {
79
+ value: normalize ? normalize(explicit) : (explicit as T),
80
+ source: {
81
+ kind:
82
+ key === "host" && defaultValue === undefined
83
+ ? "target-input"
84
+ : "explicit",
85
+ key,
86
+ },
87
+ };
88
+ }
89
+
90
+ const envValue = env[envName];
91
+ if (envValue !== undefined) {
92
+ return {
93
+ value: normalize ? normalize(envValue) : (envValue as T),
94
+ source: { kind: "env", key: envName },
95
+ };
96
+ }
97
+
98
+ if (commentKv !== undefined) {
99
+ return commentKv;
100
+ }
101
+
102
+ if (defaultValue !== undefined) {
103
+ return {
104
+ value: normalize ? normalize(defaultValue) : (defaultValue as T),
105
+ source: {
106
+ kind: key === "host" ? "target-input" : "default",
107
+ key,
108
+ },
109
+ };
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
115
+ /**
116
+ * Resolve a boolean-valued setting. Order: explicit → env → comment-kv →
117
+ * default.
118
+ */
119
+ export function resolveBooleanSetting(
120
+ explicit: boolean | undefined,
121
+ env: Record<string, string | undefined>,
122
+ envName: string,
123
+ defaultValue: boolean,
124
+ key: string,
125
+ commentKv?: CommentKvLayer<boolean>,
126
+ ): ResolvedSetting<boolean> {
127
+ if (explicit !== undefined) {
128
+ return { value: explicit, source: { kind: "explicit", key } };
129
+ }
130
+
131
+ const envValue = env[envName];
132
+ if (envValue !== undefined) {
133
+ return {
134
+ value: parseBoolean(envValue, envName),
135
+ source: { kind: "env", key: envName },
136
+ };
137
+ }
138
+
139
+ if (commentKv !== undefined) {
140
+ return commentKv;
141
+ }
142
+
143
+ return { value: defaultValue, source: { kind: "default", key } };
144
+ }
145
+
146
+ /**
147
+ * Resolve an optional positive-integer setting (no built-in default). Order:
148
+ * explicit → env → comment-kv → unset.
149
+ */
150
+ export function resolveOptionalIntegerSetting(
151
+ explicit: number | undefined,
152
+ env: Record<string, string | undefined>,
153
+ envName: string,
154
+ key: string,
155
+ commentKv?: CommentKvLayer<number>,
156
+ ): ResolvedSetting<number> | undefined {
157
+ if (explicit !== undefined) {
158
+ if (!Number.isInteger(explicit) || explicit <= 0) {
159
+ throw new CentrsError({
160
+ code: "settings/invalid-integer",
161
+ summary: `${key} must be a positive integer. Received: ${explicit}`,
162
+ remediation: `Pass a positive integer for ${key}.`,
163
+ });
164
+ }
165
+ return { value: explicit, source: { kind: "explicit", key } };
166
+ }
167
+
168
+ const envValue = env[envName];
169
+ if (envValue !== undefined) {
170
+ const parsed = Number.parseInt(envValue, 10);
171
+ if (!Number.isInteger(parsed) || parsed <= 0) {
172
+ throw new CentrsError({
173
+ code: "settings/invalid-integer",
174
+ summary: `${envName} must be a positive integer. Received: ${envValue}`,
175
+ remediation: `Set ${envName} to a positive integer value.`,
176
+ });
177
+ }
178
+ return { value: parsed, source: { kind: "env", key: envName } };
179
+ }
180
+
181
+ if (commentKv !== undefined) {
182
+ return commentKv;
183
+ }
184
+
185
+ return undefined;
186
+ }
187
+
188
+ export function parseBoolean(value: string, settingName: string): boolean {
189
+ const normalized = value.trim().toLowerCase();
190
+ if (["1", "true", "yes", "on"].includes(normalized)) {
191
+ return true;
192
+ }
193
+ if (["0", "false", "no", "off"].includes(normalized)) {
194
+ return false;
195
+ }
196
+
197
+ throw new CentrsError({
198
+ code: "settings/invalid-boolean",
199
+ summary: `${settingName} must be a boolean-like value. Received: ${value}`,
200
+ remediation: "Use one of: true/false, yes/no, on/off, or 1/0.",
201
+ });
202
+ }
203
+
204
+ export function parseDuration(value: string): number {
205
+ const trimmed = value.trim();
206
+ const match = /^(\d+)(ms|s|m)?$/i.exec(trimmed);
207
+ if (!match) {
208
+ throw new CentrsError({
209
+ code: "settings/invalid-timeout",
210
+ summary: `Unsupported timeout value: ${value}`,
211
+ remediation:
212
+ "Use an integer number of milliseconds or a suffix like `500ms`, `5s`, or `1m`.",
213
+ });
214
+ }
215
+
216
+ const numeric = Number.parseInt(match[1] ?? "0", 10);
217
+ const unit = (match[2] ?? "ms").toLowerCase();
218
+ switch (unit) {
219
+ case "ms":
220
+ return numeric;
221
+ case "s":
222
+ return numeric * 1000;
223
+ case "m":
224
+ return numeric * 60_000;
225
+ default:
226
+ return numeric;
227
+ }
228
+ }
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Target identity + credential resolution.
3
+ *
4
+ * Combines CDB identity (`./cdb.ts`), the settings precedence ladder
5
+ * (`./settings.ts`), and the matched record's comment-kv `port` override into a
6
+ * concrete transport target with per-field provenance. The result feeds
7
+ * `EnvelopeTargetMeta` (`target.source` for the identity as a whole, plus the
8
+ * `sources` map for each resolved field) and `meta.settings`.
9
+ *
10
+ * `via` selects the URL shape: `native-api` resolves to an `api://` /
11
+ * `api-ssl://` base URL (TCP 8728 / TLS 8729), everything else to a REST
12
+ * `http(s)://.../rest` base URL. MAC / ARP / MNDP identity fallback is out of
13
+ * scope (WP-2c); the shape exposes `identity`/`recordIndex` and leaves room for
14
+ * a future `mac` field.
15
+ */
16
+
17
+ import { CentrsError } from "../errors.ts";
18
+ import type { RouterOsProtocol } from "../protocols/index.ts";
19
+ import { MAC_TELNET_PORT } from "../protocols/mac-telnet.ts";
20
+ import {
21
+ NATIVE_API_PORT,
22
+ NATIVE_API_TLS_PORT,
23
+ } from "../protocols/native-api.ts";
24
+ import type { CdbResolution } from "./cdb.ts";
25
+ import { normalizeMac } from "./mac.ts";
26
+ import {
27
+ type ResolvedSetting,
28
+ type ResolverSettingSource,
29
+ resolveOptionalIntegerSetting,
30
+ resolveStringSetting,
31
+ } from "./settings.ts";
32
+
33
+ const ENV_HOST = "CENTRS_HOST";
34
+ const ENV_PORT = "CENTRS_PORT";
35
+
36
+ export interface TargetResolveInput {
37
+ targetInput?: string;
38
+ host?: string;
39
+ port?: number;
40
+ /**
41
+ * Pre-resolved MAC→IP mapping (host ARP lookup). When the resolved host
42
+ * candidate is this MAC, `ip` is used as the transport host with `arp`
43
+ * provenance. The command resolution seam decides whether ARP is allowed.
44
+ */
45
+ macResolution?: { mac: string; ip: string };
46
+ }
47
+
48
+ export interface ResolvedTarget {
49
+ input?: string;
50
+ host: string;
51
+ port: number;
52
+ scheme: "http" | "https";
53
+ /** TLS transport (native-api over api-ssl). REST uses the URL scheme. */
54
+ tls: boolean;
55
+ baseUrl: string;
56
+ /** Target MAC, set for L2 transports (mac-telnet) that address by MAC. */
57
+ mac?: string;
58
+ /** Human-facing device handle (CDB `identity=`, else target), if resolved. */
59
+ identity?: string;
60
+ recordIndex?: number;
61
+ /** Provenance of the identity as a whole. */
62
+ source: ResolverSettingSource;
63
+ /** Provenance of the resolved host. */
64
+ hostSource: ResolverSettingSource;
65
+ /** Provenance of the resolved port (settings ladder). */
66
+ portSource: ResolverSettingSource;
67
+ /** Per-field provenance map for `EnvelopeTargetMeta.sources`. */
68
+ sources: Record<string, ResolverSettingSource>;
69
+ }
70
+
71
+ export interface ResolvedAuth {
72
+ username?: string;
73
+ usernameSource?: ResolverSettingSource;
74
+ password: string;
75
+ passwordProvided: boolean;
76
+ passwordSource?: ResolverSettingSource;
77
+ /** SSH private-key path for sftp/scp/ssh; path only, never key material. */
78
+ sshKey?: string;
79
+ sshKeySource?: ResolverSettingSource;
80
+ }
81
+
82
+ export function resolveTarget(
83
+ input: TargetResolveInput,
84
+ env: Record<string, string | undefined>,
85
+ via: RouterOsProtocol,
86
+ cdb?: CdbResolution,
87
+ ): ResolvedTarget {
88
+ const hostSetting = resolveStringSetting(
89
+ input.host,
90
+ env,
91
+ ENV_HOST,
92
+ cdb?.target ?? input.targetInput,
93
+ "host",
94
+ );
95
+
96
+ if (!hostSetting || hostSetting.value.trim().length === 0) {
97
+ throw new CentrsError({
98
+ code: "target/unresolved",
99
+ summary: "No host could be resolved for `retrieve`.",
100
+ remediation:
101
+ "Pass a target positional like `centrs retrieve 192.0.2.10 /system/resource --via rest-api` or set `--host` / `CENTRS_HOST`.",
102
+ context: { targetInput: input.targetInput },
103
+ });
104
+ }
105
+
106
+ const rawCandidate = hostSetting.value.trim();
107
+ const macCandidate = normalizeMac(rawCandidate);
108
+
109
+ // L2 transports (mac-telnet) address the device by **MAC** directly — no IP
110
+ // resolution. The MAC is the device identity (from the positional / CDB
111
+ // target); the `host`/`port` are the *delivery* endpoint of the UDP/20561
112
+ // datagrams (default L2 broadcast, overridable with `--host`/`--port` — the
113
+ // integration harness points them at its loopback L2 bridge).
114
+ if (via === "mac-telnet") {
115
+ // The device MAC: an explicit MAC positional wins; otherwise the matched
116
+ // CDB record's MAC (the `mac=` lookup key, or a MAC `target`) — so a device
117
+ // resolved by identity=/ip= is still reachable over L2.
118
+ const mac =
119
+ normalizeMac(input.targetInput ?? "") ??
120
+ cdb?.mac ??
121
+ normalizeMac(cdb?.target ?? "");
122
+ if (!mac) {
123
+ throw new CentrsError({
124
+ code: "target/mac-required",
125
+ summary: `mac-telnet needs a MAC address target; got "${input.targetInput ?? cdb?.target ?? rawCandidate}".`,
126
+ remediation:
127
+ "Pass the device MAC (aa:bb:cc:dd:ee:ff), or pin an IP transport with `--via native-api`/`rest-api` (add `--resolve arp` for a MAC).",
128
+ context: {
129
+ target: input.targetInput ?? cdb?.target ?? rawCandidate,
130
+ via,
131
+ },
132
+ });
133
+ }
134
+ const deliveryHostSetting = resolveStringSetting(
135
+ input.host,
136
+ env,
137
+ ENV_HOST,
138
+ undefined,
139
+ "host",
140
+ );
141
+ const macPortSetting = resolveOptionalIntegerSetting(
142
+ input.port,
143
+ env,
144
+ ENV_PORT,
145
+ "port",
146
+ cdb?.overrides.port,
147
+ );
148
+ // The device identity is the MAC (positional or CDB), NOT --host/CENTRS_HOST,
149
+ // which are only the UDP delivery endpoint. Attribute meta.target.source to
150
+ // where the MAC came from so an overridden delivery host does not masquerade
151
+ // as the identity.
152
+ const macSource: ResolverSettingSource = normalizeMac(
153
+ input.targetInput ?? "",
154
+ )
155
+ ? { kind: "target-input", key: input.targetInput as string }
156
+ : cdb
157
+ ? { kind: "cdb", key: `record:${cdb.recordIndex}` }
158
+ : hostSetting.source;
159
+ // Delivery host/port provenance is independent of the identity.
160
+ const deliverySource: ResolverSettingSource =
161
+ deliveryHostSetting?.source ?? {
162
+ kind: "default",
163
+ key: "l2-broadcast",
164
+ };
165
+ const portSource: ResolverSettingSource = macPortSetting?.source ?? {
166
+ kind: "default",
167
+ key: "mac-telnet",
168
+ };
169
+ const deliveryHost = deliveryHostSetting?.value.trim() || "255.255.255.255";
170
+ const port = macPortSetting?.value ?? MAC_TELNET_PORT;
171
+ return {
172
+ input: input.targetInput,
173
+ host: deliveryHost,
174
+ mac,
175
+ port,
176
+ scheme: "http",
177
+ tls: false,
178
+ baseUrl: `mac-telnet://${mac}`,
179
+ identity: cdb?.identity,
180
+ recordIndex: cdb?.recordIndex,
181
+ source: macSource,
182
+ hostSource: deliverySource,
183
+ portSource,
184
+ sources: {
185
+ host: deliverySource,
186
+ port: portSource,
187
+ },
188
+ };
189
+ }
190
+
191
+ let candidate = rawCandidate;
192
+ let arpResolvedMac: string | undefined;
193
+ if (macCandidate) {
194
+ if (
195
+ input.macResolution &&
196
+ normalizeMac(input.macResolution.mac) === macCandidate
197
+ ) {
198
+ candidate = input.macResolution.ip;
199
+ arpResolvedMac = macCandidate;
200
+ } else {
201
+ // A MAC with no CDB record and no opted-in resolution. The command
202
+ // seam normally throws first with operation-specific guidance; this
203
+ // guards direct callers and never lets a MAC reach `new URL`.
204
+ throw new CentrsError({
205
+ code: "target/mac-unresolved",
206
+ summary: `Target ${rawCandidate} is a MAC address that could not be resolved to a host.`,
207
+ remediation:
208
+ "Pass an IP/hostname, add a CDB record for this MAC, or opt into host ARP resolution with `--resolve arp`.",
209
+ context: { mac: macCandidate },
210
+ });
211
+ }
212
+ }
213
+ const parsedUrl = parseHostCandidate(candidate);
214
+ const portSetting = resolveOptionalIntegerSetting(
215
+ input.port,
216
+ env,
217
+ ENV_PORT,
218
+ "port",
219
+ cdb?.overrides.port,
220
+ );
221
+ const scheme = parsedUrl.protocol === "https:" ? "https" : "http";
222
+
223
+ const identitySource: ResolverSettingSource = arpResolvedMac
224
+ ? { kind: "arp", key: arpResolvedMac }
225
+ : cdb && input.host === undefined && env[ENV_HOST] === undefined
226
+ ? { kind: "cdb", key: `record:${cdb.recordIndex}` }
227
+ : hostSetting.source;
228
+
229
+ const isNative = via === "native-api";
230
+ const isSsh = via === "ssh";
231
+ let port: number;
232
+ let tls: boolean;
233
+ let baseUrl: string;
234
+ if (isSsh) {
235
+ // SSH (sftp/scp/exec) addresses host:22 and carries its own crypto, so the
236
+ // URL scheme is irrelevant. An explicit `--port` / URL port wins over 22.
237
+ tls = false;
238
+ port =
239
+ portSetting?.value ??
240
+ (parsedUrl.port.length > 0 ? Number.parseInt(parsedUrl.port, 10) : 22);
241
+ baseUrl = `ssh://${formatHostForUrl(parsedUrl.hostname)}:${port}`;
242
+ } else if (isNative) {
243
+ // Native API ignores the URL scheme for its wire protocol; it defaults to
244
+ // TCP 8728, or TLS (api-ssl) 8729 when the caller passed `https://` or an
245
+ // explicit 8729. An explicit well-known port wins over the scheme so
246
+ // `https://host --port 8728` stays plaintext instead of running a TLS
247
+ // handshake against the plaintext API port.
248
+ const explicitPort = portSetting?.value;
249
+ if (explicitPort === NATIVE_API_PORT) {
250
+ tls = false;
251
+ } else if (explicitPort === NATIVE_API_TLS_PORT) {
252
+ tls = true;
253
+ } else {
254
+ tls = scheme === "https";
255
+ }
256
+ port = explicitPort ?? (tls ? NATIVE_API_TLS_PORT : NATIVE_API_PORT);
257
+ baseUrl = `${tls ? "api-ssl" : "api"}://${formatHostForUrl(parsedUrl.hostname)}:${port}`;
258
+ } else {
259
+ tls = scheme === "https";
260
+ port = portSetting?.value ?? readPort(parsedUrl, scheme);
261
+ baseUrl = `${scheme}://${formatHostForUrl(parsedUrl.hostname)}:${port}/rest`;
262
+ }
263
+
264
+ const portSource = resolvePortSource(
265
+ portSetting,
266
+ identitySource,
267
+ port,
268
+ scheme,
269
+ isNative,
270
+ );
271
+
272
+ const sources: Record<string, ResolverSettingSource> = {
273
+ host: identitySource,
274
+ port: portSource,
275
+ };
276
+
277
+ return {
278
+ input: input.targetInput,
279
+ host: parsedUrl.hostname,
280
+ port,
281
+ scheme,
282
+ tls,
283
+ baseUrl,
284
+ identity: cdb?.identity,
285
+ recordIndex: cdb?.recordIndex,
286
+ source: identitySource,
287
+ hostSource: identitySource,
288
+ portSource,
289
+ sources,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Port provenance. When a port came from the settings ladder
295
+ * (cli/env/comment-kv) its own source wins. Otherwise the port mirrors the
296
+ * identity source, except a bare positional target on its scheme-default REST
297
+ * port is attributed to the built-in default.
298
+ */
299
+ function resolvePortSource(
300
+ portSetting: ResolvedSetting<number> | undefined,
301
+ identitySource: ResolverSettingSource,
302
+ port: number,
303
+ scheme: "http" | "https",
304
+ isNative: boolean,
305
+ ): ResolverSettingSource {
306
+ if (portSetting) {
307
+ return portSetting.source;
308
+ }
309
+ if (
310
+ !isNative &&
311
+ identitySource.kind === "target-input" &&
312
+ port === defaultPortForScheme(scheme)
313
+ ) {
314
+ return { kind: "default", key: `${scheme} default` };
315
+ }
316
+ return identitySource;
317
+ }
318
+
319
+ export function resolveAuth(
320
+ credentials: { username?: string; password?: string; sshKey?: string },
321
+ env: Record<string, string | undefined>,
322
+ cdb?: CdbResolution,
323
+ ): ResolvedAuth {
324
+ const username = resolveStringSetting(
325
+ credentials.username,
326
+ env,
327
+ "CENTRS_USERNAME",
328
+ undefined,
329
+ "username",
330
+ );
331
+ // SSH key path: --ssh-key → CENTRS_SSH_KEY → CDB `ssh-key=` comment-kv → unset.
332
+ // Path only; the SFTP/SSH transport reads the file, centrs never stores material.
333
+ const sshKey = resolveStringSetting(
334
+ credentials.sshKey,
335
+ env,
336
+ "CENTRS_SSH_KEY",
337
+ undefined,
338
+ "ssh-key",
339
+ undefined,
340
+ cdb?.overrides.sshKey,
341
+ );
342
+ const password = resolveStringSetting(
343
+ credentials.password,
344
+ env,
345
+ "CENTRS_PASSWORD",
346
+ undefined,
347
+ "password",
348
+ );
349
+ const def = cdb?.defaults;
350
+ const cdbUsername =
351
+ username === undefined && cdb?.username
352
+ ? {
353
+ value: cdb.username,
354
+ source: {
355
+ kind: "cdb" as const,
356
+ key: `record:${cdb.recordIndex}:user`,
357
+ },
358
+ }
359
+ : undefined;
360
+ const cdbPassword =
361
+ password === undefined && cdb
362
+ ? {
363
+ value: cdb.password,
364
+ source: {
365
+ kind: "cdb" as const,
366
+ key: `record:${cdb.recordIndex}:password`,
367
+ },
368
+ }
369
+ : undefined;
370
+ // __default__ fallback: fill a field only when args/env and the matched
371
+ // device record all left it unset. An empty device value is "unset".
372
+ const defaultUsername =
373
+ username === undefined &&
374
+ cdbUsername === undefined &&
375
+ def !== undefined &&
376
+ def.username !== undefined
377
+ ? {
378
+ value: def.username,
379
+ source: {
380
+ kind: "cdb" as const,
381
+ key: `record:${def.recordIndex}:user`,
382
+ },
383
+ }
384
+ : undefined;
385
+ const defaultPassword =
386
+ password === undefined &&
387
+ def !== undefined &&
388
+ def.password !== undefined &&
389
+ (cdb?.password ?? "") === ""
390
+ ? {
391
+ value: def.password,
392
+ source: {
393
+ kind: "cdb" as const,
394
+ key: `record:${def.recordIndex}:password`,
395
+ },
396
+ }
397
+ : undefined;
398
+ const resolvedUsername = username ?? cdbUsername ?? defaultUsername;
399
+ const resolvedPassword = password ?? defaultPassword ?? cdbPassword;
400
+
401
+ return {
402
+ username: resolvedUsername?.value,
403
+ usernameSource: resolvedUsername?.source,
404
+ password: resolvedPassword?.value ?? "",
405
+ passwordProvided: resolvedPassword !== undefined,
406
+ passwordSource: resolvedPassword?.source,
407
+ sshKey: sshKey?.value,
408
+ sshKeySource: sshKey?.source,
409
+ };
410
+ }
411
+
412
+ export function parseHostCandidate(value: string): URL {
413
+ if (/^https?:\/\//i.test(value)) {
414
+ const url = new URL(value);
415
+ if (url.pathname !== "/" && url.pathname !== "") {
416
+ throw new CentrsError({
417
+ code: "input/invalid-target",
418
+ summary: `Target URL must not include a path. Received: ${value}`,
419
+ remediation:
420
+ "Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
421
+ });
422
+ }
423
+ return url;
424
+ }
425
+
426
+ return new URL(`http://${value}`);
427
+ }
428
+
429
+ function readPort(parsedUrl: URL, scheme: "http" | "https"): number {
430
+ if (parsedUrl.port.length > 0) {
431
+ return Number.parseInt(parsedUrl.port, 10);
432
+ }
433
+ return defaultPortForScheme(scheme);
434
+ }
435
+
436
+ export function defaultPortForScheme(scheme: "http" | "https"): number {
437
+ return scheme === "https" ? 443 : 80;
438
+ }
439
+
440
+ function formatHostForUrl(host: string): string {
441
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
442
+ }