@tikoci/centrs 0.1.9-next.27 → 0.1.9
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 +64 -6
- package/README.md +15 -13
- package/commands/devices/README.md +89 -28
- package/commands/devices/examples.md +112 -1
- package/commands/execute/README.md +2 -2
- package/commands/retrieve/README.md +2 -2
- package/commands/settings/README.md +6 -0
- package/commands/settings/examples.md +17 -0
- package/docs/CLI.md +43 -9
- package/docs/errors/input/empty-stdin.md +27 -0
- package/docs/errors/input/invalid-stdin.md +28 -0
- package/docs/errors/usage/stdin-is-tty.md +21 -0
- package/package.json +4 -4
- package/src/cli/api.ts +1 -1
- package/src/cli/common.ts +1 -0
- package/src/cli/devices.ts +189 -24
- package/src/cli/execute.ts +3 -3
- package/src/cli/settings.ts +28 -6
- package/src/core/error-catalog.ts +15 -0
- package/src/devices.ts +224 -24
- package/src/index.ts +1 -0
- package/src/mcp/safety.ts +7 -6
- package/src/mcp/tools.ts +30 -68
- package/src/resolver/cdb.ts +9 -8
- package/src/resolver/selection.ts +4 -4
- package/src/settings.ts +23 -8
package/src/devices.ts
CHANGED
|
@@ -94,8 +94,16 @@ export interface DevicesListItem {
|
|
|
94
94
|
location?: DeviceLocation;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A CDB record as `devices show` / `add` / `set` return it. The record password
|
|
99
|
+
* is withheld by default (#551): `passwordSet` says whether one is stored, and
|
|
100
|
+
* only {@link DevicesShowItemWithPassword} (the `showPassword: true` opt-in)
|
|
101
|
+
* carries the value. Not to be confused with `savedPassword`, the WinBox
|
|
102
|
+
* "keep password" flag.
|
|
103
|
+
*/
|
|
97
104
|
export interface DevicesShowItem extends DevicesListItem {
|
|
98
|
-
password
|
|
105
|
+
/** True when the record stores a non-empty password. Always present. */
|
|
106
|
+
passwordSet: boolean;
|
|
99
107
|
comment: string;
|
|
100
108
|
commentMirror: string;
|
|
101
109
|
profile: string;
|
|
@@ -105,6 +113,11 @@ export interface DevicesShowItem extends DevicesListItem {
|
|
|
105
113
|
flags: number;
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/** {@link DevicesShowItem} plus the cleartext record password (`showPassword: true`). */
|
|
117
|
+
export interface DevicesShowItemWithPassword extends DevicesShowItem {
|
|
118
|
+
password: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
export interface DevicesGroupSummary {
|
|
109
122
|
group: string;
|
|
110
123
|
members: number;
|
|
@@ -614,10 +627,19 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
|
614
627
|
for (const entry of entries) {
|
|
615
628
|
const tags = unknownTcodeTags(entry.record);
|
|
616
629
|
if (tags.length > 0) {
|
|
630
|
+
// `passwordSet` reports the password centrs decoded and would dial
|
|
631
|
+
// with. When the undecoded tail may hold one, say so here rather than
|
|
632
|
+
// let `passwordSet: false` read as certain.
|
|
633
|
+
const mayContainPassword =
|
|
634
|
+
passwordBearingRawTailIndex(entry.record) !== -1;
|
|
617
635
|
warnings.push({
|
|
618
636
|
code: "cdb/unknown-field",
|
|
619
|
-
message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim
|
|
620
|
-
context: {
|
|
637
|
+
message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.${mayContainPassword ? " The undecoded bytes may contain the record password, so passwordSet may under-report it." : ""}`,
|
|
638
|
+
context: {
|
|
639
|
+
target: entry.target,
|
|
640
|
+
tags,
|
|
641
|
+
...(mayContainPassword ? { mayContainPassword: true } : {}),
|
|
642
|
+
},
|
|
621
643
|
});
|
|
622
644
|
}
|
|
623
645
|
}
|
|
@@ -794,13 +816,24 @@ export interface ShowDeviceArgs {
|
|
|
794
816
|
*/
|
|
795
817
|
match?: string;
|
|
796
818
|
env?: Record<string, string | undefined>;
|
|
819
|
+
/**
|
|
820
|
+
* Return the record password in cleartext (`--show-password`). Default
|
|
821
|
+
* `false`: the entry carries `passwordSet` and no `password`, and the
|
|
822
|
+
* `explain` record has its password field value replaced.
|
|
823
|
+
*/
|
|
824
|
+
showPassword?: boolean;
|
|
797
825
|
}
|
|
798
826
|
|
|
799
|
-
export interface DevicesShowEnvelopeData
|
|
800
|
-
|
|
827
|
+
export interface DevicesShowEnvelopeData<
|
|
828
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
829
|
+
> {
|
|
830
|
+
entry: Entry;
|
|
801
831
|
record?: WinBoxCdbRecord;
|
|
802
832
|
}
|
|
803
833
|
|
|
834
|
+
/** Placeholder written over a withheld password in an `explain` record. */
|
|
835
|
+
export const REDACTED_PASSWORD_VALUE = "<redacted>";
|
|
836
|
+
|
|
804
837
|
/**
|
|
805
838
|
* Canonical form for `<router>` comparison: a MAC is normalized (case /
|
|
806
839
|
* separator / zero-pad insensitive via {@link normalizeMac}); everything else is
|
|
@@ -931,28 +964,150 @@ function describeMatches(matches: readonly EntryMatch[]): {
|
|
|
931
964
|
}));
|
|
932
965
|
}
|
|
933
966
|
|
|
967
|
+
/**
|
|
968
|
+
* The raw CDB record `<router>` resolves to, password included. INTERNAL: for
|
|
969
|
+
* callers that dial or gate on the record (the resolver, the MCP allowlist),
|
|
970
|
+
* never for output. It is deliberately absent from `src/index.ts`; the public
|
|
971
|
+
* {@link showDevice} envelope withholds the password by default (#551).
|
|
972
|
+
*/
|
|
973
|
+
export interface DeviceRecordLookup {
|
|
974
|
+
entry: WinBoxCdbEntry;
|
|
975
|
+
cdbRecordIndex: number;
|
|
976
|
+
/** CDB load warnings plus the record's comment-kv / `via` warnings. */
|
|
977
|
+
warnings: DevicesWarning[];
|
|
978
|
+
tips: Tip[];
|
|
979
|
+
target: EnvelopeTargetMeta;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Resolve `<router>` to its raw CDB record (see {@link DeviceRecordLookup}).
|
|
984
|
+
* Same matcher, `--match` selection and errors as {@link showDevice}.
|
|
985
|
+
*/
|
|
986
|
+
export function lookupDeviceRecord(
|
|
987
|
+
args: Omit<ShowDeviceArgs, "explain" | "showPassword">,
|
|
988
|
+
): DeviceRecordLookup {
|
|
989
|
+
const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
|
|
990
|
+
const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
|
|
991
|
+
return {
|
|
992
|
+
entry: match.entry,
|
|
993
|
+
cdbRecordIndex: match.index,
|
|
994
|
+
warnings: [...args.cdb.warnings, ...resolved.warnings],
|
|
995
|
+
tips: credentialsMissingTips(match.entry, args.cdb.entries),
|
|
996
|
+
target: resolved.target,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function showDevice(
|
|
1001
|
+
args: ShowDeviceArgs & { showPassword: true },
|
|
1002
|
+
): CentrsSuccessEnvelope<
|
|
1003
|
+
DevicesShowEnvelopeData<DevicesShowItemWithPassword>,
|
|
1004
|
+
DevicesOperationMeta
|
|
1005
|
+
>;
|
|
1006
|
+
export function showDevice(
|
|
1007
|
+
args: ShowDeviceArgs,
|
|
1008
|
+
): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta>;
|
|
934
1009
|
export function showDevice(
|
|
935
1010
|
args: ShowDeviceArgs,
|
|
936
1011
|
): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta> {
|
|
937
|
-
const
|
|
1012
|
+
const found = lookupDeviceRecord(args);
|
|
1013
|
+
const showPassword = args.showPassword === true;
|
|
938
1014
|
const data: DevicesShowEnvelopeData = {
|
|
939
|
-
entry:
|
|
1015
|
+
entry: projectShowItem(
|
|
1016
|
+
entryToShowItem(found.entry, found.cdbRecordIndex),
|
|
1017
|
+
showPassword,
|
|
1018
|
+
),
|
|
940
1019
|
};
|
|
941
1020
|
if (args.explain) {
|
|
942
|
-
data.record =
|
|
1021
|
+
data.record = showPassword
|
|
1022
|
+
? found.entry.record
|
|
1023
|
+
: redactRecordPassword(found.entry.record);
|
|
943
1024
|
}
|
|
944
1025
|
|
|
945
|
-
const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
|
|
946
|
-
|
|
947
1026
|
return {
|
|
948
1027
|
ok: true,
|
|
949
1028
|
data,
|
|
950
|
-
warnings:
|
|
951
|
-
tips:
|
|
952
|
-
meta: devicesMeta("show", args.cdb.settings,
|
|
1029
|
+
warnings: found.warnings,
|
|
1030
|
+
tips: found.tips,
|
|
1031
|
+
meta: devicesMeta("show", args.cdb.settings, found.target),
|
|
953
1032
|
};
|
|
954
1033
|
}
|
|
955
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* The shared output redaction for a device entry (#551): drop `password`, keep
|
|
1037
|
+
* `passwordSet`. The CLI, the library envelopes and MCP all return entries
|
|
1038
|
+
* through this, so they withhold the secret the same way.
|
|
1039
|
+
*/
|
|
1040
|
+
export function redactDeviceEntry(
|
|
1041
|
+
entry: DevicesShowItemWithPassword,
|
|
1042
|
+
): DevicesShowItem {
|
|
1043
|
+
const { password, ...rest } = entry;
|
|
1044
|
+
return { ...rest, passwordSet: password.length > 0 };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function projectShowItem(
|
|
1048
|
+
entry: DevicesShowItemWithPassword,
|
|
1049
|
+
showPassword: boolean,
|
|
1050
|
+
): DevicesShowItem {
|
|
1051
|
+
return showPassword ? entry : redactDeviceEntry(entry);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Replace the password field value in an `explain` record. The record is a
|
|
1056
|
+
* debugging view of the CDB bytes; it must not become a side door around the
|
|
1057
|
+
* entry redaction.
|
|
1058
|
+
*/
|
|
1059
|
+
function redactRecordPassword(record: WinBoxCdbRecord): WinBoxCdbRecord {
|
|
1060
|
+
const tailIndex = passwordBearingRawTailIndex(record);
|
|
1061
|
+
if (tailIndex === -1 && !record.fields.some(isPasswordField)) {
|
|
1062
|
+
return record;
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
...record,
|
|
1066
|
+
fields: record.fields.map((field, index) => {
|
|
1067
|
+
if (index === tailIndex) {
|
|
1068
|
+
const length =
|
|
1069
|
+
field.value instanceof Uint8Array ? field.value.length : 0;
|
|
1070
|
+
return { ...field, value: redactedRawTailValue(length) };
|
|
1071
|
+
}
|
|
1072
|
+
return isPasswordField(field)
|
|
1073
|
+
? { ...field, value: REDACTED_PASSWORD_VALUE }
|
|
1074
|
+
: field;
|
|
1075
|
+
}),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Index of a `rawTail` field whose bytes may hold the password, or -1. The
|
|
1081
|
+
* decoder folds everything after an unknown tcode into one opaque tail, so
|
|
1082
|
+
* when no decoded password field (tag 3) precedes the tail, the password — if
|
|
1083
|
+
* the record has one — is somewhere inside those bytes (or the tail is tag 3
|
|
1084
|
+
* itself).
|
|
1085
|
+
*/
|
|
1086
|
+
function passwordBearingRawTailIndex(record: WinBoxCdbRecord): number {
|
|
1087
|
+
for (const [index, field] of record.fields.entries()) {
|
|
1088
|
+
if (field.rawTail) {
|
|
1089
|
+
return index;
|
|
1090
|
+
}
|
|
1091
|
+
if (field.tag === winBoxCdbFieldTag.password) {
|
|
1092
|
+
return -1;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return -1;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function redactedRawTailValue(length: number): string {
|
|
1099
|
+
return `<redacted: ${length} bytes, may contain password>`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function isPasswordField(field: WinBoxCdbField): boolean {
|
|
1103
|
+
return (
|
|
1104
|
+
field.tag === winBoxCdbFieldTag.password &&
|
|
1105
|
+
!field.rawTail &&
|
|
1106
|
+
typeof field.value === "string" &&
|
|
1107
|
+
field.value.length > 0
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
956
1111
|
function entryToListItem(
|
|
957
1112
|
entry: WinBoxCdbEntry,
|
|
958
1113
|
cdbRecordIndex: number,
|
|
@@ -1006,7 +1161,7 @@ interface ResolvedDeviceTargetMeta {
|
|
|
1006
1161
|
function resolveDeviceShowTargetMeta(
|
|
1007
1162
|
entry: WinBoxCdbEntry,
|
|
1008
1163
|
cdbRecordIndex: number,
|
|
1009
|
-
args: ShowDeviceArgs,
|
|
1164
|
+
args: Pick<ShowDeviceArgs, "target" | "via" | "env">,
|
|
1010
1165
|
): ResolvedDeviceTargetMeta {
|
|
1011
1166
|
const parsed = parseCommentKv(entry.comment);
|
|
1012
1167
|
const warnings: DevicesWarning[] = parsed.warnings.map((warning) => ({
|
|
@@ -1122,14 +1277,15 @@ function parseProtocolOption(
|
|
|
1122
1277
|
function entryToShowItem(
|
|
1123
1278
|
entry: WinBoxCdbEntry,
|
|
1124
1279
|
cdbRecordIndex: number,
|
|
1125
|
-
):
|
|
1126
|
-
const item:
|
|
1280
|
+
): DevicesShowItemWithPassword {
|
|
1281
|
+
const item: DevicesShowItemWithPassword = {
|
|
1127
1282
|
target: entry.target,
|
|
1128
1283
|
recordType: entry.recordType,
|
|
1129
1284
|
recordTypeName: recordTypeName(entry.recordType),
|
|
1130
1285
|
group: entry.group,
|
|
1131
1286
|
user: entry.user,
|
|
1132
1287
|
password: entry.password,
|
|
1288
|
+
passwordSet: entry.password.length > 0,
|
|
1133
1289
|
session: entry.session,
|
|
1134
1290
|
comment: entry.comment,
|
|
1135
1291
|
commentMirror: entry.commentMirror,
|
|
@@ -1200,7 +1356,9 @@ export function listGroups(
|
|
|
1200
1356
|
* resulting record (absent for `remove`); `preservedUnknownTags` lists any
|
|
1201
1357
|
* unknown/`rawTail` field tags carried over verbatim from the prior record.
|
|
1202
1358
|
*/
|
|
1203
|
-
export interface DevicesMutationData
|
|
1359
|
+
export interface DevicesMutationData<
|
|
1360
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
1361
|
+
> {
|
|
1204
1362
|
action: "add" | "edit" | "set" | "remove";
|
|
1205
1363
|
target: string;
|
|
1206
1364
|
cdbRecordIndex: number;
|
|
@@ -1209,13 +1367,13 @@ export interface DevicesMutationData {
|
|
|
1209
1367
|
backupPath?: string;
|
|
1210
1368
|
prunedBackups: readonly string[];
|
|
1211
1369
|
preservedUnknownTags?: readonly number[];
|
|
1212
|
-
|
|
1370
|
+
/** The resulting record; the password is withheld unless `showPassword`. */
|
|
1371
|
+
entry?: Entry;
|
|
1213
1372
|
}
|
|
1214
1373
|
|
|
1215
|
-
export type DevicesMutationEnvelope
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
>;
|
|
1374
|
+
export type DevicesMutationEnvelope<
|
|
1375
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
1376
|
+
> = CentrsSuccessEnvelope<DevicesMutationData<Entry>, DevicesOperationMeta>;
|
|
1219
1377
|
|
|
1220
1378
|
const KNOWN_FIELD_TAGS = new Set<number>(Object.values(winBoxCdbFieldTag));
|
|
1221
1379
|
|
|
@@ -1583,8 +1741,16 @@ export interface AddDeviceArgs {
|
|
|
1583
1741
|
force?: boolean;
|
|
1584
1742
|
strict?: boolean;
|
|
1585
1743
|
writeOptions?: WriteWinBoxCdbOptions;
|
|
1744
|
+
/** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
|
|
1745
|
+
showPassword?: boolean;
|
|
1586
1746
|
}
|
|
1587
1747
|
|
|
1748
|
+
export function addDevice(
|
|
1749
|
+
args: AddDeviceArgs & { showPassword: true },
|
|
1750
|
+
): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
|
|
1751
|
+
export function addDevice(
|
|
1752
|
+
args: AddDeviceArgs,
|
|
1753
|
+
): Promise<DevicesMutationEnvelope>;
|
|
1588
1754
|
export async function addDevice(
|
|
1589
1755
|
args: AddDeviceArgs,
|
|
1590
1756
|
): Promise<DevicesMutationEnvelope> {
|
|
@@ -1665,7 +1831,10 @@ export async function addDevice(
|
|
|
1665
1831
|
args.target,
|
|
1666
1832
|
cdbRecordIndex,
|
|
1667
1833
|
existing !== undefined,
|
|
1668
|
-
|
|
1834
|
+
projectShowItem(
|
|
1835
|
+
entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
|
|
1836
|
+
args.showPassword === true,
|
|
1837
|
+
),
|
|
1669
1838
|
preserved,
|
|
1670
1839
|
warnings,
|
|
1671
1840
|
);
|
|
@@ -1686,6 +1855,8 @@ export interface SetDeviceArgs {
|
|
|
1686
1855
|
savedPassword?: boolean;
|
|
1687
1856
|
strict?: boolean;
|
|
1688
1857
|
writeOptions?: WriteWinBoxCdbOptions;
|
|
1858
|
+
/** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
|
|
1859
|
+
showPassword?: boolean;
|
|
1689
1860
|
}
|
|
1690
1861
|
|
|
1691
1862
|
/**
|
|
@@ -1695,6 +1866,12 @@ export interface SetDeviceArgs {
|
|
|
1695
1866
|
* {@link requireSingleMatch}; a missing target throws `cdb/not-found-target`.
|
|
1696
1867
|
* Unchanged fields and the comment's free-form prose are preserved verbatim.
|
|
1697
1868
|
*/
|
|
1869
|
+
export function setDevice(
|
|
1870
|
+
args: SetDeviceArgs & { showPassword: true },
|
|
1871
|
+
): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
|
|
1872
|
+
export function setDevice(
|
|
1873
|
+
args: SetDeviceArgs,
|
|
1874
|
+
): Promise<DevicesMutationEnvelope>;
|
|
1698
1875
|
export async function setDevice(
|
|
1699
1876
|
args: SetDeviceArgs,
|
|
1700
1877
|
): Promise<DevicesMutationEnvelope> {
|
|
@@ -1771,7 +1948,10 @@ export async function setDevice(
|
|
|
1771
1948
|
args.target,
|
|
1772
1949
|
match.index,
|
|
1773
1950
|
true,
|
|
1774
|
-
|
|
1951
|
+
projectShowItem(
|
|
1952
|
+
entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
|
|
1953
|
+
args.showPassword === true,
|
|
1954
|
+
),
|
|
1775
1955
|
preserved,
|
|
1776
1956
|
warnings,
|
|
1777
1957
|
);
|
|
@@ -2068,6 +2248,7 @@ function renderMutationText(lines: string[], data: DevicesMutationData): void {
|
|
|
2068
2248
|
}
|
|
2069
2249
|
if (data.entry) {
|
|
2070
2250
|
lines.push(`user: ${data.entry.user || "-"}`);
|
|
2251
|
+
lines.push(passwordLine(data.entry));
|
|
2071
2252
|
lines.push(`group: ${data.entry.group || "-"}`);
|
|
2072
2253
|
lines.push(`comment: ${data.entry.comment || "-"}`);
|
|
2073
2254
|
}
|
|
@@ -2104,6 +2285,7 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
|
|
|
2104
2285
|
lines.push(`record-type: ${entry.recordTypeName} (${entry.recordType})`);
|
|
2105
2286
|
lines.push(`cdb-index: ${entry.cdbRecordIndex}`);
|
|
2106
2287
|
lines.push(`user: ${entry.user || "-"}`);
|
|
2288
|
+
lines.push(passwordLine(entry));
|
|
2107
2289
|
lines.push(`saved-password: ${entry.savedPassword ? "yes" : "no"}`);
|
|
2108
2290
|
lines.push(`group: ${entry.group || "-"}`);
|
|
2109
2291
|
lines.push(`profile: ${entry.profile || "-"}`);
|
|
@@ -2129,9 +2311,27 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
|
|
|
2129
2311
|
}
|
|
2130
2312
|
}
|
|
2131
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* The `password:` text line. The cleartext value appears only when the entry
|
|
2316
|
+
* carries it (`--show-password`); otherwise it reads `(set, hidden)` or
|
|
2317
|
+
* `(not set)`.
|
|
2318
|
+
*/
|
|
2319
|
+
function passwordLine(entry: DevicesShowItem): string {
|
|
2320
|
+
const shown = (entry as Partial<DevicesShowItemWithPassword>).password;
|
|
2321
|
+
if (typeof shown === "string") {
|
|
2322
|
+
// An empty password reads `(not set)`, never `-`, which is a valid password.
|
|
2323
|
+
return `password: ${shown === "" ? "(not set)" : shown}`;
|
|
2324
|
+
}
|
|
2325
|
+
return `password: ${entry.passwordSet ? "(set, hidden)" : "(not set)"}`;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2132
2328
|
function describeField(field: WinBoxCdbField): string {
|
|
2133
2329
|
const head = `tag=${field.tag} marker=0x${field.marker.toString(16).padStart(2, "0")} tcode=0x${field.tcode.toString(16).padStart(2, "0")}`;
|
|
2134
2330
|
if (field.rawTail) {
|
|
2331
|
+
if (typeof field.value === "string") {
|
|
2332
|
+
// Withheld by `redactRecordPassword`: the tail may hold the password.
|
|
2333
|
+
return `${head} rawTail ${field.value}`;
|
|
2334
|
+
}
|
|
2135
2335
|
const bytes = field.value instanceof Uint8Array ? field.value : null;
|
|
2136
2336
|
const preview = bytes
|
|
2137
2337
|
? Array.from(bytes.subarray(0, 16))
|
package/src/index.ts
CHANGED
package/src/mcp/safety.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
-
type
|
|
14
|
+
type DeviceRecordLookup,
|
|
15
15
|
type LoadedCdb,
|
|
16
16
|
loadCdb,
|
|
17
|
-
|
|
17
|
+
lookupDeviceRecord,
|
|
18
18
|
} from "../devices.ts";
|
|
19
19
|
import { CentrsError } from "../errors.ts";
|
|
20
20
|
import type { McpWritePolicy } from "../mcp.ts";
|
|
@@ -63,7 +63,7 @@ export async function loadAllowlistCdb(
|
|
|
63
63
|
* MCP-specific framing of "this device is not on the allowlist", pointing at
|
|
64
64
|
* `centrs_devices add` rather than leaking the CLI's `cdb/not-found-target`.
|
|
65
65
|
*
|
|
66
|
-
* Matches with {@link
|
|
66
|
+
* Matches with {@link lookupDeviceRecord}, the matcher the core resolver dials with
|
|
67
67
|
* (target, `identity=`, `mac=`, `ip=`), so the record whose write policy is
|
|
68
68
|
* checked here is the record the call then runs against (#444). A name that
|
|
69
69
|
* matches more than one record fails `identity/ambiguous` rather than taking
|
|
@@ -75,9 +75,9 @@ export function resolveRegisteredTarget(
|
|
|
75
75
|
): RegisteredTarget {
|
|
76
76
|
// Before the lookup, whose refusal echoes the target (#478).
|
|
77
77
|
assertNoTargetPassword(target);
|
|
78
|
-
let
|
|
78
|
+
let found: DeviceRecordLookup;
|
|
79
79
|
try {
|
|
80
|
-
|
|
80
|
+
found = lookupDeviceRecord({ cdb, target });
|
|
81
81
|
} catch (error) {
|
|
82
82
|
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
83
83
|
if (redactTargetInput(target) !== target) {
|
|
@@ -105,11 +105,12 @@ export function resolveRegisteredTarget(
|
|
|
105
105
|
}
|
|
106
106
|
throw error;
|
|
107
107
|
}
|
|
108
|
+
const { entry } = found;
|
|
108
109
|
return {
|
|
109
110
|
target: entry.target,
|
|
110
111
|
user: entry.user,
|
|
111
112
|
group: entry.group,
|
|
112
|
-
recordIndex:
|
|
113
|
+
recordIndex: found.cdbRecordIndex,
|
|
113
114
|
writePolicy: readWritePolicy(entry.comment),
|
|
114
115
|
};
|
|
115
116
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -63,8 +63,6 @@ export interface McpOperationMeta {
|
|
|
63
63
|
*/
|
|
64
64
|
type McpEnvelope = CentrsEnvelope;
|
|
65
65
|
|
|
66
|
-
type EnvelopeWithData = CentrsEnvelope<unknown> & { data?: unknown };
|
|
67
|
-
|
|
68
66
|
/**
|
|
69
67
|
* Build a minimal centrs error envelope for failures that happen in the MCP
|
|
70
68
|
* adapter itself (allowlist rejections, policy denials) — before or instead of
|
|
@@ -111,39 +109,6 @@ function requireConfirm(tool: string, confirmed: boolean | undefined): void {
|
|
|
111
109
|
}
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
function redactEntry(entry: unknown): unknown {
|
|
115
|
-
if (typeof entry !== "object" || entry === null) {
|
|
116
|
-
return entry;
|
|
117
|
-
}
|
|
118
|
-
const { password, ...rest } = entry as Record<string, unknown>;
|
|
119
|
-
return {
|
|
120
|
-
...rest,
|
|
121
|
-
passwordSet: typeof password === "string" ? password.length > 0 : undefined,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function redactDeviceSecrets(envelope: McpEnvelope): McpEnvelope {
|
|
126
|
-
if (!envelope.ok) {
|
|
127
|
-
return envelope;
|
|
128
|
-
}
|
|
129
|
-
const withData = envelope as EnvelopeWithData;
|
|
130
|
-
const data = withData.data;
|
|
131
|
-
if (typeof data !== "object" || data === null) {
|
|
132
|
-
return envelope;
|
|
133
|
-
}
|
|
134
|
-
const deviceData = data as { entry?: unknown };
|
|
135
|
-
if (deviceData.entry === undefined) {
|
|
136
|
-
return envelope;
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
...envelope,
|
|
140
|
-
data: {
|
|
141
|
-
...data,
|
|
142
|
-
entry: redactEntry(deviceData.entry),
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
112
|
// --- centrs_explain ---------------------------------------------------------
|
|
148
113
|
|
|
149
114
|
export const explainInputShape = {
|
|
@@ -630,6 +595,9 @@ export async function handleDevices(
|
|
|
630
595
|
context: { op: args.op },
|
|
631
596
|
});
|
|
632
597
|
}
|
|
598
|
+
// `show`/`add`/`set` entries come back with the password withheld and
|
|
599
|
+
// `passwordSet` in its place: core redacts by default and MCP never
|
|
600
|
+
// passes `showPassword` (#551).
|
|
633
601
|
switch (args.op) {
|
|
634
602
|
case "list":
|
|
635
603
|
return listDevices({
|
|
@@ -643,29 +611,25 @@ export async function handleDevices(
|
|
|
643
611
|
return listGroups({ cdb, withMembers: true });
|
|
644
612
|
case "show": {
|
|
645
613
|
const target = requireDevicesTarget(args, "show");
|
|
646
|
-
return
|
|
647
|
-
showDevice({ cdb, target, env: config.env }),
|
|
648
|
-
);
|
|
614
|
+
return showDevice({ cdb, target, env: config.env });
|
|
649
615
|
}
|
|
650
616
|
case "add": {
|
|
651
617
|
requireConfirm("centrs_devices op=add", args.confirm);
|
|
652
618
|
const target = requireDevicesTarget(args, "add");
|
|
653
|
-
return
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}),
|
|
668
|
-
);
|
|
619
|
+
return await addDevice({
|
|
620
|
+
cdb,
|
|
621
|
+
target,
|
|
622
|
+
recordType: resolveRecordType(args.recordType),
|
|
623
|
+
user: args.user,
|
|
624
|
+
password: args.password,
|
|
625
|
+
group: args.group,
|
|
626
|
+
profile: args.profile,
|
|
627
|
+
session: args.session,
|
|
628
|
+
comment: args.comment,
|
|
629
|
+
commentKvUpdates: buildGeoUpdates(args),
|
|
630
|
+
savedPassword: args.savedPassword,
|
|
631
|
+
force: args.force,
|
|
632
|
+
});
|
|
669
633
|
}
|
|
670
634
|
case "edit":
|
|
671
635
|
// The interactive editor has no MCP analogue; modify via op=set.
|
|
@@ -673,20 +637,18 @@ export async function handleDevices(
|
|
|
673
637
|
case "set": {
|
|
674
638
|
requireConfirm("centrs_devices op=set", args.confirm);
|
|
675
639
|
const target = requireDevicesTarget(args, "set");
|
|
676
|
-
return
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}),
|
|
689
|
-
);
|
|
640
|
+
return await setDevice({
|
|
641
|
+
cdb,
|
|
642
|
+
target,
|
|
643
|
+
updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
|
|
644
|
+
user: args.user,
|
|
645
|
+
password: args.password,
|
|
646
|
+
group: args.group,
|
|
647
|
+
profile: args.profile,
|
|
648
|
+
session: args.session,
|
|
649
|
+
savedPassword: args.savedPassword,
|
|
650
|
+
strict: args.strict,
|
|
651
|
+
});
|
|
690
652
|
}
|
|
691
653
|
case "remove": {
|
|
692
654
|
requireConfirm("centrs_devices op=remove", args.confirm);
|
package/src/resolver/cdb.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This is the transport-agnostic seam every command (`retrieve`, and later
|
|
5
5
|
* `execute` / `devices`) shares for turning a `<router>` argument into a
|
|
6
6
|
* concrete target plus per-device option overrides. It reuses the devices CDB
|
|
7
|
-
* primitives (`loadCdb` / `
|
|
7
|
+
* primitives (`loadCdb` / `lookupDeviceRecord`) so decode/decrypt logic lives in one
|
|
8
8
|
* place, then layers the comment kv-soup contract on top.
|
|
9
9
|
*
|
|
10
10
|
* Comment-kv values arrive as RAW strings from {@link parseCommentKv}. This
|
|
@@ -32,8 +32,8 @@ import {
|
|
|
32
32
|
type DevicesWarning,
|
|
33
33
|
type LoadedCdb,
|
|
34
34
|
loadCdb,
|
|
35
|
+
lookupDeviceRecord,
|
|
35
36
|
resolveDevicesSettings,
|
|
36
|
-
showDevice,
|
|
37
37
|
} from "../devices.ts";
|
|
38
38
|
import { CentrsError } from "../errors.ts";
|
|
39
39
|
import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
|
|
@@ -256,17 +256,18 @@ export async function resolveCdb(
|
|
|
256
256
|
const defaults = findDefaultCreds(cdb);
|
|
257
257
|
|
|
258
258
|
try {
|
|
259
|
-
|
|
259
|
+
// The raw record, not the `showDevice` envelope: that envelope withholds
|
|
260
|
+
// the password (#551), and dialing needs the stored credential.
|
|
261
|
+
const found = lookupDeviceRecord({
|
|
260
262
|
cdb,
|
|
261
263
|
target: input.targetInput,
|
|
262
264
|
match: input.match,
|
|
263
265
|
});
|
|
264
|
-
const entry =
|
|
265
|
-
const warnings: ResolverWarning[] =
|
|
266
|
-
envelope.warnings.map(warningFromDevices);
|
|
266
|
+
const entry = found.entry;
|
|
267
|
+
const warnings: ResolverWarning[] = found.warnings.map(warningFromDevices);
|
|
267
268
|
const overrides = coerceCommentKv(
|
|
268
269
|
entry.comment,
|
|
269
|
-
|
|
270
|
+
found.cdbRecordIndex,
|
|
270
271
|
warnings,
|
|
271
272
|
);
|
|
272
273
|
return {
|
|
@@ -275,7 +276,7 @@ export async function resolveCdb(
|
|
|
275
276
|
mac: macFromComment(entry.comment, entry.target),
|
|
276
277
|
username: entry.user,
|
|
277
278
|
password: entry.password,
|
|
278
|
-
recordIndex:
|
|
279
|
+
recordIndex: found.cdbRecordIndex,
|
|
279
280
|
overrides,
|
|
280
281
|
warnings,
|
|
281
282
|
...(defaults ? { defaults } : {}),
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Boundaries this module is careful about:
|
|
13
13
|
* - The CDB is loaded + decrypted ONCE for the whole expansion.
|
|
14
14
|
* - A positional is matched against the already-loaded entries with
|
|
15
|
-
* {@link
|
|
15
|
+
* {@link lookupDeviceRecord} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
|
|
16
16
|
* re-loads the CDB and, when a `__default__` record exists, returns a
|
|
17
17
|
* synthetic resolution for ANY unmatched target, which would collide two
|
|
18
18
|
* distinct ad-hoc literals on the default record's index. A non-matching
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
import {
|
|
31
31
|
type LoadedCdb,
|
|
32
32
|
loadCdb,
|
|
33
|
+
lookupDeviceRecord,
|
|
33
34
|
resolveDevicesSettings,
|
|
34
|
-
showDevice,
|
|
35
35
|
} from "../devices.ts";
|
|
36
36
|
import { CentrsError } from "../errors.ts";
|
|
37
37
|
import {
|
|
@@ -282,8 +282,8 @@ export async function expandSelection(
|
|
|
282
282
|
for (const positional of selection.positionals) {
|
|
283
283
|
assertNoTargetPassword(positional);
|
|
284
284
|
try {
|
|
285
|
-
const shown =
|
|
286
|
-
indices.add(shown.
|
|
285
|
+
const shown = lookupDeviceRecord({ cdb, target: positional });
|
|
286
|
+
indices.add(shown.cdbRecordIndex);
|
|
287
287
|
} catch (error) {
|
|
288
288
|
// Preserve registered identities; mask credential-shaped lookup failures.
|
|
289
289
|
if (redactTargetInput(positional) !== positional) {
|