@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,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
|
+
}
|