@tikoci/centrs 0.1.8 → 0.1.9-next.22
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 +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/protocols/adapter.ts
CHANGED
|
@@ -180,6 +180,16 @@ export interface ProtocolApiResult {
|
|
|
180
180
|
data: unknown;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Options for a single `execute`/`apiRequest` call. `onSent` fires once the
|
|
185
|
+
* command itself is about to leave for the router: after a native or console
|
|
186
|
+
* login, never during it. A failure before it fired cannot have run the
|
|
187
|
+
* command (#448).
|
|
188
|
+
*/
|
|
189
|
+
export interface ProtocolSendOptions {
|
|
190
|
+
onSent?: () => void;
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
/** Options for incremental native command replies. */
|
|
184
194
|
export interface ProtocolListenOptions {
|
|
185
195
|
/** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
|
|
@@ -237,9 +247,15 @@ export interface ProtocolAdapter {
|
|
|
237
247
|
/** Read a menu as an array of records, optionally projected/detailed. */
|
|
238
248
|
list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
|
|
239
249
|
/** Run a CLI-shaped command (WP-1c write surface). */
|
|
240
|
-
execute(
|
|
250
|
+
execute(
|
|
251
|
+
request: ProtocolExecuteRequest,
|
|
252
|
+
options?: ProtocolSendOptions,
|
|
253
|
+
): Promise<ProtocolExecuteResult>;
|
|
241
254
|
/** Run a normalized structured `api` request (the gh-api passthrough surface). */
|
|
242
|
-
apiRequest(
|
|
255
|
+
apiRequest(
|
|
256
|
+
request: ProtocolApiRequest,
|
|
257
|
+
options?: ProtocolSendOptions,
|
|
258
|
+
): Promise<ProtocolApiResult>;
|
|
243
259
|
/**
|
|
244
260
|
* Incremental native replies to the literal command (the same mapping as
|
|
245
261
|
* `apiRequest`; never rewritten into a `listen`). Yields each `!re` and
|
|
@@ -341,7 +357,10 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
341
357
|
|
|
342
358
|
async execute(
|
|
343
359
|
request: ProtocolExecuteRequest,
|
|
360
|
+
options?: ProtocolSendOptions,
|
|
344
361
|
): Promise<ProtocolExecuteResult> {
|
|
362
|
+
// REST has no session to open first: the request is the command.
|
|
363
|
+
options?.onSent?.();
|
|
345
364
|
if (request.script !== undefined) {
|
|
346
365
|
const data = await this.restPost<unknown>("/execute", {
|
|
347
366
|
script: request.script,
|
|
@@ -369,7 +388,11 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
369
388
|
return normalizeRestExecute(data);
|
|
370
389
|
}
|
|
371
390
|
|
|
372
|
-
async apiRequest(
|
|
391
|
+
async apiRequest(
|
|
392
|
+
request: ProtocolApiRequest,
|
|
393
|
+
options?: ProtocolSendOptions,
|
|
394
|
+
): Promise<ProtocolApiResult> {
|
|
395
|
+
options?.onSent?.();
|
|
373
396
|
const base = request.path.replace(/\/$/, "");
|
|
374
397
|
switch (request.verb) {
|
|
375
398
|
case "print": {
|
|
@@ -765,9 +788,12 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
765
788
|
|
|
766
789
|
async execute(
|
|
767
790
|
request: ProtocolExecuteRequest,
|
|
791
|
+
options?: ProtocolSendOptions,
|
|
768
792
|
): Promise<ProtocolExecuteResult> {
|
|
769
793
|
if (request.script !== undefined) {
|
|
770
|
-
return normalizeNativeExecute(
|
|
794
|
+
return normalizeNativeExecute(
|
|
795
|
+
await this.executeScript(request.script, options),
|
|
796
|
+
);
|
|
771
797
|
}
|
|
772
798
|
const command: NativeApiCommand = {
|
|
773
799
|
command: `${request.path.replace(/\/$/, "")}/${request.command}`,
|
|
@@ -781,7 +807,7 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
781
807
|
if (request.binary) {
|
|
782
808
|
command.binary = true;
|
|
783
809
|
}
|
|
784
|
-
const replies = await this.talk(command);
|
|
810
|
+
const replies = await this.talk(command, options);
|
|
785
811
|
return { records: repliesToRecords(replies) };
|
|
786
812
|
}
|
|
787
813
|
|
|
@@ -794,17 +820,24 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
794
820
|
*/
|
|
795
821
|
private async executeScript(
|
|
796
822
|
script: string,
|
|
823
|
+
options?: ProtocolSendOptions,
|
|
797
824
|
): Promise<Record<string, string>[]> {
|
|
798
|
-
const replies = await this.talk(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
825
|
+
const replies = await this.talk(
|
|
826
|
+
{
|
|
827
|
+
command: "/execute",
|
|
828
|
+
attributes: { script, "as-string": "" },
|
|
829
|
+
},
|
|
830
|
+
options,
|
|
831
|
+
);
|
|
802
832
|
return repliesToRecords(replies);
|
|
803
833
|
}
|
|
804
834
|
|
|
805
|
-
async apiRequest(
|
|
835
|
+
async apiRequest(
|
|
836
|
+
request: ProtocolApiRequest,
|
|
837
|
+
options?: ProtocolSendOptions,
|
|
838
|
+
): Promise<ProtocolApiResult> {
|
|
806
839
|
const records = repliesToRecords(
|
|
807
|
-
await this.talk(nativeCommandFor(request)),
|
|
840
|
+
await this.talk(nativeCommandFor(request), options),
|
|
808
841
|
);
|
|
809
842
|
switch (request.verb) {
|
|
810
843
|
case "print":
|
|
@@ -880,8 +913,13 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
880
913
|
return session;
|
|
881
914
|
}
|
|
882
915
|
|
|
883
|
-
private async talk(
|
|
916
|
+
private async talk(
|
|
917
|
+
command: NativeApiCommand,
|
|
918
|
+
options?: ProtocolSendOptions,
|
|
919
|
+
): Promise<ApiReply[]> {
|
|
920
|
+
// Connecting logs in lazily; only the sentence after `/login` is the command.
|
|
884
921
|
const session = await this.connect();
|
|
922
|
+
options?.onSent?.();
|
|
885
923
|
return this.withTimeout(session.talk(command));
|
|
886
924
|
}
|
|
887
925
|
|
|
@@ -982,6 +1020,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
|
|
|
982
1020
|
|
|
983
1021
|
async execute(
|
|
984
1022
|
request: ProtocolExecuteRequest,
|
|
1023
|
+
options?: ProtocolSendOptions,
|
|
985
1024
|
): Promise<ProtocolExecuteResult> {
|
|
986
1025
|
const cli = request.script;
|
|
987
1026
|
if (cli === undefined) {
|
|
@@ -994,6 +1033,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
|
|
|
994
1033
|
});
|
|
995
1034
|
}
|
|
996
1035
|
const console = await this.open();
|
|
1036
|
+
options?.onSent?.();
|
|
997
1037
|
const { output } = await console.run(cli);
|
|
998
1038
|
return { records: [], ret: output };
|
|
999
1039
|
}
|
|
@@ -1134,6 +1174,7 @@ class SshExecAdapter implements ProtocolAdapter {
|
|
|
1134
1174
|
|
|
1135
1175
|
async execute(
|
|
1136
1176
|
request: ProtocolExecuteRequest,
|
|
1177
|
+
options?: ProtocolSendOptions,
|
|
1137
1178
|
): Promise<ProtocolExecuteResult> {
|
|
1138
1179
|
const cli = request.script;
|
|
1139
1180
|
if (cli === undefined) {
|
|
@@ -1145,6 +1186,7 @@ class SshExecAdapter implements ProtocolAdapter {
|
|
|
1145
1186
|
"Report this bug; runCommand must pass the raw command as `script` for ssh.",
|
|
1146
1187
|
});
|
|
1147
1188
|
}
|
|
1189
|
+
options?.onSent?.();
|
|
1148
1190
|
const output = await this.client.exec(cli);
|
|
1149
1191
|
return { records: [], ret: output };
|
|
1150
1192
|
}
|
package/src/resolver/cdb.ts
CHANGED
|
@@ -44,6 +44,11 @@ import {
|
|
|
44
44
|
parseDuration,
|
|
45
45
|
type ResolvedSetting,
|
|
46
46
|
} from "./settings.ts";
|
|
47
|
+
import {
|
|
48
|
+
assertNoTargetCredentials,
|
|
49
|
+
assertNoTargetPassword,
|
|
50
|
+
redactTargetInput,
|
|
51
|
+
} from "./target.ts";
|
|
47
52
|
|
|
48
53
|
const ENV_CDB_FILE = "CENTRS_CDB_FILE";
|
|
49
54
|
const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
|
|
@@ -161,6 +166,8 @@ export async function resolveCdb(
|
|
|
161
166
|
if (!input.targetInput) {
|
|
162
167
|
return undefined;
|
|
163
168
|
}
|
|
169
|
+
// Before any lookup: a CDB miss echoes the target it was given (#478).
|
|
170
|
+
assertNoTargetPassword(input.targetInput);
|
|
164
171
|
|
|
165
172
|
const settings = resolveDevicesSettings({
|
|
166
173
|
cdbFile: input.cdbFile,
|
|
@@ -225,6 +232,10 @@ export async function resolveCdb(
|
|
|
225
232
|
...(defaults ? { defaults } : {}),
|
|
226
233
|
};
|
|
227
234
|
} catch (error) {
|
|
235
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
236
|
+
if (redactTargetInput(input.targetInput) !== input.targetInput) {
|
|
237
|
+
assertNoTargetCredentials(input.targetInput);
|
|
238
|
+
}
|
|
228
239
|
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
229
240
|
// A `__default__` record supplies fallback credentials even when the
|
|
230
241
|
// target has no record of its own. On the CLI/API this fills creds for
|
|
@@ -366,7 +377,8 @@ export function resolutionFromEntry(
|
|
|
366
377
|
* ambiguity path). See `commands/devices/README.md` (Identity model).
|
|
367
378
|
*/
|
|
368
379
|
export function identityFromComment(comment: string, target: string): string {
|
|
369
|
-
|
|
380
|
+
// The record's target is shown as its handle; never with a password (#478).
|
|
381
|
+
return parseCommentKv(comment).lookups.identity ?? redactTargetInput(target);
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
/**
|
package/src/resolver/index.ts
CHANGED
|
@@ -97,6 +97,7 @@ export {
|
|
|
97
97
|
SUPPORTED_DESCRIPTOR_VERSION,
|
|
98
98
|
} from "./quickchr-provider.ts";
|
|
99
99
|
export {
|
|
100
|
+
emptyWriteSelectionError,
|
|
100
101
|
expandSelection,
|
|
101
102
|
isDefaultRecordTarget,
|
|
102
103
|
type SelectionExpansion,
|
|
@@ -130,10 +131,13 @@ export {
|
|
|
130
131
|
toCoreSource,
|
|
131
132
|
} from "./settings.ts";
|
|
132
133
|
export {
|
|
134
|
+
assertNoTargetCredentials,
|
|
135
|
+
assertNoTargetPassword,
|
|
133
136
|
defaultPortForScheme,
|
|
134
137
|
parseHostCandidate,
|
|
135
138
|
type ResolvedAuth,
|
|
136
139
|
type ResolvedTarget,
|
|
140
|
+
redactTargetInput,
|
|
137
141
|
resolveAuth,
|
|
138
142
|
resolveTarget,
|
|
139
143
|
type TargetResolveInput,
|
|
@@ -47,6 +47,11 @@ import {
|
|
|
47
47
|
matchesNear,
|
|
48
48
|
type NearPredicate,
|
|
49
49
|
} from "./geo.ts";
|
|
50
|
+
import {
|
|
51
|
+
assertNoTargetCredentials,
|
|
52
|
+
assertNoTargetPassword,
|
|
53
|
+
redactTargetInput,
|
|
54
|
+
} from "./target.ts";
|
|
50
55
|
|
|
51
56
|
/** One `--where attr=value` device-class clause (exact-match, AND-combined). */
|
|
52
57
|
export interface SelectionWhereClause {
|
|
@@ -106,10 +111,36 @@ export interface SelectionExpansion {
|
|
|
106
111
|
/** Members ordered by record index; literals appended in positional order. */
|
|
107
112
|
targets: readonly SelectionMember[];
|
|
108
113
|
warnings: readonly ResolverWarning[];
|
|
109
|
-
/** True when nothing matched (an
|
|
114
|
+
/** True when nothing matched (an error only for a write; see {@link emptyWriteSelectionError}). */
|
|
110
115
|
empty: boolean;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/**
|
|
119
|
+
* An empty selection for a write-shaped fan-out (#460). A read over it is
|
|
120
|
+
* `ok: true` with the expansion's warning, so a poller over an occasionally
|
|
121
|
+
* empty group stays quiet. A write is an error, as RouterOS refuses an empty
|
|
122
|
+
* CMR selection (`no devices selected`): a misspelled group must not look like
|
|
123
|
+
* a successful bulk write. The code is the expansion's own empty warning.
|
|
124
|
+
*/
|
|
125
|
+
export function emptyWriteSelectionError(
|
|
126
|
+
expansion: Pick<SelectionExpansion, "warnings">,
|
|
127
|
+
action: string,
|
|
128
|
+
): CentrsError {
|
|
129
|
+
const warning = expansion.warnings.findLast(
|
|
130
|
+
(w) => w.code === "cdb/empty-group" || w.code === "cdb/empty-selection",
|
|
131
|
+
);
|
|
132
|
+
return new CentrsError({
|
|
133
|
+
code:
|
|
134
|
+
warning?.code === "cdb/empty-group"
|
|
135
|
+
? "cdb/empty-group"
|
|
136
|
+
: "cdb/empty-selection",
|
|
137
|
+
summary: `${warning?.message ?? "The target selection matched no CDB records."} Nothing ran: a write-shaped ${action} must select at least one router.`,
|
|
138
|
+
remediation:
|
|
139
|
+
"Check the selector against `centrs devices list` and `centrs devices groups` (groups and `--where` values match as written), then re-run. A read over the same selection succeeds with a warning instead.",
|
|
140
|
+
context: { ...warning?.context, write: true },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
/** True when a resolved target is the reserved credential-fallback record. */
|
|
114
145
|
export function isDefaultRecordTarget(target: string): boolean {
|
|
115
146
|
return target === DEFAULT_RECORD_TARGET;
|
|
@@ -249,10 +280,15 @@ export async function expandSelection(
|
|
|
249
280
|
const literals: SelectionMember[] = [];
|
|
250
281
|
const seenLiteral = new Set<string>();
|
|
251
282
|
for (const positional of selection.positionals) {
|
|
283
|
+
assertNoTargetPassword(positional);
|
|
252
284
|
try {
|
|
253
285
|
const shown = showDevice({ cdb, target: positional });
|
|
254
286
|
indices.add(shown.data.entry.cdbRecordIndex);
|
|
255
287
|
} catch (error) {
|
|
288
|
+
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
289
|
+
if (redactTargetInput(positional) !== positional) {
|
|
290
|
+
assertNoTargetCredentials(positional);
|
|
291
|
+
}
|
|
256
292
|
if (
|
|
257
293
|
error instanceof CentrsError &&
|
|
258
294
|
error.code === "cdb/not-found-target"
|
package/src/resolver/target.ts
CHANGED
|
@@ -104,11 +104,12 @@ export function resolveTarget(
|
|
|
104
104
|
summary: "No host could be resolved for `retrieve`.",
|
|
105
105
|
remediation:
|
|
106
106
|
"Pass a target positional like `centrs retrieve 192.0.2.10 /system/resource --via rest-api` or set `--host` / `CENTRS_HOST`.",
|
|
107
|
-
context: { targetInput: input.targetInput },
|
|
107
|
+
context: { targetInput: redactTargetInput(input.targetInput) },
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const rawCandidate = hostSetting.value.trim();
|
|
112
|
+
assertNoTargetCredentials(rawCandidate);
|
|
112
113
|
const macCandidate = normalizeMac(rawCandidate);
|
|
113
114
|
|
|
114
115
|
// L2 transports (mac-telnet) address the device by **MAC** directly — no IP
|
|
@@ -127,11 +128,13 @@ export function resolveTarget(
|
|
|
127
128
|
if (!mac) {
|
|
128
129
|
throw new CentrsError({
|
|
129
130
|
code: "target/mac-required",
|
|
130
|
-
summary: `mac-telnet needs a MAC address target; got "${input.targetInput ?? cdb?.target ?? rawCandidate}".`,
|
|
131
|
+
summary: `mac-telnet needs a MAC address target; got "${redactTargetInput(input.targetInput ?? cdb?.target ?? rawCandidate)}".`,
|
|
131
132
|
remediation:
|
|
132
133
|
"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).",
|
|
133
134
|
context: {
|
|
134
|
-
target:
|
|
135
|
+
target: redactTargetInput(
|
|
136
|
+
input.targetInput ?? cdb?.target ?? rawCandidate,
|
|
137
|
+
),
|
|
135
138
|
via,
|
|
136
139
|
},
|
|
137
140
|
});
|
|
@@ -178,7 +181,7 @@ export function resolveTarget(
|
|
|
178
181
|
const deliveryHost = deliveryHostSetting?.value.trim() || "255.255.255.255";
|
|
179
182
|
const port = macPortSetting?.value ?? MAC_TELNET_PORT;
|
|
180
183
|
return {
|
|
181
|
-
input: input.targetInput,
|
|
184
|
+
input: redactTargetInput(input.targetInput),
|
|
182
185
|
host: deliveryHost,
|
|
183
186
|
mac,
|
|
184
187
|
port,
|
|
@@ -290,7 +293,7 @@ export function resolveTarget(
|
|
|
290
293
|
};
|
|
291
294
|
|
|
292
295
|
return {
|
|
293
|
-
input: input.targetInput,
|
|
296
|
+
input: redactTargetInput(input.targetInput),
|
|
294
297
|
// WHATWG keeps an IPv6 hostname bracketed; sockets and ssh need the bare
|
|
295
298
|
// address (`baseUrl` re-brackets it).
|
|
296
299
|
host: parsedUrl.hostname.replace(/^\[(.*)\]$/, "$1"),
|
|
@@ -471,6 +474,91 @@ export function resolveAuth(
|
|
|
471
474
|
};
|
|
472
475
|
}
|
|
473
476
|
|
|
477
|
+
/** An authority with userinfo: `user@host`, `user:pass@host`, with or without a scheme. */
|
|
478
|
+
const URL_USERINFO = /^\s*([a-z][a-z0-9+.-]*:\/\/)?[^/@\s]*@/i;
|
|
479
|
+
/** Leading whitespace and an optional `scheme://`, kept by redaction. */
|
|
480
|
+
const TARGET_PREFIX = /^\s*(?:[a-z][a-z0-9+.-]*:\/\/)?/i;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Where credentials could sit: a `:` before the LAST `@`, or explicit URL
|
|
484
|
+
* userinfo. Display also masks a percent-encoded colon; lookup validation
|
|
485
|
+
* allows that ambiguous spelling until CDB lookup has run. Bare `user@host`
|
|
486
|
+
* remains a CDB identity candidate. Index arithmetic, so a password holding `/`, `?` or
|
|
487
|
+
* `#` (which ends a URL authority early, CodeRabbit on PR #479) still counts,
|
|
488
|
+
* and nothing can backtrack (CodeQL js/polynomial-redos).
|
|
489
|
+
*/
|
|
490
|
+
function targetCredentialsSpan(
|
|
491
|
+
value: string,
|
|
492
|
+
includeEncodedColon = true,
|
|
493
|
+
): { start: number; at: number } | undefined {
|
|
494
|
+
const at = value.lastIndexOf("@");
|
|
495
|
+
if (at < 0) return undefined;
|
|
496
|
+
const prefix = TARGET_PREFIX.exec(value)?.[0] ?? "";
|
|
497
|
+
const start = prefix.length;
|
|
498
|
+
const urlUserinfo = prefix.includes("://") && URL_USERINFO.test(value);
|
|
499
|
+
const userinfo = value.slice(start, at);
|
|
500
|
+
return userinfo.includes(":") ||
|
|
501
|
+
(includeEncodedColon && /%3a/i.test(userinfo)) ||
|
|
502
|
+
urlUserinfo
|
|
503
|
+
? { start, at }
|
|
504
|
+
: undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A target string with any `user:pass@` or explicit URL userinfo replaced by
|
|
509
|
+
* `***@`, for display (#478).
|
|
510
|
+
* A target is echoed in `meta.target.input` and in error messages, so the
|
|
511
|
+
* password part must never reach them, not even when the target is refused.
|
|
512
|
+
* Everything up to the last `@` goes, so an odd password cannot slip past.
|
|
513
|
+
* Other targets, including a CDB identity like `rtr@site`, pass unchanged.
|
|
514
|
+
* An encoded colon is masked conservatively without changing lookup input.
|
|
515
|
+
*/
|
|
516
|
+
export function redactTargetInput(value: string): string;
|
|
517
|
+
export function redactTargetInput(
|
|
518
|
+
value: string | undefined,
|
|
519
|
+
): string | undefined;
|
|
520
|
+
export function redactTargetInput(
|
|
521
|
+
value: string | undefined,
|
|
522
|
+
): string | undefined {
|
|
523
|
+
if (value === undefined) return value;
|
|
524
|
+
const span = targetCredentialsSpan(value);
|
|
525
|
+
return span
|
|
526
|
+
? `${value.slice(0, span.start)}***${value.slice(span.at)}`
|
|
527
|
+
: value;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function targetCredentialsError(value: string): CentrsError {
|
|
531
|
+
const target = redactTargetInput(value);
|
|
532
|
+
return new CentrsError({
|
|
533
|
+
code: "input/invalid-target",
|
|
534
|
+
summary: `A target must not carry credentials (\`user[:password]@\`): ${target}`,
|
|
535
|
+
remediation:
|
|
536
|
+
"Drop the `user:password@` part and pass the credentials with `--username` and `--password` (or `CENTRS_USERNAME`/`CENTRS_PASSWORD`, or a CDB record); give the target as `host[:port]` or `http(s)://host[:port]`.",
|
|
537
|
+
context: { target },
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Refuse a target that may hold a password, BEFORE any lookup whose miss would
|
|
543
|
+
* echo it (#478): `resolveCdb`, the MCP allowlist, the fan-out selection. A
|
|
544
|
+
* bare `user@host` is left to the lookup; a CDB identity may contain `@`.
|
|
545
|
+
* Explicit URL userinfo is always refused, including percent-encoded forms.
|
|
546
|
+
*/
|
|
547
|
+
export function assertNoTargetPassword(value: string): void {
|
|
548
|
+
if (targetCredentialsSpan(value, false)) throw targetCredentialsError(value);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* centrs never reads credentials from a target (#478): `user:pass@host` used
|
|
553
|
+
* to be accepted with its credentials ignored, and echoed the password back.
|
|
554
|
+
* After lookup, refuse any userinfo in the host to dial, from any source.
|
|
555
|
+
*/
|
|
556
|
+
export function assertNoTargetCredentials(value: string): void {
|
|
557
|
+
if (targetCredentialsSpan(value) || URL_USERINFO.test(value)) {
|
|
558
|
+
throw targetCredentialsError(value);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
474
562
|
export function parseHostCandidate(value: string): URL {
|
|
475
563
|
const hasScheme = /^https?:\/\//i.test(value);
|
|
476
564
|
// A bare IPv6 literal (`2001:db8::1`) has no port; URL needs it bracketed.
|
|
@@ -484,7 +572,7 @@ export function parseHostCandidate(value: string): URL {
|
|
|
484
572
|
if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
|
|
485
573
|
throw new CentrsError({
|
|
486
574
|
code: "input/invalid-target",
|
|
487
|
-
summary: `Target URL must not include a path. Received: ${value}`,
|
|
575
|
+
summary: `Target URL must not include a path. Received: ${redactTargetInput(value)}`,
|
|
488
576
|
remediation:
|
|
489
577
|
"Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
|
|
490
578
|
});
|
|
@@ -500,12 +588,12 @@ function invalidTargetError(value: string, cause: unknown): CentrsError {
|
|
|
500
588
|
const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
|
|
501
589
|
return new CentrsError({
|
|
502
590
|
code: "input/invalid-target",
|
|
503
|
-
summary: `Target is not a known device or a valid host: ${value}`,
|
|
591
|
+
summary: `Target is not a known device or a valid host: ${redactTargetInput(value)}`,
|
|
504
592
|
remediation:
|
|
505
593
|
quickchrGuess !== undefined
|
|
506
594
|
? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
|
|
507
595
|
: "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
|
|
508
|
-
context: { target: value },
|
|
596
|
+
context: { target: redactTargetInput(value) },
|
|
509
597
|
cause,
|
|
510
598
|
});
|
|
511
599
|
}
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
expandSelection,
|
|
60
60
|
isDefaultRecordTarget,
|
|
61
61
|
loadEnvFileDefaults,
|
|
62
|
+
redactTargetInput,
|
|
62
63
|
type SelectionExpansion,
|
|
63
64
|
type SelectionMember,
|
|
64
65
|
type SelectionResolveInput,
|
|
@@ -97,6 +98,8 @@ export interface RetrieveFanoutOperationMeta {
|
|
|
97
98
|
concurrency: number;
|
|
98
99
|
summary: FanoutSummary;
|
|
99
100
|
request: RetrieveRequestSummary;
|
|
101
|
+
/** Hoisted from the targets when every target used the same login (#461). */
|
|
102
|
+
auth?: { username?: string; passwordProvided: boolean };
|
|
100
103
|
selection: RetrieveSelectionSummary;
|
|
101
104
|
}
|
|
102
105
|
|
|
@@ -143,7 +146,7 @@ function summarizeSelection(
|
|
|
143
146
|
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
144
147
|
all: selection.all,
|
|
145
148
|
default: selection.default,
|
|
146
|
-
positionals: selection.positionals,
|
|
149
|
+
positionals: selection.positionals.map((value) => redactTargetInput(value)),
|
|
147
150
|
quickchr: selection.quickchr,
|
|
148
151
|
};
|
|
149
152
|
}
|
package/src/retrieve-follow.ts
CHANGED
|
@@ -28,17 +28,22 @@ import {
|
|
|
28
28
|
buildRetrieveErrorEnvelope,
|
|
29
29
|
buildRetrieveErrorEnvelopeFromResolved,
|
|
30
30
|
type ResolvedRetrieveRequest,
|
|
31
|
-
type RetrieveEnvelope,
|
|
32
31
|
type RetrieveFollowCounts,
|
|
32
|
+
type RetrieveFollowLine,
|
|
33
33
|
type RetrieveFollowStopReason,
|
|
34
34
|
type RetrieveFollowSummary,
|
|
35
35
|
type RetrieveRequest,
|
|
36
|
+
type RetrieveStreamLineEnvelope,
|
|
36
37
|
type RetrieveStreamMeta,
|
|
37
38
|
type RetrieveSuccessEnvelope,
|
|
38
39
|
resolveRetrieveRequest,
|
|
39
40
|
validateRetrieveRead,
|
|
40
41
|
} from "./retrieve.ts";
|
|
41
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
failedStreamEnvelope,
|
|
44
|
+
streamEnvelope,
|
|
45
|
+
streamLineEnvelope,
|
|
46
|
+
} from "./retrieve-stream.ts";
|
|
42
47
|
|
|
43
48
|
/** Changes allowed to wait unread before the follow ends (`transport/stream-overflow`). */
|
|
44
49
|
export const FOLLOW_BUFFER_LIMIT = 50_000;
|
|
@@ -52,7 +57,8 @@ export interface RetrieveFollowOptions {
|
|
|
52
57
|
bufferLimit?: number;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
/** `at`: when the reply arrived (host clock), stamped by `push` in the wire tap. */
|
|
61
|
+
type FollowEvent = { at?: number } & (
|
|
56
62
|
| { type: "listen"; reply: ProtocolTappedReply; version?: number }
|
|
57
63
|
| { type: "listen-end" }
|
|
58
64
|
| { type: "snapshot-row"; row: Record<string, string> }
|
|
@@ -65,7 +71,8 @@ type FollowEvent =
|
|
|
65
71
|
rows: Map<string, Record<string, string>>;
|
|
66
72
|
}
|
|
67
73
|
| { type: "stop" }
|
|
68
|
-
| { type: "error"; error: unknown }
|
|
74
|
+
| { type: "error"; error: unknown }
|
|
75
|
+
);
|
|
69
76
|
|
|
70
77
|
/**
|
|
71
78
|
* The wire-ordered event queue: every source feeds it from a synchronous
|
|
@@ -144,8 +151,8 @@ class FollowQueue {
|
|
|
144
151
|
type FrameMeta = Extract<RetrieveStreamMeta, { kind: "frame" }>;
|
|
145
152
|
|
|
146
153
|
/**
|
|
147
|
-
* Follow one menu: yields
|
|
148
|
-
* live frames, then exactly one summary (successful, or an error envelope
|
|
154
|
+
* Follow one menu: yields `started` (the full meta and any advice), the
|
|
155
|
+
* snapshot frames, `synced`, live frames, then exactly one summary (successful, or an error envelope
|
|
149
156
|
* carrying the partial counts). Errors before the follow starts (resolution,
|
|
150
157
|
* validation, a non-followable menu, a non-native transport) yield one error
|
|
151
158
|
* envelope without a summary.
|
|
@@ -154,7 +161,7 @@ export async function* retrieveFollow(
|
|
|
154
161
|
request: RetrieveRequest,
|
|
155
162
|
env: Record<string, string | undefined> = Bun.env,
|
|
156
163
|
options: RetrieveFollowOptions = {},
|
|
157
|
-
): AsyncGenerator<
|
|
164
|
+
): AsyncGenerator<RetrieveFollowLine, void, void> {
|
|
158
165
|
const followRequest: RetrieveRequest = { ...request, follow: true };
|
|
159
166
|
let resolved: ResolvedRetrieveRequest;
|
|
160
167
|
try {
|
|
@@ -269,7 +276,7 @@ async function* runFollow(
|
|
|
269
276
|
options: RetrieveFollowOptions,
|
|
270
277
|
startedAt: number,
|
|
271
278
|
query?: readonly string[],
|
|
272
|
-
): AsyncGenerator<
|
|
279
|
+
): AsyncGenerator<RetrieveFollowLine, void, void> {
|
|
273
280
|
const follow = resolved.follow ?? { sweepMs: 0 };
|
|
274
281
|
const bufferLimit = options.bufferLimit ?? FOLLOW_BUFFER_LIMIT;
|
|
275
282
|
const counts: RetrieveFollowCounts = {
|
|
@@ -322,7 +329,7 @@ async function* runFollow(
|
|
|
322
329
|
controller.abort();
|
|
323
330
|
return;
|
|
324
331
|
}
|
|
325
|
-
queue.push(event);
|
|
332
|
+
queue.push({ ...event, at: Date.now() });
|
|
326
333
|
};
|
|
327
334
|
const stop = (reason: RetrieveFollowStopReason): void => {
|
|
328
335
|
stopReason ??= reason;
|
|
@@ -529,14 +536,16 @@ async function* runFollow(
|
|
|
529
536
|
id: string,
|
|
530
537
|
source: FrameMeta["source"],
|
|
531
538
|
row: Record<string, string> | null,
|
|
532
|
-
|
|
539
|
+
at: number | undefined,
|
|
540
|
+
): RetrieveStreamLineEnvelope => {
|
|
533
541
|
counts.frames += 1;
|
|
534
542
|
if (phase === "snapshot") counts.snapshot += 1;
|
|
535
543
|
else counts.changes += 1;
|
|
536
544
|
const data = row === null ? null : projectRow(row, resolved.attributes);
|
|
537
|
-
return
|
|
545
|
+
return streamLineEnvelope(data, {
|
|
538
546
|
kind: "frame",
|
|
539
547
|
index: counts.frames,
|
|
548
|
+
observedAt: new Date(at ?? Date.now()).toISOString(),
|
|
540
549
|
phase,
|
|
541
550
|
change,
|
|
542
551
|
id,
|
|
@@ -547,7 +556,7 @@ async function* runFollow(
|
|
|
547
556
|
const applyListen = (
|
|
548
557
|
event: Extract<FollowEvent, { type: "listen" }>,
|
|
549
558
|
phase: FrameMeta["phase"],
|
|
550
|
-
):
|
|
559
|
+
): RetrieveStreamLineEnvelope | undefined => {
|
|
551
560
|
const { reply } = event;
|
|
552
561
|
// `!empty` on a listen is the cancel acknowledgement, never a table state.
|
|
553
562
|
if (reply.type === "empty") return undefined;
|
|
@@ -568,10 +577,10 @@ async function* runFollow(
|
|
|
568
577
|
if (reply.attributes[".dead"] === "true") {
|
|
569
578
|
// A `.dead` for an id never reported is a coalesced add+delete.
|
|
570
579
|
if (!held.delete(id)) return undefined;
|
|
571
|
-
return frame(phase, "removed", id, "listen", null);
|
|
580
|
+
return frame(phase, "removed", id, "listen", null, event.at);
|
|
572
581
|
}
|
|
573
582
|
held.add(id);
|
|
574
|
-
return frame(phase, "upsert", id, "listen", reply.attributes);
|
|
583
|
+
return frame(phase, "upsert", id, "listen", reply.attributes, event.at);
|
|
575
584
|
};
|
|
576
585
|
/**
|
|
577
586
|
* Between frames of one batch (the A1 replay, a sweep's removals): a stop
|
|
@@ -586,15 +595,7 @@ async function* runFollow(
|
|
|
586
595
|
follow.count !== undefined && counts.changes >= follow.count;
|
|
587
596
|
|
|
588
597
|
try {
|
|
589
|
-
|
|
590
|
-
yield streamEnvelope(
|
|
591
|
-
resolved,
|
|
592
|
-
validation,
|
|
593
|
-
null,
|
|
594
|
-
{ kind: "notice" },
|
|
595
|
-
tips,
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
+
yield streamEnvelope(resolved, validation, null, { kind: "started" }, tips);
|
|
598
599
|
loop: for (;;) {
|
|
599
600
|
const event = await queue.next();
|
|
600
601
|
switch (event.type) {
|
|
@@ -620,7 +621,7 @@ async function* runFollow(
|
|
|
620
621
|
const id = event.row[".id"];
|
|
621
622
|
if (id !== undefined) {
|
|
622
623
|
held.add(id);
|
|
623
|
-
yield frame("snapshot", "upsert", id, "print", event.row);
|
|
624
|
+
yield frame("snapshot", "upsert", id, "print", event.row, event.at);
|
|
624
625
|
}
|
|
625
626
|
break;
|
|
626
627
|
}
|
|
@@ -660,9 +661,9 @@ async function* runFollow(
|
|
|
660
661
|
const phase = counts.synced ? "live" : "snapshot";
|
|
661
662
|
if (row) {
|
|
662
663
|
held.add(id);
|
|
663
|
-
yield frame(phase, "upsert", id, "membership", row);
|
|
664
|
+
yield frame(phase, "upsert", id, "membership", row, event.at);
|
|
664
665
|
} else if (held.delete(id)) {
|
|
665
|
-
yield frame(phase, "removed", id, "membership", null);
|
|
666
|
+
yield frame(phase, "removed", id, "membership", null, event.at);
|
|
666
667
|
}
|
|
667
668
|
if (counts.synced && countReached()) {
|
|
668
669
|
stopReason ??= "count-reached";
|
|
@@ -688,13 +689,8 @@ async function* runFollow(
|
|
|
688
689
|
continue;
|
|
689
690
|
if (haltRequested()) break loop;
|
|
690
691
|
held.delete(id);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
"removed",
|
|
694
|
-
id,
|
|
695
|
-
query ? "membership" : "sweep",
|
|
696
|
-
null,
|
|
697
|
-
);
|
|
692
|
+
// The sweep found it gone, filtered or not (#462).
|
|
693
|
+
yield frame("live", "removed", id, "sweep", null, event.at);
|
|
698
694
|
if (countReached()) {
|
|
699
695
|
stopReason ??= "count-reached";
|
|
700
696
|
break loop;
|