@tikoci/centrs 0.1.3 → 0.1.4
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 +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quickchr named-live-provider (#134): resolve `--quickchr <name>` into the
|
|
3
|
+
* neutral per-service connection facts (`./service-endpoint.ts`).
|
|
4
|
+
*
|
|
5
|
+
* quickchr is a *named-live-provider* (see `docs/CONSTITUTION.md` → Resolution
|
|
6
|
+
* providers), not a selector-source: it is addressed only by an explicit flag,
|
|
7
|
+
* never matched by `--group`/`--where`, and it owns an ephemeral endpoint. This
|
|
8
|
+
* module produces the {@link ServiceEndpointMap} for a running machine and maps
|
|
9
|
+
* quickchr's typed failures onto centrs' `quickchr/*` errors. Selection, fan-out,
|
|
10
|
+
* `--via` choice, and the settings/`__default__` bypass live with the CLI plumbing
|
|
11
|
+
* (#134 Phase 3+); this file is only the connection-fact origin.
|
|
12
|
+
*
|
|
13
|
+
* Boundaries (`tikoci/quickchr` `docs/centrs-interface.md`):
|
|
14
|
+
* - quickchr is imported through a **runtime specifier** (as in
|
|
15
|
+
* `scripts/qa-active-channels.ts` / `test/integration/chr.ts`), so the
|
|
16
|
+
* optional dependency's raw `.ts` never enters centrs' strict `tsc`, and a
|
|
17
|
+
* missing package is a friendly typed error rather than a build/import crash.
|
|
18
|
+
* - centrs reads quickchr's public API only (`QuickCHR.get(name).descriptor()`);
|
|
19
|
+
* it never touches `machine.json`, `quickchr.env`, or the credential store.
|
|
20
|
+
* - The descriptor is credential-bearing; its `auth` values are secret material
|
|
21
|
+
* and must never be logged.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { CentrsError } from "../errors.ts";
|
|
25
|
+
import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
|
|
26
|
+
import type {
|
|
27
|
+
AnyServiceEndpoint,
|
|
28
|
+
ServiceEndpointMap,
|
|
29
|
+
SshEndpointAuth,
|
|
30
|
+
} from "./service-endpoint.ts";
|
|
31
|
+
import { isEndpointAvailable } from "./service-endpoint.ts";
|
|
32
|
+
import type { ResolvedAuth, ResolvedTarget } from "./target.ts";
|
|
33
|
+
import { formatHostForUrl } from "./target.ts";
|
|
34
|
+
|
|
35
|
+
/** Highest quickchr descriptor schema version this centrs understands. */
|
|
36
|
+
export const SUPPORTED_DESCRIPTOR_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
const QUICKCHR_MODULE = "@tikoci/quickchr";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The narrow slice of quickchr's public API this consumer depends on. Declared
|
|
42
|
+
* here (not imported) so the runtime-specifier `import()` keeps quickchr's types
|
|
43
|
+
* out of `tsc`; the shapes mirror `@tikoci/quickchr` `docs/centrs-interface.md`.
|
|
44
|
+
*/
|
|
45
|
+
interface QuickChrInstance {
|
|
46
|
+
// The descriptor crosses an optional-dependency boundary, so it is `unknown`
|
|
47
|
+
// until validated by `validateDescriptor()` — never trust its runtime shape.
|
|
48
|
+
descriptor(): Promise<unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface QuickChrModule {
|
|
52
|
+
QuickCHR?: {
|
|
53
|
+
get?(name: string): QuickChrInstance | null | undefined;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The descriptor fields centrs reads. quickchr's `ServiceEndpoint` is structurally
|
|
58
|
+
* identical to centrs' neutral {@link AnyServiceEndpoint} (deliberately shared). */
|
|
59
|
+
interface QuickchrDescriptor {
|
|
60
|
+
descriptorVersion: number;
|
|
61
|
+
quickchr?: { packageVersion?: string };
|
|
62
|
+
name: string;
|
|
63
|
+
version: string;
|
|
64
|
+
arch: string;
|
|
65
|
+
services: Record<string, AnyServiceEndpoint>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A quickchr error carries a typed `code` (`ErrorCode`) we can branch on. */
|
|
69
|
+
function quickchrErrorCode(error: unknown): string | undefined {
|
|
70
|
+
if (
|
|
71
|
+
typeof error === "object" &&
|
|
72
|
+
error !== null &&
|
|
73
|
+
"code" in error &&
|
|
74
|
+
typeof (error as { code: unknown }).code === "string"
|
|
75
|
+
) {
|
|
76
|
+
return (error as { code: string }).code;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolved quickchr connection facts for one running machine. */
|
|
82
|
+
export interface QuickchrResolution {
|
|
83
|
+
/** The machine name quickchr reports (echoes the `--quickchr <name>` input). */
|
|
84
|
+
name: string;
|
|
85
|
+
/** Per-`--via` endpoints; only protocols quickchr forwards are present. */
|
|
86
|
+
services: ServiceEndpointMap;
|
|
87
|
+
/** The descriptor schema version quickchr emitted (≤ {@link SUPPORTED_DESCRIPTOR_VERSION}). */
|
|
88
|
+
descriptorVersion: number;
|
|
89
|
+
/** The `@tikoci/quickchr` package version that produced the descriptor, when reported. */
|
|
90
|
+
packageVersion?: string;
|
|
91
|
+
/** The RouterOS version the CHR is running. */
|
|
92
|
+
routerOsVersion: string;
|
|
93
|
+
/** The CHR architecture (e.g. `x86_64`). */
|
|
94
|
+
arch: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadQuickChr(): Promise<QuickChrModule> {
|
|
98
|
+
return (await import(QUICKCHR_MODULE)) as unknown as QuickChrModule;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function packageUnavailable(cause: unknown): CentrsError {
|
|
102
|
+
return new CentrsError({
|
|
103
|
+
code: "quickchr/package-unavailable",
|
|
104
|
+
summary: `The optional \`${QUICKCHR_MODULE}\` package is required for \`--quickchr\` targets but is not installed.`,
|
|
105
|
+
remediation: `Install it with \`bun add ${QUICKCHR_MODULE}\` (it is an optional dependency), or target the device directly with \`--host\`/a CDB record instead of \`--quickchr\`.`,
|
|
106
|
+
context: { module: QUICKCHR_MODULE },
|
|
107
|
+
cause,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A `quickchr/unsupported` for an old/malformed descriptor the provider can't trust. */
|
|
112
|
+
function malformedDescriptor(
|
|
113
|
+
name: string,
|
|
114
|
+
detail: string,
|
|
115
|
+
extra?: { context?: Record<string, unknown>; cause?: unknown },
|
|
116
|
+
): CentrsError {
|
|
117
|
+
return new CentrsError({
|
|
118
|
+
code: "quickchr/unsupported",
|
|
119
|
+
summary: `quickchr described machine "${name}" with a descriptor centrs cannot use: ${detail}.`,
|
|
120
|
+
remediation: `Upgrade to a \`${QUICKCHR_MODULE}\` release that emits descriptor v${SUPPORTED_DESCRIPTOR_VERSION} (0.4.5+); check the machine with \`quickchr inspect ${name}\`.`,
|
|
121
|
+
context: { module: QUICKCHR_MODULE, machine: name, ...extra?.context },
|
|
122
|
+
cause: extra?.cause,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Distinguish a *missing* optional package from one that is installed but failed
|
|
128
|
+
* to evaluate. Only a failure to resolve `@tikoci/quickchr` **itself** is
|
|
129
|
+
* `quickchr/package-unavailable`; an installed quickchr whose own transitive
|
|
130
|
+
* import is missing carries the same `ERR_MODULE_NOT_FOUND` code but names a
|
|
131
|
+
* different specifier — that is a real load fault (→ `quickchr/unsupported`,
|
|
132
|
+
* cause preserved), not a missing optional dep. Mislabeling it would send the
|
|
133
|
+
* user to reinstall the wrong package.
|
|
134
|
+
*/
|
|
135
|
+
function isModuleNotFound(cause: unknown): boolean {
|
|
136
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
137
|
+
// The loader quotes the specifier it could not resolve, e.g.
|
|
138
|
+
// `Cannot find module '@tikoci/quickchr'` (top-level, missing) vs
|
|
139
|
+
// `Cannot find package 'left-pad' imported from …/@tikoci/quickchr/…` (transitive).
|
|
140
|
+
const missing = message.match(
|
|
141
|
+
/cannot find (?:module|package)\s+['"]([^'"]+)['"]/i,
|
|
142
|
+
)?.[1];
|
|
143
|
+
if (missing !== undefined) {
|
|
144
|
+
return missing === QUICKCHR_MODULE;
|
|
145
|
+
}
|
|
146
|
+
// No parseable specifier: fall back to the not-found code, but only when the
|
|
147
|
+
// message references our module and not a nested (`imported from`) resolution.
|
|
148
|
+
const code = quickchrErrorCode(cause);
|
|
149
|
+
const isNotFoundCode =
|
|
150
|
+
code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
151
|
+
return (
|
|
152
|
+
isNotFoundCode &&
|
|
153
|
+
message.includes(QUICKCHR_MODULE) &&
|
|
154
|
+
!/imported from|from\s+['"]/i.test(message)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validate the untrusted descriptor an optional-dependency call returned into the
|
|
160
|
+
* shape centrs reads. Every malformed/old/newer case becomes an actionable
|
|
161
|
+
* `quickchr/unsupported` — never a raw `TypeError` from a later field access, and
|
|
162
|
+
* never a silent empty `services` map (that would hide a provider contract
|
|
163
|
+
* violation and defer the failure to a `--via` lookup).
|
|
164
|
+
*/
|
|
165
|
+
function validateDescriptor(raw: unknown, name: string): QuickchrDescriptor {
|
|
166
|
+
if (typeof raw !== "object" || raw === null) {
|
|
167
|
+
throw malformedDescriptor(
|
|
168
|
+
name,
|
|
169
|
+
"quickchr returned a non-object descriptor",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const d = raw as Partial<QuickchrDescriptor>;
|
|
173
|
+
if (typeof d.descriptorVersion !== "number") {
|
|
174
|
+
throw malformedDescriptor(
|
|
175
|
+
name,
|
|
176
|
+
"it has no numeric `descriptorVersion` (pre-v1 or malformed)",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (d.descriptorVersion > SUPPORTED_DESCRIPTOR_VERSION) {
|
|
180
|
+
throw new CentrsError({
|
|
181
|
+
code: "quickchr/unsupported",
|
|
182
|
+
summary: `quickchr emitted descriptor v${d.descriptorVersion}, but this centrs understands only v${SUPPORTED_DESCRIPTOR_VERSION}.`,
|
|
183
|
+
remediation:
|
|
184
|
+
"Upgrade centrs to a release that supports the newer quickchr descriptor version.",
|
|
185
|
+
context: {
|
|
186
|
+
machine: name,
|
|
187
|
+
descriptorVersion: d.descriptorVersion,
|
|
188
|
+
supported: SUPPORTED_DESCRIPTOR_VERSION,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (typeof d.services !== "object" || d.services === null) {
|
|
193
|
+
throw malformedDescriptor(
|
|
194
|
+
name,
|
|
195
|
+
"it has no `services` map (the quickchr contract requires one)",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (
|
|
199
|
+
typeof d.name !== "string" ||
|
|
200
|
+
typeof d.version !== "string" ||
|
|
201
|
+
typeof d.arch !== "string"
|
|
202
|
+
) {
|
|
203
|
+
throw malformedDescriptor(
|
|
204
|
+
name,
|
|
205
|
+
"it is missing required `name`/`version`/`arch` fields",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return d as QuickchrDescriptor;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Map quickchr's `services` map onto the neutral {@link ServiceEndpointMap}. Only
|
|
213
|
+
* keys that are known centrs `--via` protocols are carried; unknown future service
|
|
214
|
+
* keys are ignored (the descriptor's additive-only forward-compat policy). The
|
|
215
|
+
* endpoint objects are structurally centrs' own shape, so no per-field rewrite is
|
|
216
|
+
* needed here — provider-specific auth mapping (e.g. `privateKeyPath` → `sshKey`)
|
|
217
|
+
* happens later when an endpoint is turned into a `ResolvedAuth` for a chosen `--via`.
|
|
218
|
+
*/
|
|
219
|
+
function mapServices(
|
|
220
|
+
services: Record<string, AnyServiceEndpoint>,
|
|
221
|
+
): ServiceEndpointMap {
|
|
222
|
+
const map: ServiceEndpointMap = {};
|
|
223
|
+
const known = new Set<string>(plannedProtocols);
|
|
224
|
+
for (const [key, endpoint] of Object.entries(services)) {
|
|
225
|
+
if (known.has(key)) {
|
|
226
|
+
// The per-key map narrows `ssh` to SshServiceEndpoint; the provider-supplied
|
|
227
|
+
// endpoint is structurally correct per the descriptor contract, so assign
|
|
228
|
+
// through a widened view rather than re-validating each field here.
|
|
229
|
+
(map as Record<RouterOsProtocol, AnyServiceEndpoint>)[
|
|
230
|
+
key as RouterOsProtocol
|
|
231
|
+
] = endpoint;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return map;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolve a `--quickchr <name>` target into its live connection facts. Throws a
|
|
239
|
+
* typed `quickchr/*` error for every failure mode — a missing package, an
|
|
240
|
+
* unsupported/old quickchr, an unknown machine name, or a stopped machine — never
|
|
241
|
+
* a silent fallback. The `load` seam is for unit tests; production passes nothing.
|
|
242
|
+
*/
|
|
243
|
+
export async function resolveQuickchrTarget(
|
|
244
|
+
name: string,
|
|
245
|
+
load: () => Promise<QuickChrModule> = loadQuickChr,
|
|
246
|
+
): Promise<QuickchrResolution> {
|
|
247
|
+
let mod: QuickChrModule;
|
|
248
|
+
try {
|
|
249
|
+
mod = await load();
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
// A CentrsError from the loader (or downstream) passes through unchanged.
|
|
252
|
+
if (cause instanceof CentrsError) {
|
|
253
|
+
throw cause;
|
|
254
|
+
}
|
|
255
|
+
// Only a genuine module-not-found is "package not installed"; an installed
|
|
256
|
+
// package that throws while loading is a real fault, not a missing optional dep.
|
|
257
|
+
if (isModuleNotFound(cause)) {
|
|
258
|
+
throw packageUnavailable(cause);
|
|
259
|
+
}
|
|
260
|
+
throw new CentrsError({
|
|
261
|
+
code: "quickchr/unsupported",
|
|
262
|
+
summary: `The \`${QUICKCHR_MODULE}\` package is installed but failed to load.`,
|
|
263
|
+
remediation: `Reinstall it (\`bun add ${QUICKCHR_MODULE}\`) or inspect the underlying load error; target the device with \`--host\`/a CDB record to bypass \`--quickchr\`.`,
|
|
264
|
+
context: { module: QUICKCHR_MODULE },
|
|
265
|
+
cause,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (typeof mod?.QuickCHR?.get !== "function") {
|
|
269
|
+
throw new CentrsError({
|
|
270
|
+
code: "quickchr/unsupported",
|
|
271
|
+
summary: `The installed \`${QUICKCHR_MODULE}\` does not expose the \`QuickCHR.get()\` descriptor API centrs needs.`,
|
|
272
|
+
remediation: `Upgrade to a \`${QUICKCHR_MODULE}\` release that ships descriptor v${SUPPORTED_DESCRIPTOR_VERSION} (0.4.5+).`,
|
|
273
|
+
context: { module: QUICKCHR_MODULE },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const instance = mod.QuickCHR.get(name);
|
|
278
|
+
if (instance === null || instance === undefined) {
|
|
279
|
+
throw new CentrsError({
|
|
280
|
+
code: "quickchr/machine-not-found",
|
|
281
|
+
summary: `No quickchr machine named "${name}" is registered.`,
|
|
282
|
+
remediation:
|
|
283
|
+
"List machines with `quickchr list` and pass an existing name, or start it with `quickchr start`.",
|
|
284
|
+
context: { machine: name },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let raw: unknown;
|
|
289
|
+
try {
|
|
290
|
+
raw = await instance.descriptor();
|
|
291
|
+
} catch (cause) {
|
|
292
|
+
const code = quickchrErrorCode(cause);
|
|
293
|
+
if (code === "MACHINE_STOPPED") {
|
|
294
|
+
throw new CentrsError({
|
|
295
|
+
code: "quickchr/machine-stopped",
|
|
296
|
+
summary: `The quickchr machine "${name}" is not running, so it has no live connection facts.`,
|
|
297
|
+
remediation: `Start it with \`quickchr start ${name}\` (or by name), then retry \`--quickchr ${name}\`.`,
|
|
298
|
+
context: { machine: name },
|
|
299
|
+
cause,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (code === "MACHINE_NOT_FOUND") {
|
|
303
|
+
throw new CentrsError({
|
|
304
|
+
code: "quickchr/machine-not-found",
|
|
305
|
+
summary: `No quickchr machine named "${name}" is registered.`,
|
|
306
|
+
remediation:
|
|
307
|
+
"List machines with `quickchr list` and pass an existing name, or start it with `quickchr start`.",
|
|
308
|
+
context: { machine: name },
|
|
309
|
+
cause,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
throw new CentrsError({
|
|
313
|
+
code: "quickchr/unsupported",
|
|
314
|
+
summary: `quickchr failed to describe machine "${name}".`,
|
|
315
|
+
remediation:
|
|
316
|
+
"Check `quickchr inspect <name>` directly; the machine may be in a transient state.",
|
|
317
|
+
context: { machine: name, quickchrCode: code },
|
|
318
|
+
cause,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const descriptor = validateDescriptor(raw, name);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
name: descriptor.name,
|
|
326
|
+
services: mapServices(descriptor.services),
|
|
327
|
+
descriptorVersion: descriptor.descriptorVersion,
|
|
328
|
+
packageVersion: descriptor.quickchr?.packageVersion,
|
|
329
|
+
routerOsVersion: descriptor.version,
|
|
330
|
+
arch: descriptor.arch,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The `--quickchr` ↔ direct-override conflict (#134 "Initial override rule"):
|
|
336
|
+
* a named-live-provider owns its ephemeral endpoint, so explicit `--host` /
|
|
337
|
+
* `--port` / `--username` / `--password` / `--ssh-key` values have nothing to
|
|
338
|
+
* override and are rejected up front rather than silently ignored. Env/config
|
|
339
|
+
* values for the same fields are NOT a conflict — the provider simply bypasses
|
|
340
|
+
* the settings ladder for connection facts (`docs/CONSTITUTION.md` →
|
|
341
|
+
* Resolution providers).
|
|
342
|
+
*/
|
|
343
|
+
export function assertNoQuickchrOverrideConflict(
|
|
344
|
+
request: {
|
|
345
|
+
host?: string;
|
|
346
|
+
port?: number;
|
|
347
|
+
username?: string;
|
|
348
|
+
password?: string;
|
|
349
|
+
sshKey?: string;
|
|
350
|
+
},
|
|
351
|
+
machine: string,
|
|
352
|
+
): void {
|
|
353
|
+
const conflicts: string[] = [];
|
|
354
|
+
if (request.host !== undefined) {
|
|
355
|
+
conflicts.push("--host");
|
|
356
|
+
}
|
|
357
|
+
if (request.port !== undefined) {
|
|
358
|
+
conflicts.push("--port");
|
|
359
|
+
}
|
|
360
|
+
if (request.username !== undefined) {
|
|
361
|
+
conflicts.push("--username");
|
|
362
|
+
}
|
|
363
|
+
if (request.password !== undefined) {
|
|
364
|
+
conflicts.push("--password");
|
|
365
|
+
}
|
|
366
|
+
if (request.sshKey !== undefined) {
|
|
367
|
+
conflicts.push("--ssh-key");
|
|
368
|
+
}
|
|
369
|
+
if (conflicts.length === 0) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
throw new CentrsError({
|
|
373
|
+
code: "usage/conflicting-flags",
|
|
374
|
+
summary: `\`--quickchr ${machine}\` supplies host/port/auth from the live VM descriptor and cannot be combined with ${conflicts.join(", ")}.`,
|
|
375
|
+
remediation:
|
|
376
|
+
"Drop the override flag(s) and let quickchr supply the connection facts, or drop `--quickchr` and target the device directly.",
|
|
377
|
+
context: { machine, conflicts },
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The connection facts a chosen `--via` extracts from a quickchr resolution. */
|
|
382
|
+
export interface QuickchrConnection {
|
|
383
|
+
target: ResolvedTarget;
|
|
384
|
+
auth: ResolvedAuth;
|
|
385
|
+
/**
|
|
386
|
+
* True when centrs must relax peer verification to use the endpoint: the
|
|
387
|
+
* descriptor prefers the VM's TLS forwards (https / api-ssl), whose
|
|
388
|
+
* certificate is the CHR's own self-signed one, and an SSH endpoint's host
|
|
389
|
+
* key is ephemeral (regenerated per VM). The endpoint is trusted **by
|
|
390
|
+
* provenance** — quickchr vouches for its loopback-forwarded port — so the
|
|
391
|
+
* command resolver applies `insecure` with `provider` provenance and surfaces
|
|
392
|
+
* {@link warnings} rather than demanding `--insecure` for every lab call.
|
|
393
|
+
*/
|
|
394
|
+
insecure: boolean;
|
|
395
|
+
/** Envelope warnings the consuming resolver must carry (provider-trust note). */
|
|
396
|
+
warnings: readonly { code: string; message: string }[];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* True when the SSH endpoint's advertised auth can actually complete a batch
|
|
401
|
+
* (non-interactive) handoff: `agent-or-config`, or `private-key` **with** a
|
|
402
|
+
* `privateKeyPath` to hand to the client. A bare `private-key` batch mode with
|
|
403
|
+
* no path would silently fall back to whatever ambient agent/config offers —
|
|
404
|
+
* an unintended trust widening — so it does not count as usable.
|
|
405
|
+
*/
|
|
406
|
+
function hasUsableSshAuth(auth: SshEndpointAuth | undefined): boolean {
|
|
407
|
+
const batchModes = auth?.batchModes ?? [];
|
|
408
|
+
return (
|
|
409
|
+
batchModes.includes("agent-or-config") ||
|
|
410
|
+
(batchModes.includes("private-key") && auth?.privateKeyPath !== undefined)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function unsupportedVia(
|
|
415
|
+
resolution: QuickchrResolution,
|
|
416
|
+
via: RouterOsProtocol,
|
|
417
|
+
detail: string,
|
|
418
|
+
context: Record<string, unknown> = {},
|
|
419
|
+
): CentrsError {
|
|
420
|
+
// Alternatives must be actually dialable: never re-suggest the `--via` that
|
|
421
|
+
// just failed, and only advertise `ssh` when its auth passes the same batch
|
|
422
|
+
// gate {@link buildConnection} enforces.
|
|
423
|
+
const available = (
|
|
424
|
+
Object.entries(resolution.services) as [
|
|
425
|
+
RouterOsProtocol,
|
|
426
|
+
AnyServiceEndpoint,
|
|
427
|
+
][]
|
|
428
|
+
)
|
|
429
|
+
.filter(
|
|
430
|
+
([id, endpoint]) =>
|
|
431
|
+
id !== via &&
|
|
432
|
+
endpoint.available &&
|
|
433
|
+
(id !== "ssh" ||
|
|
434
|
+
hasUsableSshAuth(endpoint.auth as SshEndpointAuth | undefined)),
|
|
435
|
+
)
|
|
436
|
+
.map(([id]) => id);
|
|
437
|
+
return new CentrsError({
|
|
438
|
+
code: "quickchr/unsupported-via",
|
|
439
|
+
summary: `quickchr machine "${resolution.name}" cannot serve \`--via ${via}\`: ${detail}.`,
|
|
440
|
+
remediation:
|
|
441
|
+
available.length > 0
|
|
442
|
+
? `Pick one of the machine's live services with \`--via ${available.join("` / `--via ")}\`, or target the device directly without \`--quickchr\`.`
|
|
443
|
+
: `The machine reports no live services; check it with \`quickchr inspect ${resolution.name}\`.`,
|
|
444
|
+
context: {
|
|
445
|
+
machine: resolution.name,
|
|
446
|
+
via,
|
|
447
|
+
availableServices: available,
|
|
448
|
+
...context,
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Materialize the connection facts for one chosen `--via` from a resolved
|
|
455
|
+
* quickchr machine — the named-live-provider consumption seam (#134 Phase 4).
|
|
456
|
+
* Substitutes for `resolveTarget`/`resolveAuth`: host/port/TLS/auth come ONLY
|
|
457
|
+
* from the descriptor's per-service endpoint (never the CDB, env, or the
|
|
458
|
+
* `__default__` ladder), stamped with `provider` provenance.
|
|
459
|
+
*
|
|
460
|
+
* Failure modes are all `quickchr/unsupported-via`, never a silent fallback:
|
|
461
|
+
* a `--via` the descriptor does not forward (quickchr v1 forwards
|
|
462
|
+
* `rest-api`/`native-api`/`ssh` only), an endpoint quickchr marked
|
|
463
|
+
* unavailable, or an SSH endpoint with no batch-capable (non-interactive)
|
|
464
|
+
* auth — centrs must not password-prompt mid-fan-out.
|
|
465
|
+
*/
|
|
466
|
+
export function quickchrConnection(
|
|
467
|
+
resolution: QuickchrResolution,
|
|
468
|
+
via: RouterOsProtocol,
|
|
469
|
+
): QuickchrConnection {
|
|
470
|
+
const endpoint = resolution.services[via];
|
|
471
|
+
if (endpoint === undefined) {
|
|
472
|
+
throw unsupportedVia(
|
|
473
|
+
resolution,
|
|
474
|
+
via,
|
|
475
|
+
"the descriptor does not forward that service (quickchr v1 forwards rest-api/native-api/ssh)",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return buildConnection(resolution, via, endpoint);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildConnection(
|
|
482
|
+
resolution: QuickchrResolution,
|
|
483
|
+
via: RouterOsProtocol,
|
|
484
|
+
endpoint: AnyServiceEndpoint,
|
|
485
|
+
): QuickchrConnection {
|
|
486
|
+
if (!isEndpointAvailable(endpoint)) {
|
|
487
|
+
throw unsupportedVia(
|
|
488
|
+
resolution,
|
|
489
|
+
via,
|
|
490
|
+
`the service is not available (${endpoint.unavailableReason})`,
|
|
491
|
+
{ unavailableReason: endpoint.unavailableReason },
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const source = {
|
|
496
|
+
kind: "provider" as const,
|
|
497
|
+
key: `quickchr:${resolution.name}`,
|
|
498
|
+
};
|
|
499
|
+
const hostForUrl = formatHostForUrl(endpoint.host);
|
|
500
|
+
let baseUrl: string;
|
|
501
|
+
let scheme: "http" | "https";
|
|
502
|
+
if (via === "ssh") {
|
|
503
|
+
scheme = "http";
|
|
504
|
+
baseUrl = `ssh://${hostForUrl}:${endpoint.port}`;
|
|
505
|
+
} else if (via === "native-api") {
|
|
506
|
+
scheme = endpoint.tls ? "https" : "http";
|
|
507
|
+
baseUrl = `${endpoint.tls ? "api-ssl" : "api"}://${hostForUrl}:${endpoint.port}`;
|
|
508
|
+
} else {
|
|
509
|
+
scheme = endpoint.tls ? "https" : "http";
|
|
510
|
+
baseUrl = `${scheme}://${hostForUrl}:${endpoint.port}/rest`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const target: ResolvedTarget = {
|
|
514
|
+
input: resolution.name,
|
|
515
|
+
host: endpoint.host,
|
|
516
|
+
port: endpoint.port,
|
|
517
|
+
scheme,
|
|
518
|
+
tls: endpoint.tls,
|
|
519
|
+
baseUrl,
|
|
520
|
+
identity: resolution.name,
|
|
521
|
+
source,
|
|
522
|
+
hostSource: source,
|
|
523
|
+
portSource: source,
|
|
524
|
+
sources: { host: source, port: source },
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Trust-by-provenance: a TLS endpoint presents the CHR's self-signed cert
|
|
528
|
+
// and an SSH endpoint an ephemeral host key, and the descriptor carries no
|
|
529
|
+
// trust material — so the provider's voucher for its loopback forward is the
|
|
530
|
+
// trust anchor, surfaced as a warning (see QuickchrConnection.insecure).
|
|
531
|
+
const insecure = via === "ssh" || endpoint.tls;
|
|
532
|
+
const warnings = insecure
|
|
533
|
+
? [
|
|
534
|
+
{
|
|
535
|
+
code: "transport/provider-trust",
|
|
536
|
+
message:
|
|
537
|
+
via === "ssh"
|
|
538
|
+
? `quickchr machine "${resolution.name}": strict SSH host-key checking is relaxed for the ephemeral VM (loopback forward vouched by the provider).`
|
|
539
|
+
: `quickchr machine "${resolution.name}": the ${via} endpoint uses the VM's self-signed TLS certificate; peer verification is relaxed (loopback forward vouched by the provider).`,
|
|
540
|
+
},
|
|
541
|
+
]
|
|
542
|
+
: [];
|
|
543
|
+
|
|
544
|
+
if (via === "ssh") {
|
|
545
|
+
// The contract guarantees `auth` on an available SSH endpoint; treat a
|
|
546
|
+
// malformed descriptor (missing auth / batchModes / a `private-key` batch
|
|
547
|
+
// mode with no key path) as batch-incapable rather than crashing on a
|
|
548
|
+
// field access or letting the SSH client fall back to ambient config.
|
|
549
|
+
const auth = endpoint.auth as SshEndpointAuth | undefined;
|
|
550
|
+
if (auth === undefined || !hasUsableSshAuth(auth)) {
|
|
551
|
+
throw unsupportedVia(
|
|
552
|
+
resolution,
|
|
553
|
+
via,
|
|
554
|
+
"the SSH endpoint has no usable batch-capable (non-interactive) auth mode, and centrs never password-prompts for a provider target",
|
|
555
|
+
{ modes: auth?.modes ?? [], batchModes: auth?.batchModes ?? [] },
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
target,
|
|
560
|
+
auth: {
|
|
561
|
+
username: auth.username,
|
|
562
|
+
usernameSource: { ...source, key: `${source.key}:username` },
|
|
563
|
+
password: "",
|
|
564
|
+
passwordProvided: false,
|
|
565
|
+
sshKey: auth.privateKeyPath,
|
|
566
|
+
sshKeySource:
|
|
567
|
+
auth.privateKeyPath !== undefined
|
|
568
|
+
? { ...source, key: `${source.key}:ssh-key` }
|
|
569
|
+
: undefined,
|
|
570
|
+
},
|
|
571
|
+
insecure,
|
|
572
|
+
warnings,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const auth = endpoint.auth as
|
|
577
|
+
| { username: string; password?: string }
|
|
578
|
+
| undefined;
|
|
579
|
+
return {
|
|
580
|
+
target,
|
|
581
|
+
auth: {
|
|
582
|
+
username: auth?.username,
|
|
583
|
+
usernameSource:
|
|
584
|
+
auth?.username !== undefined
|
|
585
|
+
? { ...source, key: `${source.key}:username` }
|
|
586
|
+
: undefined,
|
|
587
|
+
password: auth?.password ?? "",
|
|
588
|
+
passwordProvided: auth?.password !== undefined,
|
|
589
|
+
passwordSource:
|
|
590
|
+
auth?.password !== undefined
|
|
591
|
+
? { ...source, key: `${source.key}:password` }
|
|
592
|
+
: undefined,
|
|
593
|
+
},
|
|
594
|
+
insecure,
|
|
595
|
+
warnings,
|
|
596
|
+
};
|
|
597
|
+
}
|