@tikoci/centrs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
|
@@ -0,0 +1,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
|
+
}
|