@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,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parser for the CDB comment "kv-soup" of per-device option overrides.
|
|
3
|
+
*
|
|
4
|
+
* The CDB `comment` field is free text. centrs additionally parses tokens
|
|
5
|
+
* shaped like `key=value` out of it and treats the allowlisted ones as
|
|
6
|
+
* per-device defaults. See `commands/devices/README.md` (Comment kv-soup) and
|
|
7
|
+
* `docs/CONSTITUTION.md` (Settings precedence) for the contract this module
|
|
8
|
+
* implements.
|
|
9
|
+
*
|
|
10
|
+
* Rules implemented here:
|
|
11
|
+
* - Tokens are shell-word tokenized: whitespace separates tokens unless inside
|
|
12
|
+
* double quotes. Inside double quotes, `\"` and `\\` are the only escapes;
|
|
13
|
+
* any other backslash is literal. `=` inside a quoted span is literal.
|
|
14
|
+
* - A token is a kv token only when it *starts* with a bare-word key
|
|
15
|
+
* (`[A-Za-z][A-Za-z0-9_-]*`) drawn entirely from unquoted characters,
|
|
16
|
+
* immediately followed by an unquoted `=`. The value is the remainder of the
|
|
17
|
+
* token (which may have been quoted).
|
|
18
|
+
* - Free-form text outside well-formed `key=value` tokens is inert: no value,
|
|
19
|
+
* no warning.
|
|
20
|
+
* - Allowlisted keys populate `values`. First-class CDB keys are rejected with
|
|
21
|
+
* a `cdb/reserved-option` warning (they have dedicated CDB tags and must not
|
|
22
|
+
* live in the comment). Any other key emits `cdb/unknown-option`.
|
|
23
|
+
*
|
|
24
|
+
* This module is intentionally pure: it returns raw string values. Type
|
|
25
|
+
* coercion (`validate` -> boolean, `timeout`/`port` -> integer) and settings
|
|
26
|
+
* precedence belong to the resolver that consumes this output.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Keys the comment kv-soup may legitimately carry. */
|
|
30
|
+
export const commentKvAllowlist = [
|
|
31
|
+
"via",
|
|
32
|
+
"validate",
|
|
33
|
+
"timeout",
|
|
34
|
+
"port",
|
|
35
|
+
"source",
|
|
36
|
+
"mcp",
|
|
37
|
+
"ssh-key",
|
|
38
|
+
"insecure",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type CommentKvKey = (typeof commentKvAllowlist)[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Comment keys that resolve `<router>` to a record, distinct from the setting
|
|
45
|
+
* *override* keys above. The non-`target` identifiers ride here so one record is
|
|
46
|
+
* resolvable by identity / MAC / IP regardless of which is stored as `target`.
|
|
47
|
+
* See `commands/devices/README.md` (Identity model) and `docs/CONSTITUTION.md`
|
|
48
|
+
* (Identity and CDB). Values are returned raw; normalization + matching belong
|
|
49
|
+
* to the resolver that consumes them.
|
|
50
|
+
*/
|
|
51
|
+
export const commentKvLookupKeys = ["identity", "mac", "ip"] as const;
|
|
52
|
+
|
|
53
|
+
export type CommentKvLookupKey = (typeof commentKvLookupKeys)[number];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* First-class CDB fields. These have dedicated CDB tags and must never be
|
|
57
|
+
* expressed through the comment kv-soup; `devices set` refuses to write them
|
|
58
|
+
* and the parser refuses to read them.
|
|
59
|
+
*/
|
|
60
|
+
export const commentKvReservedKeys = [
|
|
61
|
+
"user",
|
|
62
|
+
"password",
|
|
63
|
+
"group",
|
|
64
|
+
"profile",
|
|
65
|
+
"session",
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
export interface CommentKvWarning {
|
|
69
|
+
code: "cdb/unknown-option" | "cdb/reserved-option";
|
|
70
|
+
message: string;
|
|
71
|
+
context: { key: string };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CommentKvResult {
|
|
75
|
+
/** Allowlisted overrides, last-wins on duplicate keys, raw string values. */
|
|
76
|
+
values: Partial<Record<CommentKvKey, string>>;
|
|
77
|
+
/** Lookup keys (identity/mac/ip), last-wins, raw string values. */
|
|
78
|
+
lookups: Partial<Record<CommentKvLookupKey, string>>;
|
|
79
|
+
warnings: CommentKvWarning[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ShellWord {
|
|
83
|
+
/** Final, unquoted text of the word. */
|
|
84
|
+
text: string;
|
|
85
|
+
/** Per-character flag: true when the character came from inside quotes. */
|
|
86
|
+
quoted: boolean[];
|
|
87
|
+
/** Source start offset (inclusive) of the word in the original comment. */
|
|
88
|
+
start: number;
|
|
89
|
+
/** Source end offset (exclusive) of the word in the original comment. */
|
|
90
|
+
end: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const BARE_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
94
|
+
|
|
95
|
+
const allowSet = new Set<string>(commentKvAllowlist);
|
|
96
|
+
const reservedSet = new Set<string>(commentKvReservedKeys);
|
|
97
|
+
const lookupSet = new Set<string>(commentKvLookupKeys);
|
|
98
|
+
|
|
99
|
+
function isAllowlistKey(key: string): key is CommentKvKey {
|
|
100
|
+
return allowSet.has(key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isLookupKey(key: string): key is CommentKvLookupKey {
|
|
104
|
+
return lookupSet.has(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Shell-word tokenize a comment, tracking which characters originated inside a
|
|
109
|
+
* quoted span so the kv splitter can require a bare, unquoted key.
|
|
110
|
+
*/
|
|
111
|
+
function tokenize(comment: string): ShellWord[] {
|
|
112
|
+
const words: ShellWord[] = [];
|
|
113
|
+
let text = "";
|
|
114
|
+
let quoted: boolean[] = [];
|
|
115
|
+
let inWord = false;
|
|
116
|
+
let inQuotes = false;
|
|
117
|
+
let wordStart = -1;
|
|
118
|
+
|
|
119
|
+
const flush = (end: number): void => {
|
|
120
|
+
if (inWord) {
|
|
121
|
+
words.push({ text, quoted, start: wordStart, end });
|
|
122
|
+
text = "";
|
|
123
|
+
quoted = [];
|
|
124
|
+
inWord = false;
|
|
125
|
+
wordStart = -1;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const beginWord = (index: number): void => {
|
|
130
|
+
if (!inWord) {
|
|
131
|
+
inWord = true;
|
|
132
|
+
wordStart = index;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (let index = 0; index < comment.length; index += 1) {
|
|
137
|
+
const char = comment[index] as string;
|
|
138
|
+
|
|
139
|
+
if (inQuotes) {
|
|
140
|
+
if (char === "\\") {
|
|
141
|
+
const next = comment[index + 1];
|
|
142
|
+
if (next === '"' || next === "\\") {
|
|
143
|
+
text += next;
|
|
144
|
+
quoted.push(true);
|
|
145
|
+
index += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
text += char;
|
|
149
|
+
quoted.push(true);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (char === '"') {
|
|
153
|
+
inQuotes = false;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
text += char;
|
|
157
|
+
quoted.push(true);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (char === '"') {
|
|
162
|
+
beginWord(index);
|
|
163
|
+
inQuotes = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (char === " " || char === "\t" || char === "\n" || char === "\r") {
|
|
168
|
+
flush(index);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
beginWord(index);
|
|
173
|
+
text += char;
|
|
174
|
+
quoted.push(false);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// An unterminated quote consumes the rest of the input as quoted text; the
|
|
178
|
+
// word is still flushed so its value survives (best-effort, not an error).
|
|
179
|
+
flush(comment.length);
|
|
180
|
+
return words;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Split a shell word into a bare, unquoted key and its raw value. Returns
|
|
185
|
+
* `undefined` when the word is not a well-formed `key=value` token (inert).
|
|
186
|
+
*/
|
|
187
|
+
function splitKv(word: ShellWord): { key: string; value: string } | undefined {
|
|
188
|
+
const eq = word.text.indexOf("=");
|
|
189
|
+
if (eq <= 0) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
// The `=` and the entire key must be unquoted.
|
|
193
|
+
if (word.quoted[eq]) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
for (let index = 0; index < eq; index += 1) {
|
|
197
|
+
if (word.quoted[index]) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const key = word.text.slice(0, eq);
|
|
202
|
+
if (!BARE_KEY.test(key)) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
return { key, value: word.text.slice(eq + 1) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function parseCommentKv(comment: string): CommentKvResult {
|
|
209
|
+
const values: Partial<Record<CommentKvKey, string>> = {};
|
|
210
|
+
const lookups: Partial<Record<CommentKvLookupKey, string>> = {};
|
|
211
|
+
const warnings: CommentKvWarning[] = [];
|
|
212
|
+
|
|
213
|
+
for (const word of tokenize(comment)) {
|
|
214
|
+
const kv = splitKv(word);
|
|
215
|
+
if (!kv) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (isLookupKey(kv.key)) {
|
|
219
|
+
lookups[kv.key] = kv.value;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (isAllowlistKey(kv.key)) {
|
|
223
|
+
values[kv.key] = kv.value;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (reservedSet.has(kv.key)) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
code: "cdb/reserved-option",
|
|
229
|
+
message: `Comment option "${kv.key}" is a first-class CDB field and is ignored; set it through the CDB record instead.`,
|
|
230
|
+
context: { key: kv.key },
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
warnings.push({
|
|
235
|
+
code: "cdb/unknown-option",
|
|
236
|
+
message: `Unknown comment option "${kv.key}"; it has no effect.`,
|
|
237
|
+
context: { key: kv.key },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { values, lookups, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A single comment kv-soup mutation. `value: null` removes every token whose
|
|
246
|
+
* bare key matches; any other value upserts `key=value` (the value is quoted
|
|
247
|
+
* when needed so it survives re-tokenization as one token).
|
|
248
|
+
*/
|
|
249
|
+
export interface CommentKvUpdate {
|
|
250
|
+
key: string;
|
|
251
|
+
value: string | null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Quote a value so it tokenizes back to exactly `value`. Bare values with no
|
|
256
|
+
* whitespace, quotes, or backslashes are emitted unquoted; everything else is
|
|
257
|
+
* double-quoted with `\"` / `\\` escapes (the only escapes the parser honors).
|
|
258
|
+
*/
|
|
259
|
+
function quoteCommentValue(value: string): string {
|
|
260
|
+
if (value.length > 0 && !/[\s"\\]/.test(value)) {
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
264
|
+
return `"${escaped}"`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Render a kv token whose value round-trips through {@link parseCommentKv}. */
|
|
268
|
+
export function renderCommentKvToken(key: string, value: string): string {
|
|
269
|
+
return `${key}=${quoteCommentValue(value)}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface SpanEdit {
|
|
273
|
+
start: number;
|
|
274
|
+
end: number;
|
|
275
|
+
replacement: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Apply one upsert/remove to a comment string, preserving free-form text. */
|
|
279
|
+
function applyOne(comment: string, update: CommentKvUpdate): string {
|
|
280
|
+
const matches = tokenize(comment).filter((word) => {
|
|
281
|
+
const kv = splitKv(word);
|
|
282
|
+
return kv !== undefined && kv.key === update.key;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const edits: SpanEdit[] = [];
|
|
286
|
+
if (update.value === null) {
|
|
287
|
+
for (const word of matches) {
|
|
288
|
+
edits.push(removalEdit(comment, word.start, word.end));
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
const token = renderCommentKvToken(update.key, update.value);
|
|
292
|
+
const [first, ...rest] = matches;
|
|
293
|
+
if (first) {
|
|
294
|
+
edits.push({ start: first.start, end: first.end, replacement: token });
|
|
295
|
+
for (const word of rest) {
|
|
296
|
+
edits.push(removalEdit(comment, word.start, word.end));
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
return appendToken(comment, token);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return applyEdits(comment, edits);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Expand a token's span to swallow one adjacent whitespace run so removal does
|
|
308
|
+
* not leave a doubled space or a leading/trailing gap.
|
|
309
|
+
*/
|
|
310
|
+
function removalEdit(comment: string, start: number, end: number): SpanEdit {
|
|
311
|
+
let from = start;
|
|
312
|
+
let to = end;
|
|
313
|
+
if (from > 0 && isSpace(comment[from - 1])) {
|
|
314
|
+
from -= 1;
|
|
315
|
+
} else {
|
|
316
|
+
while (to < comment.length && isSpace(comment[to])) {
|
|
317
|
+
to += 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { start: from, end: to, replacement: "" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function appendToken(comment: string, token: string): string {
|
|
324
|
+
if (comment.length === 0) {
|
|
325
|
+
return token;
|
|
326
|
+
}
|
|
327
|
+
if (isSpace(comment[comment.length - 1])) {
|
|
328
|
+
return `${comment}${token}`;
|
|
329
|
+
}
|
|
330
|
+
return `${comment} ${token}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function applyEdits(comment: string, edits: readonly SpanEdit[]): string {
|
|
334
|
+
let result = comment;
|
|
335
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
336
|
+
result =
|
|
337
|
+
result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isSpace(char: string | undefined): boolean {
|
|
343
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Apply comment kv-soup updates in order, returning the new comment string.
|
|
348
|
+
* Free-form (non-kv) text and untouched kv tokens are preserved verbatim; only
|
|
349
|
+
* the targeted keys are upserted or removed.
|
|
350
|
+
*/
|
|
351
|
+
export function applyCommentKv(
|
|
352
|
+
comment: string,
|
|
353
|
+
updates: readonly CommentKvUpdate[],
|
|
354
|
+
): string {
|
|
355
|
+
let result = comment;
|
|
356
|
+
for (const update of updates) {
|
|
357
|
+
result = applyOne(result, update);
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, transport-agnostic resolver.
|
|
3
|
+
*
|
|
4
|
+
* One resolver feeds every command (`retrieve` today; `execute` / `devices`
|
|
5
|
+
* next) so target identity, credentials, comment-kv overrides, and settings
|
|
6
|
+
* precedence behave identically everywhere. See `docs/CONSTITUTION.md`
|
|
7
|
+
* ("Settings precedence", "Identity and CDB") for the contract.
|
|
8
|
+
*
|
|
9
|
+
* Layers:
|
|
10
|
+
* - `./settings.ts` — precedence primitives (default < config < comment-kv <
|
|
11
|
+
* env < cli) and source coercion to the core envelope union.
|
|
12
|
+
* - `./cdb.ts` — CDB identity + comment-kv parse/coerce/provenance.
|
|
13
|
+
* - `./target.ts` — host/port/baseUrl identity + credentials + per-field
|
|
14
|
+
* provenance map.
|
|
15
|
+
* - `./comment-kv.ts` — the raw kv-soup parser (pure).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
type CdbGroupExpansion,
|
|
20
|
+
type CdbGroupResolveInput,
|
|
21
|
+
type CdbGroupTarget,
|
|
22
|
+
type CdbResolution,
|
|
23
|
+
type CdbResolveInput,
|
|
24
|
+
type CommentKvOverrides,
|
|
25
|
+
coerceCommentKv,
|
|
26
|
+
expandCdbGroup,
|
|
27
|
+
type ResolverWarning,
|
|
28
|
+
resolveCdb,
|
|
29
|
+
} from "./cdb.ts";
|
|
30
|
+
export {
|
|
31
|
+
applyCommentKv,
|
|
32
|
+
type CommentKvKey,
|
|
33
|
+
type CommentKvResult,
|
|
34
|
+
type CommentKvUpdate,
|
|
35
|
+
type CommentKvWarning,
|
|
36
|
+
commentKvAllowlist,
|
|
37
|
+
commentKvReservedKeys,
|
|
38
|
+
parseCommentKv,
|
|
39
|
+
renderCommentKvToken,
|
|
40
|
+
} from "./comment-kv.ts";
|
|
41
|
+
export {
|
|
42
|
+
type ArpEntry,
|
|
43
|
+
effectiveHostCandidate,
|
|
44
|
+
isIpTransport,
|
|
45
|
+
isMacAddress,
|
|
46
|
+
normalizeMac,
|
|
47
|
+
parseArpTable,
|
|
48
|
+
parseResolvePolicy,
|
|
49
|
+
type ResolvePolicy,
|
|
50
|
+
resolveMacTarget,
|
|
51
|
+
resolveMacViaArp,
|
|
52
|
+
unresolvedMacError,
|
|
53
|
+
} from "./mac.ts";
|
|
54
|
+
export {
|
|
55
|
+
type CommentKvLayer,
|
|
56
|
+
parseBoolean,
|
|
57
|
+
parseDuration,
|
|
58
|
+
type ResolvedSetting,
|
|
59
|
+
type ResolverSettingSource,
|
|
60
|
+
type ResolverSettingSourceKind,
|
|
61
|
+
resolveBooleanSetting,
|
|
62
|
+
resolveOptionalIntegerSetting,
|
|
63
|
+
resolveStringSetting,
|
|
64
|
+
toCoreSource,
|
|
65
|
+
} from "./settings.ts";
|
|
66
|
+
export {
|
|
67
|
+
defaultPortForScheme,
|
|
68
|
+
parseHostCandidate,
|
|
69
|
+
type ResolvedAuth,
|
|
70
|
+
type ResolvedTarget,
|
|
71
|
+
resolveAuth,
|
|
72
|
+
resolveTarget,
|
|
73
|
+
type TargetResolveInput,
|
|
74
|
+
} from "./target.ts";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAC-address identity helpers and host-ARP resolution.
|
|
3
|
+
*
|
|
4
|
+
* A `<router>` argument may be a MAC address. The constitution resolves a MAC
|
|
5
|
+
* through the CDB first; only when no CDB record matches may the caller opt into
|
|
6
|
+
* local ARP resolution (`--resolve arp` / `CENTRS_RESOLVE=arp`) to turn the MAC
|
|
7
|
+
* into an IP-level target. This module owns MAC recognition/normalization and
|
|
8
|
+
* the platform ARP-table lookup; the policy (when ARP is allowed) lives in the
|
|
9
|
+
* command resolution seam.
|
|
10
|
+
*
|
|
11
|
+
* ARP is a host-local cache: the neighbor must have been talked to recently for
|
|
12
|
+
* its MAC→IP mapping to exist. Resolution is therefore best-effort and the
|
|
13
|
+
* caller surfaces an actionable error when the MAC is absent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CentrsError } from "../errors.ts";
|
|
17
|
+
|
|
18
|
+
/** How an unresolved MAC target may be turned into an IP-level target. */
|
|
19
|
+
export type ResolvePolicy = "none" | "arp";
|
|
20
|
+
|
|
21
|
+
const MAC_RE = /^(?:[0-9a-fA-F]{1,2})(?:[:-][0-9a-fA-F]{1,2}){5}$/;
|
|
22
|
+
|
|
23
|
+
/** True when `value` looks like a 6-octet MAC (colon or dash separated). */
|
|
24
|
+
export function isMacAddress(value: string): boolean {
|
|
25
|
+
return MAC_RE.test(value.trim());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True for transports that connect to an IP/host:port and can therefore use an
|
|
30
|
+
* ARP-resolved MAC. L2/identity transports (mac-telnet, romon) take the MAC
|
|
31
|
+
* directly and must not be ARP-resolved.
|
|
32
|
+
*/
|
|
33
|
+
export function isIpTransport(via: string): boolean {
|
|
34
|
+
return (
|
|
35
|
+
via === "rest-api" ||
|
|
36
|
+
via === "native-api" ||
|
|
37
|
+
via === "ssh" ||
|
|
38
|
+
via === "snmp"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a MAC to lower-case, colon-separated, zero-padded octets
|
|
44
|
+
* (`8a:6:1c:a:2:1f` → `8a:06:1c:0a:02:1f`). Returns `undefined` when the input
|
|
45
|
+
* is not a MAC, so callers can use it as a recognizer + canonicalizer in one.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeMac(value: string): string | undefined {
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
if (!isMacAddress(trimmed)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return trimmed
|
|
53
|
+
.split(/[:-]/)
|
|
54
|
+
.map((octet) => octet.toLowerCase().padStart(2, "0"))
|
|
55
|
+
.join(":");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A single host ARP-cache entry. */
|
|
59
|
+
export interface ArpEntry {
|
|
60
|
+
ip: string;
|
|
61
|
+
mac: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse the output of `arp -an` (BSD/macOS and Linux both emit
|
|
66
|
+
* `? (10.0.0.1) at 8a:6:1c:a:2:1f on en0 ...`). macOS prints octets without
|
|
67
|
+
* leading zeros, so every MAC is normalized for stable comparison.
|
|
68
|
+
*/
|
|
69
|
+
export function parseArpTable(output: string): ArpEntry[] {
|
|
70
|
+
const entries: ArpEntry[] = [];
|
|
71
|
+
const line =
|
|
72
|
+
/\(([0-9.]+)\)\s+at\s+([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})/g;
|
|
73
|
+
for (const match of output.matchAll(line)) {
|
|
74
|
+
const ip = match[1];
|
|
75
|
+
const mac = match[2] ? normalizeMac(match[2]) : undefined;
|
|
76
|
+
if (ip && mac) {
|
|
77
|
+
entries.push({ ip, mac });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Look up `mac` in the host ARP cache and return its IPv4 address, or
|
|
85
|
+
* `undefined` when absent. Spawns `arp -an`; a spawn failure is swallowed
|
|
86
|
+
* (treated as "not found") so the caller emits a single actionable error.
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveMacViaArp(
|
|
89
|
+
mac: string,
|
|
90
|
+
run: (cmd: string[]) => Promise<string> = defaultRunArp,
|
|
91
|
+
): Promise<string | undefined> {
|
|
92
|
+
const target = normalizeMac(mac);
|
|
93
|
+
if (!target) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
let output: string;
|
|
97
|
+
try {
|
|
98
|
+
output = await run(["arp", "-an"]);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const match = parseArpTable(output).find((entry) => entry.mac === target);
|
|
103
|
+
return match?.ip;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function defaultRunArp(cmd: string[]): Promise<string> {
|
|
107
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
108
|
+
const output = await new Response(proc.stdout).text();
|
|
109
|
+
await proc.exited;
|
|
110
|
+
return output;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Coerce a `--resolve` / `CENTRS_RESOLVE` value to a {@link ResolvePolicy}.
|
|
115
|
+
* Defaults to `none`; an unrecognized value is an actionable error.
|
|
116
|
+
*/
|
|
117
|
+
export function parseResolvePolicy(value: string | undefined): ResolvePolicy {
|
|
118
|
+
if (value === undefined || value.trim().length === 0) {
|
|
119
|
+
return "none";
|
|
120
|
+
}
|
|
121
|
+
const normalized = value.trim().toLowerCase();
|
|
122
|
+
if (normalized === "none" || normalized === "arp") {
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
throw new CentrsError({
|
|
126
|
+
code: "validation/option",
|
|
127
|
+
summary: `Unknown --resolve value "${value}".`,
|
|
128
|
+
remediation: "Use `--resolve none` (default) or `--resolve arp`.",
|
|
129
|
+
context: { resolve: value },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compute the effective host candidate using the same precedence as
|
|
135
|
+
* {@link resolveTarget}: explicit host > `CENTRS_HOST` > CDB target > the bare
|
|
136
|
+
* `<router>` positional.
|
|
137
|
+
*/
|
|
138
|
+
export function effectiveHostCandidate(opts: {
|
|
139
|
+
host?: string;
|
|
140
|
+
targetInput?: string;
|
|
141
|
+
cdbTarget?: string;
|
|
142
|
+
env: Record<string, string | undefined>;
|
|
143
|
+
}): string | undefined {
|
|
144
|
+
const candidate =
|
|
145
|
+
opts.host ?? opts.env["CENTRS_HOST"] ?? opts.cdbTarget ?? opts.targetInput;
|
|
146
|
+
return candidate?.trim() ? candidate.trim() : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a MAC target to an IP via host ARP, honoring the opt-in policy.
|
|
151
|
+
*
|
|
152
|
+
* Returns `undefined` when the effective target is not a MAC (the normal IP /
|
|
153
|
+
* hostname path). When it is a MAC: with policy `none` it throws an actionable
|
|
154
|
+
* error (ARP not opted into); with policy `arp` it spawns `arp -an` and returns
|
|
155
|
+
* `{ mac, ip }`, or throws when the MAC is absent from the cache.
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveMacTarget(opts: {
|
|
158
|
+
host?: string;
|
|
159
|
+
targetInput?: string;
|
|
160
|
+
cdbTarget?: string;
|
|
161
|
+
env: Record<string, string | undefined>;
|
|
162
|
+
policy: ResolvePolicy;
|
|
163
|
+
operation: "retrieve" | "execute" | "terminal";
|
|
164
|
+
runArp?: (cmd: string[]) => Promise<string>;
|
|
165
|
+
}): Promise<{ mac: string; ip: string } | undefined> {
|
|
166
|
+
const candidate = effectiveHostCandidate(opts);
|
|
167
|
+
const mac = candidate ? normalizeMac(candidate) : undefined;
|
|
168
|
+
if (!mac) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (opts.policy !== "arp") {
|
|
172
|
+
throw unresolvedMacError(candidate ?? mac, opts.policy, opts.operation);
|
|
173
|
+
}
|
|
174
|
+
const ip = await resolveMacViaArp(mac, opts.runArp ?? defaultRunArp);
|
|
175
|
+
if (!ip) {
|
|
176
|
+
throw unresolvedMacError(candidate ?? mac, "arp", opts.operation);
|
|
177
|
+
}
|
|
178
|
+
return { mac, ip };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the actionable error raised when a MAC target cannot be turned into an
|
|
183
|
+
* IP-level target for a read/REST/native path. `policy` distinguishes "you did
|
|
184
|
+
* not opt into ARP" from "ARP had no entry".
|
|
185
|
+
*/
|
|
186
|
+
export function unresolvedMacError(
|
|
187
|
+
mac: string,
|
|
188
|
+
policy: ResolvePolicy,
|
|
189
|
+
operation: "retrieve" | "execute" | "terminal",
|
|
190
|
+
): CentrsError {
|
|
191
|
+
if (policy !== "arp") {
|
|
192
|
+
return new CentrsError({
|
|
193
|
+
code: "target/mac-unresolved",
|
|
194
|
+
summary: `Target ${mac} is a MAC address with no matching CDB record.`,
|
|
195
|
+
remediation: unresolvedMacRemediation(operation),
|
|
196
|
+
context: { mac, resolve: policy },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return new CentrsError({
|
|
200
|
+
code: "target/mac-not-in-arp",
|
|
201
|
+
summary: `MAC ${mac} is not in the host ARP cache, so it cannot be resolved to an IP.`,
|
|
202
|
+
remediation: macTelnetCapable(operation)
|
|
203
|
+
? "Reach it over Layer 2 with `--via mac-telnet` (no IP needed), or make the device IP-reachable first (e.g. ping it, or run `centrs discover`) so the host learns its MAC→IP mapping, then retry."
|
|
204
|
+
: "Make the device reachable first (e.g. ping its IP, or run `centrs discover`) so the host learns its MAC→IP mapping, then retry — or pass the IP/hostname directly.",
|
|
205
|
+
context: { mac, resolve: policy },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `execute` and `terminal` both have a mac-telnet L2 path that reaches a MAC
|
|
211
|
+
* with no IP at all, so their remediation leads with `--via mac-telnet`.
|
|
212
|
+
* `retrieve` has no L2 transport and leads with an IP/hostname instead.
|
|
213
|
+
*/
|
|
214
|
+
function macTelnetCapable(
|
|
215
|
+
operation: "retrieve" | "execute" | "terminal",
|
|
216
|
+
): boolean {
|
|
217
|
+
return operation !== "retrieve";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The non-ARP "MAC has no CDB record" remediation, tailored per command. */
|
|
221
|
+
function unresolvedMacRemediation(
|
|
222
|
+
operation: "retrieve" | "execute" | "terminal",
|
|
223
|
+
): string {
|
|
224
|
+
return macTelnetCapable(operation)
|
|
225
|
+
? "Reach it over Layer 2 with `--via mac-telnet` (no IP needed), pass an IP/hostname, add a CDB record for this MAC, or opt into host ARP resolution with `--resolve arp`."
|
|
226
|
+
: "Pass an IP/hostname, add a CDB record for this MAC, or opt into host ARP resolution with `--resolve arp`.";
|
|
227
|
+
}
|