@tikoci/centrs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
package/src/discover.ts
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centrs discover` core: the MNDP UDP listener plus the optional `--save` path
|
|
3
|
+
* that persists discovered neighbors into the CDB.
|
|
4
|
+
*
|
|
5
|
+
* The wire codec ({@link parseMndpPacket}) and the cache ({@link MndpCache})
|
|
6
|
+
* are pure and live in `src/data/`. This module is the thin, separately-tested
|
|
7
|
+
* IO seam: it binds a UDP socket, decodes each datagram into the cache for a
|
|
8
|
+
* `timeout` window, then returns a canonical {@link CentrsEnvelope}. The socket
|
|
9
|
+
* layer ({@link listenMndp}) is injectable so {@link discover} can be unit
|
|
10
|
+
* tested without a router, and the listener itself is tested by sending crafted
|
|
11
|
+
* packets to the bound port over loopback.
|
|
12
|
+
*
|
|
13
|
+
* `--save` reuses WP-2a's {@link addDevice}: discovered records are written
|
|
14
|
+
* through the atomic CDB writer with provenance in the comment kv-soup
|
|
15
|
+
* (`source=mndp`) and `group=discovered`. Encrypted CDBs round-trip through
|
|
16
|
+
* the write layer's `encryptWith` option (the password loaded from settings)
|
|
17
|
+
* so discovered neighbors land on disk re-encrypted under the same secret.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createSocket } from "node:dgram";
|
|
21
|
+
import type { CentrsEnvelope, EnvelopeMeta } from "./core/envelope.ts";
|
|
22
|
+
import {
|
|
23
|
+
MNDP_BROADCAST_ADDRESS,
|
|
24
|
+
MNDP_PORT,
|
|
25
|
+
type MndpNeighbor,
|
|
26
|
+
mndpRefreshPacket,
|
|
27
|
+
parseMndpPacket,
|
|
28
|
+
} from "./data/mndp.ts";
|
|
29
|
+
import { MndpCache } from "./data/mndp-cache.ts";
|
|
30
|
+
import { winBoxCdbRecordType } from "./data/winbox-cdb.ts";
|
|
31
|
+
import type { WriteWinBoxCdbOptions } from "./data/winbox-cdb-write.ts";
|
|
32
|
+
import {
|
|
33
|
+
addDevice,
|
|
34
|
+
type DevicesWarning,
|
|
35
|
+
type LoadCdbOptions,
|
|
36
|
+
loadCdb,
|
|
37
|
+
resolveDevicesSettings,
|
|
38
|
+
} from "./devices.ts";
|
|
39
|
+
import { asCentrsError, CentrsError, serializeCentrsError } from "./errors.ts";
|
|
40
|
+
import { renderCommentKvToken } from "./resolver/comment-kv.ts";
|
|
41
|
+
|
|
42
|
+
export { MNDP_PORT, MndpCache };
|
|
43
|
+
|
|
44
|
+
/** Default listen window when `--timeout` is omitted. */
|
|
45
|
+
export const DISCOVER_DEFAULT_TIMEOUT_MS = 15_000;
|
|
46
|
+
/** Default refresh-broadcast cadence inside the listen window. */
|
|
47
|
+
export const DISCOVER_DEFAULT_REFRESH_INTERVAL_MS = 5_000;
|
|
48
|
+
/** Default CDB group assigned to saved neighbors. */
|
|
49
|
+
export const DISCOVER_DEFAULT_GROUP = "discovered";
|
|
50
|
+
|
|
51
|
+
export interface ListenMndpOptions {
|
|
52
|
+
/** Listen window in ms before the listener resolves. Default 15000. */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
/** UDP port to bind. Default 5678; pass `0` for an ephemeral test port. */
|
|
55
|
+
port?: number;
|
|
56
|
+
/** Bind address. Default `0.0.0.0`; tests use `127.0.0.1`. */
|
|
57
|
+
host?: string;
|
|
58
|
+
/** Cache TTL in ms, forwarded to a freshly created {@link MndpCache}. */
|
|
59
|
+
ttlMs?: number;
|
|
60
|
+
/** Cache to populate; a new one is created when omitted. */
|
|
61
|
+
cache?: MndpCache;
|
|
62
|
+
/** Clock injection forwarded to a freshly created {@link MndpCache}. */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
/** Send the broadcast refresh that prompts replies. Default true. */
|
|
65
|
+
sendRefresh?: boolean;
|
|
66
|
+
/** Refresh cadence in ms; `0` sends a single refresh. Default 5000. */
|
|
67
|
+
refreshIntervalMs?: number;
|
|
68
|
+
/** Enable `SO_REUSEADDR`. Default true. */
|
|
69
|
+
reuseAddr?: boolean;
|
|
70
|
+
/** Enable `SO_REUSEPORT` for coexistence with other MNDP listeners. Default true. */
|
|
71
|
+
reusePort?: boolean;
|
|
72
|
+
/** Called once with the actually-bound port (useful with `port: 0`). */
|
|
73
|
+
onBound?: (port: number) => void;
|
|
74
|
+
/** Abort the listen early; resolves with whatever was collected so far. */
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ListenMndpResult {
|
|
79
|
+
/** Cache holding the decoded neighbors. */
|
|
80
|
+
cache: MndpCache;
|
|
81
|
+
/** Port the listener actually bound. */
|
|
82
|
+
port: number;
|
|
83
|
+
/** Datagrams received on the socket. */
|
|
84
|
+
packetsReceived: number;
|
|
85
|
+
/** Datagrams decoded into a neighbor with a MAC. */
|
|
86
|
+
packetsDecoded: number;
|
|
87
|
+
/** Datagrams ignored (self-echo, MAC-less, or malformed). */
|
|
88
|
+
packetsRejected: number;
|
|
89
|
+
/** Non-fatal anomalies observed during the listen (malformed, broadcast). */
|
|
90
|
+
warnings: readonly DevicesWarning[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function errorCodeOf(error: unknown): string | undefined {
|
|
94
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
95
|
+
return String((error as { code: unknown }).code);
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function listenError(error: unknown, port: number, host: string): CentrsError {
|
|
101
|
+
const code =
|
|
102
|
+
error && typeof error === "object" && "code" in error
|
|
103
|
+
? String((error as { code: unknown }).code)
|
|
104
|
+
: undefined;
|
|
105
|
+
if (code === "EADDRINUSE") {
|
|
106
|
+
return new CentrsError({
|
|
107
|
+
code: "mndp/listen-failed",
|
|
108
|
+
summary: `UDP port ${port} on ${host} is already in use; cannot bind the MNDP listener.`,
|
|
109
|
+
remediation:
|
|
110
|
+
"Stop the process holding the port (often WinBox), or pass a different --port. centrs enables SO_REUSEPORT, but the other process or platform may not allow sharing.",
|
|
111
|
+
context: { port, host, cause: code },
|
|
112
|
+
cause: error,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
116
|
+
return new CentrsError({
|
|
117
|
+
code: "mndp/listen-failed",
|
|
118
|
+
summary: `Permission denied binding UDP ${host}:${port} for the MNDP listener.`,
|
|
119
|
+
remediation:
|
|
120
|
+
"Bind an unprivileged port (MNDP's 5678 is unprivileged) or run with sufficient privileges for the chosen --port.",
|
|
121
|
+
context: { port, host, cause: code },
|
|
122
|
+
cause: error,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return asCentrsError(error, {
|
|
126
|
+
code: "mndp/listen-failed",
|
|
127
|
+
summary: `Failed to bind the MNDP listener on ${host}:${port}.`,
|
|
128
|
+
remediation:
|
|
129
|
+
"Check the --port/host and that UDP sockets are permitted in this environment.",
|
|
130
|
+
context: { port, host, cause: code },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Bind a UDP socket and collect MNDP announcements for `timeoutMs`, then
|
|
136
|
+
* resolve with the populated cache. Bind/socket failures reject with an
|
|
137
|
+
* actionable `mndp/listen-failed` {@link CentrsError}.
|
|
138
|
+
*/
|
|
139
|
+
export function listenMndp(
|
|
140
|
+
options: ListenMndpOptions = {},
|
|
141
|
+
): Promise<ListenMndpResult> {
|
|
142
|
+
const timeoutMs = options.timeoutMs ?? DISCOVER_DEFAULT_TIMEOUT_MS;
|
|
143
|
+
const requestedPort = options.port ?? MNDP_PORT;
|
|
144
|
+
const host = options.host ?? "0.0.0.0";
|
|
145
|
+
const sendRefresh = options.sendRefresh ?? true;
|
|
146
|
+
const refreshIntervalMs =
|
|
147
|
+
options.refreshIntervalMs ?? DISCOVER_DEFAULT_REFRESH_INTERVAL_MS;
|
|
148
|
+
const cache =
|
|
149
|
+
options.cache ?? new MndpCache({ ttlMs: options.ttlMs, now: options.now });
|
|
150
|
+
|
|
151
|
+
let packetsReceived = 0;
|
|
152
|
+
let packetsDecoded = 0;
|
|
153
|
+
let packetsRejected = 0;
|
|
154
|
+
let malformedCount = 0;
|
|
155
|
+
let broadcastError: unknown;
|
|
156
|
+
|
|
157
|
+
const collectWarnings = (): DevicesWarning[] => {
|
|
158
|
+
const warnings: DevicesWarning[] = [];
|
|
159
|
+
if (malformedCount > 0) {
|
|
160
|
+
warnings.push({
|
|
161
|
+
code: "mndp/malformed",
|
|
162
|
+
message: `Ignored ${malformedCount} malformed MNDP datagram(s) on the listen port.`,
|
|
163
|
+
context: { count: malformedCount },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (broadcastError !== undefined) {
|
|
167
|
+
warnings.push({
|
|
168
|
+
code: "discover/broadcast-unavailable",
|
|
169
|
+
message:
|
|
170
|
+
"Could not enable UDP broadcast; discovery continued passively without sending refresh probes.",
|
|
171
|
+
context: { cause: errorCodeOf(broadcastError) },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return warnings;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return new Promise<ListenMndpResult>((resolve, reject) => {
|
|
178
|
+
const socket = createSocket({
|
|
179
|
+
type: "udp4",
|
|
180
|
+
reuseAddr: options.reuseAddr ?? true,
|
|
181
|
+
reusePort: options.reusePort ?? true,
|
|
182
|
+
});
|
|
183
|
+
let settled = false;
|
|
184
|
+
let boundPort = requestedPort;
|
|
185
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
186
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
187
|
+
|
|
188
|
+
const onAbort = (): void => finish();
|
|
189
|
+
const cleanup = (): void => {
|
|
190
|
+
if (timeoutTimer) {
|
|
191
|
+
clearTimeout(timeoutTimer);
|
|
192
|
+
}
|
|
193
|
+
if (refreshTimer) {
|
|
194
|
+
clearInterval(refreshTimer);
|
|
195
|
+
}
|
|
196
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const finish = (): void => {
|
|
200
|
+
if (settled) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
settled = true;
|
|
204
|
+
cleanup();
|
|
205
|
+
socket.close(() => {
|
|
206
|
+
resolve({
|
|
207
|
+
cache,
|
|
208
|
+
port: boundPort,
|
|
209
|
+
packetsReceived,
|
|
210
|
+
packetsDecoded,
|
|
211
|
+
packetsRejected,
|
|
212
|
+
warnings: collectWarnings(),
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const fail = (error: unknown): void => {
|
|
218
|
+
if (settled) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
settled = true;
|
|
222
|
+
cleanup();
|
|
223
|
+
try {
|
|
224
|
+
socket.close();
|
|
225
|
+
} catch {
|
|
226
|
+
// Socket may not be open yet; ignore.
|
|
227
|
+
}
|
|
228
|
+
reject(listenError(error, requestedPort, host));
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
socket.on("error", fail);
|
|
232
|
+
socket.on("message", (message: Buffer) => {
|
|
233
|
+
packetsReceived += 1;
|
|
234
|
+
try {
|
|
235
|
+
const neighbor = parseMndpPacket(new Uint8Array(message));
|
|
236
|
+
if (!neighbor.macAddress) {
|
|
237
|
+
// Self-echo of our own refresh, or a MAC-less announcement.
|
|
238
|
+
packetsRejected += 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
cache.observe(neighbor);
|
|
242
|
+
packetsDecoded += 1;
|
|
243
|
+
} catch {
|
|
244
|
+
// Malformed datagram on the port; count and ignore (never throw
|
|
245
|
+
// from the receive path). A single aggregated `mndp/malformed`
|
|
246
|
+
// warning surfaces these in the envelope at finish time.
|
|
247
|
+
packetsRejected += 1;
|
|
248
|
+
malformedCount += 1;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
socket.bind(requestedPort, host, () => {
|
|
254
|
+
boundPort = socket.address().port;
|
|
255
|
+
options.onBound?.(boundPort);
|
|
256
|
+
if (sendRefresh) {
|
|
257
|
+
try {
|
|
258
|
+
socket.setBroadcast(true);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Some environments forbid broadcast; passive listen still
|
|
261
|
+
// works, but surface it as a `discover/broadcast-unavailable`
|
|
262
|
+
// warning so the caller knows no refresh probes were sent.
|
|
263
|
+
broadcastError = error;
|
|
264
|
+
}
|
|
265
|
+
const refresh = (): void => {
|
|
266
|
+
socket.send(
|
|
267
|
+
mndpRefreshPacket(),
|
|
268
|
+
MNDP_PORT,
|
|
269
|
+
MNDP_BROADCAST_ADDRESS,
|
|
270
|
+
() => {
|
|
271
|
+
// Best-effort; a failed refresh does not end the listen.
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
refresh();
|
|
276
|
+
if (refreshIntervalMs > 0) {
|
|
277
|
+
refreshTimer = setInterval(refresh, refreshIntervalMs);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
timeoutTimer = setTimeout(finish, timeoutMs);
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
fail(error);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (options.signal) {
|
|
288
|
+
if (options.signal.aborted) {
|
|
289
|
+
finish();
|
|
290
|
+
} else {
|
|
291
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A neighbor projected for the discover envelope: JSON/YAML-safe, no bytes. */
|
|
298
|
+
export interface DiscoverNeighborRecord {
|
|
299
|
+
mac?: string;
|
|
300
|
+
identity?: string;
|
|
301
|
+
version?: string;
|
|
302
|
+
platform?: string;
|
|
303
|
+
board?: string;
|
|
304
|
+
uptimeSeconds?: number;
|
|
305
|
+
softwareId?: string;
|
|
306
|
+
interfaceName?: string;
|
|
307
|
+
ipv4?: string;
|
|
308
|
+
ipv6?: string;
|
|
309
|
+
sequence: number;
|
|
310
|
+
/** Unknown TLVs reduced to `{ type, byteLength }` (bytes are not emitted). */
|
|
311
|
+
unknownTlvs: readonly { type: number; byteLength: number }[];
|
|
312
|
+
firstSeenAt: string;
|
|
313
|
+
lastSeenAt: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface DiscoverData {
|
|
317
|
+
count: number;
|
|
318
|
+
neighbors: readonly DiscoverNeighborRecord[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface DiscoverSaveRecord {
|
|
322
|
+
target: string;
|
|
323
|
+
mac?: string;
|
|
324
|
+
identity?: string;
|
|
325
|
+
action: "added" | "skipped-existing";
|
|
326
|
+
cdbRecordIndex?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface DiscoverSaveSummary {
|
|
330
|
+
group: string;
|
|
331
|
+
added: number;
|
|
332
|
+
skipped: number;
|
|
333
|
+
records: readonly DiscoverSaveRecord[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface DiscoverOperationMeta {
|
|
337
|
+
command: "discover";
|
|
338
|
+
timeoutMs: number;
|
|
339
|
+
port: number;
|
|
340
|
+
packetsReceived: number;
|
|
341
|
+
packetsDecoded: number;
|
|
342
|
+
packetsRejected: number;
|
|
343
|
+
saved?: DiscoverSaveSummary;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export type DiscoverEnvelope = CentrsEnvelope<
|
|
347
|
+
DiscoverData,
|
|
348
|
+
DiscoverOperationMeta
|
|
349
|
+
>;
|
|
350
|
+
|
|
351
|
+
function toDiscoverNeighbor(
|
|
352
|
+
neighbor: MndpNeighbor,
|
|
353
|
+
firstSeenAt: number,
|
|
354
|
+
lastSeenAt: number,
|
|
355
|
+
): DiscoverNeighborRecord {
|
|
356
|
+
const record: DiscoverNeighborRecord = {
|
|
357
|
+
sequence: neighbor.sequence,
|
|
358
|
+
unknownTlvs: neighbor.unknownTlvs.map((tlv) => ({
|
|
359
|
+
type: tlv.type,
|
|
360
|
+
byteLength: tlv.value.length,
|
|
361
|
+
})),
|
|
362
|
+
firstSeenAt: new Date(firstSeenAt).toISOString(),
|
|
363
|
+
lastSeenAt: new Date(lastSeenAt).toISOString(),
|
|
364
|
+
};
|
|
365
|
+
if (neighbor.macAddress !== undefined) {
|
|
366
|
+
record.mac = neighbor.macAddress;
|
|
367
|
+
}
|
|
368
|
+
if (neighbor.identity !== undefined) {
|
|
369
|
+
record.identity = neighbor.identity;
|
|
370
|
+
}
|
|
371
|
+
if (neighbor.version !== undefined) {
|
|
372
|
+
record.version = neighbor.version;
|
|
373
|
+
}
|
|
374
|
+
if (neighbor.platform !== undefined) {
|
|
375
|
+
record.platform = neighbor.platform;
|
|
376
|
+
}
|
|
377
|
+
if (neighbor.board !== undefined) {
|
|
378
|
+
record.board = neighbor.board;
|
|
379
|
+
}
|
|
380
|
+
if (neighbor.uptimeSeconds !== undefined) {
|
|
381
|
+
record.uptimeSeconds = neighbor.uptimeSeconds;
|
|
382
|
+
}
|
|
383
|
+
if (neighbor.softwareId !== undefined) {
|
|
384
|
+
record.softwareId = neighbor.softwareId;
|
|
385
|
+
}
|
|
386
|
+
if (neighbor.interfaceName !== undefined) {
|
|
387
|
+
record.interfaceName = neighbor.interfaceName;
|
|
388
|
+
}
|
|
389
|
+
if (neighbor.ipv4 !== undefined) {
|
|
390
|
+
record.ipv4 = neighbor.ipv4;
|
|
391
|
+
}
|
|
392
|
+
if (neighbor.ipv6 !== undefined) {
|
|
393
|
+
record.ipv6 = neighbor.ipv6;
|
|
394
|
+
}
|
|
395
|
+
return record;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Build the human/structured comment for a saved neighbor. The only kv token is
|
|
400
|
+
* the allowlisted `source=mndp`; the parenthesized detail is free-form (no
|
|
401
|
+
* `key=value` shapes) so it preserves provenance without emitting
|
|
402
|
+
* `cdb/unknown-option` warnings later.
|
|
403
|
+
*/
|
|
404
|
+
export function formatMndpProvenanceComment(
|
|
405
|
+
neighbor: MndpNeighbor,
|
|
406
|
+
at: Date,
|
|
407
|
+
): string {
|
|
408
|
+
const detail: string[] = [`discovered ${at.toISOString()} via MNDP`];
|
|
409
|
+
if (neighbor.identity) {
|
|
410
|
+
detail.push(`identity: ${neighbor.identity}`);
|
|
411
|
+
}
|
|
412
|
+
if (neighbor.platform) {
|
|
413
|
+
detail.push(`platform: ${neighbor.platform}`);
|
|
414
|
+
}
|
|
415
|
+
if (neighbor.board) {
|
|
416
|
+
detail.push(`board: ${neighbor.board}`);
|
|
417
|
+
}
|
|
418
|
+
if (neighbor.version) {
|
|
419
|
+
detail.push(`version: ${neighbor.version}`);
|
|
420
|
+
}
|
|
421
|
+
if (neighbor.macAddress) {
|
|
422
|
+
detail.push(`mac: ${neighbor.macAddress}`);
|
|
423
|
+
}
|
|
424
|
+
if (neighbor.interfaceName) {
|
|
425
|
+
detail.push(`interface: ${neighbor.interfaceName}`);
|
|
426
|
+
}
|
|
427
|
+
if (neighbor.softwareId) {
|
|
428
|
+
detail.push(`software-id: ${neighbor.softwareId}`);
|
|
429
|
+
}
|
|
430
|
+
return `${renderCommentKvToken("source", "mndp")} (${detail.join("; ")})`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Target a neighbor maps to in the CDB: its IPv4, else its MAC. */
|
|
434
|
+
export function discoverNeighborTarget(
|
|
435
|
+
neighbor: MndpNeighbor,
|
|
436
|
+
): string | undefined {
|
|
437
|
+
return neighbor.ipv4 ?? neighbor.macAddress;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface SaveDiscoveredNeighborsArgs {
|
|
441
|
+
/** How to load (and reload between writes) the CDB. */
|
|
442
|
+
loadOptions: LoadCdbOptions;
|
|
443
|
+
/** Neighbors to persist; ordering is normalized internally by MAC/target. */
|
|
444
|
+
neighbors: readonly MndpNeighbor[];
|
|
445
|
+
/** First-class CDB group for new entries. Default `discovered`. */
|
|
446
|
+
group?: string;
|
|
447
|
+
/** Timestamp written into provenance comments. */
|
|
448
|
+
now?: Date;
|
|
449
|
+
/** Write options forwarded to {@link addDevice} (backup retention, etc.). */
|
|
450
|
+
writeOptions?: WriteWinBoxCdbOptions;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface SaveDiscoveredNeighborsResult {
|
|
454
|
+
summary: DiscoverSaveSummary;
|
|
455
|
+
warnings: readonly DevicesWarning[];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Persist discovered neighbors into the CDB via {@link addDevice}. De-dupe
|
|
460
|
+
* rule: a neighbor whose target (IPv4, else MAC) already names a CDB entry is
|
|
461
|
+
* skipped — never overwritten — so hand-curated records win. New neighbors are
|
|
462
|
+
* added with `group=discovered` and `source=mndp` provenance. The CDB is
|
|
463
|
+
* reloaded between writes so each `addDevice` sees the prior additions.
|
|
464
|
+
*/
|
|
465
|
+
export async function saveDiscoveredNeighbors(
|
|
466
|
+
args: SaveDiscoveredNeighborsArgs,
|
|
467
|
+
): Promise<SaveDiscoveredNeighborsResult> {
|
|
468
|
+
const group = args.group ?? DISCOVER_DEFAULT_GROUP;
|
|
469
|
+
const at = args.now ?? new Date();
|
|
470
|
+
const records: DiscoverSaveRecord[] = [];
|
|
471
|
+
const warnings: DevicesWarning[] = [];
|
|
472
|
+
let added = 0;
|
|
473
|
+
let skipped = 0;
|
|
474
|
+
|
|
475
|
+
const sorted = [...args.neighbors].sort((a, b) => {
|
|
476
|
+
const left = discoverNeighborTarget(a) ?? "";
|
|
477
|
+
const right = discoverNeighborTarget(b) ?? "";
|
|
478
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
let cdb = await loadCdb(args.loadOptions);
|
|
482
|
+
for (const neighbor of sorted) {
|
|
483
|
+
const target = discoverNeighborTarget(neighbor);
|
|
484
|
+
if (!target) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const exists = cdb.entries.some((entry) => entry.target === target);
|
|
488
|
+
const detail: DiscoverSaveRecord = {
|
|
489
|
+
target,
|
|
490
|
+
action: exists ? "skipped-existing" : "added",
|
|
491
|
+
};
|
|
492
|
+
if (neighbor.macAddress !== undefined) {
|
|
493
|
+
detail.mac = neighbor.macAddress;
|
|
494
|
+
}
|
|
495
|
+
if (neighbor.identity !== undefined) {
|
|
496
|
+
detail.identity = neighbor.identity;
|
|
497
|
+
}
|
|
498
|
+
if (exists) {
|
|
499
|
+
skipped += 1;
|
|
500
|
+
records.push(detail);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const recordType = neighbor.ipv4
|
|
505
|
+
? winBoxCdbRecordType.ipAdmin
|
|
506
|
+
: winBoxCdbRecordType.macTarget;
|
|
507
|
+
const envelope = await addDevice({
|
|
508
|
+
cdb,
|
|
509
|
+
target,
|
|
510
|
+
recordType,
|
|
511
|
+
group,
|
|
512
|
+
comment: formatMndpProvenanceComment(neighbor, at),
|
|
513
|
+
writeOptions: args.writeOptions,
|
|
514
|
+
});
|
|
515
|
+
added += 1;
|
|
516
|
+
detail.cdbRecordIndex = envelope.data.cdbRecordIndex;
|
|
517
|
+
records.push(detail);
|
|
518
|
+
for (const warning of envelope.warnings) {
|
|
519
|
+
warnings.push(warning);
|
|
520
|
+
}
|
|
521
|
+
cdb = await loadCdb(args.loadOptions);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
summary: { group, added, skipped, records },
|
|
526
|
+
warnings,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export interface DiscoverOptions {
|
|
531
|
+
/** Listen window in ms. Default 15000. */
|
|
532
|
+
timeoutMs?: number;
|
|
533
|
+
/** UDP port. Default 5678; `0` for an ephemeral test port. */
|
|
534
|
+
port?: number;
|
|
535
|
+
/** Bind address. Default `0.0.0.0`. */
|
|
536
|
+
host?: string;
|
|
537
|
+
/** Cache TTL in ms. */
|
|
538
|
+
ttlMs?: number;
|
|
539
|
+
/** Persist discovered neighbors into the CDB. */
|
|
540
|
+
save?: boolean;
|
|
541
|
+
/** CDB group for saved entries. Default `discovered`. */
|
|
542
|
+
group?: string;
|
|
543
|
+
/** CDB path override for `--save`. */
|
|
544
|
+
cdbFile?: string;
|
|
545
|
+
/** CDB password for `--save` against an encrypted CDB. */
|
|
546
|
+
cdbPassword?: string;
|
|
547
|
+
/** Environment snapshot for CDB resolution. */
|
|
548
|
+
env?: Record<string, string | undefined>;
|
|
549
|
+
/** Clock injection for the cache and provenance timestamps. */
|
|
550
|
+
now?: () => number;
|
|
551
|
+
/** Send the broadcast refresh. Default true. */
|
|
552
|
+
sendRefresh?: boolean;
|
|
553
|
+
/** Called once with the bound port (test hook). */
|
|
554
|
+
onBound?: (port: number) => void;
|
|
555
|
+
/** Abort the listen early. */
|
|
556
|
+
signal?: AbortSignal;
|
|
557
|
+
/** Listener injection so {@link discover} is testable without a socket. */
|
|
558
|
+
listen?: (options: ListenMndpOptions) => Promise<ListenMndpResult>;
|
|
559
|
+
/** Write options forwarded to the save path. */
|
|
560
|
+
writeOptions?: WriteWinBoxCdbOptions;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function discoverMeta(
|
|
564
|
+
operation: DiscoverOperationMeta,
|
|
565
|
+
options: DiscoverOptions,
|
|
566
|
+
): EnvelopeMeta<DiscoverOperationMeta> {
|
|
567
|
+
const settings = resolveDevicesSettings({
|
|
568
|
+
cdbFile: options.cdbFile,
|
|
569
|
+
cdbPassword: options.cdbPassword,
|
|
570
|
+
env: options.env ?? {},
|
|
571
|
+
});
|
|
572
|
+
return {
|
|
573
|
+
target: {},
|
|
574
|
+
via: "mndp",
|
|
575
|
+
settings: {
|
|
576
|
+
timeoutMs: { kind: options.timeoutMs !== undefined ? "cli" : "default" },
|
|
577
|
+
cdbFile: settings.cdbFile.source,
|
|
578
|
+
cdbPassword: settings.cdbPassword.source,
|
|
579
|
+
},
|
|
580
|
+
operation,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Run an MNDP discovery: listen for `timeoutMs`, optionally persist the result
|
|
586
|
+
* with `save`, and return a canonical envelope whose `data.neighbors` are
|
|
587
|
+
* sorted deterministically by MAC. A `save` against an encrypted CDB re-encrypts
|
|
588
|
+
* with the password loaded from settings via the write layer's `encryptWith`
|
|
589
|
+
* option.
|
|
590
|
+
*/
|
|
591
|
+
export async function discover(
|
|
592
|
+
options: DiscoverOptions = {},
|
|
593
|
+
): Promise<DiscoverEnvelope> {
|
|
594
|
+
const timeoutMs = options.timeoutMs ?? DISCOVER_DEFAULT_TIMEOUT_MS;
|
|
595
|
+
const listen = options.listen ?? listenMndp;
|
|
596
|
+
|
|
597
|
+
let result: ListenMndpResult;
|
|
598
|
+
try {
|
|
599
|
+
result = await listen({
|
|
600
|
+
timeoutMs,
|
|
601
|
+
port: options.port,
|
|
602
|
+
host: options.host,
|
|
603
|
+
ttlMs: options.ttlMs,
|
|
604
|
+
now: options.now,
|
|
605
|
+
sendRefresh: options.sendRefresh,
|
|
606
|
+
onBound: options.onBound,
|
|
607
|
+
signal: options.signal,
|
|
608
|
+
});
|
|
609
|
+
} catch (error) {
|
|
610
|
+
return buildDiscoverErrorEnvelope(options, timeoutMs, error);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const entries = result.cache.entries();
|
|
614
|
+
const neighbors = entries.map((entry) =>
|
|
615
|
+
toDiscoverNeighbor(entry.neighbor, entry.firstSeenAt, entry.lastSeenAt),
|
|
616
|
+
);
|
|
617
|
+
const operation: DiscoverOperationMeta = {
|
|
618
|
+
command: "discover",
|
|
619
|
+
timeoutMs,
|
|
620
|
+
port: result.port,
|
|
621
|
+
packetsReceived: result.packetsReceived,
|
|
622
|
+
packetsDecoded: result.packetsDecoded,
|
|
623
|
+
packetsRejected: result.packetsRejected,
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const warnings: DevicesWarning[] = [...result.warnings];
|
|
627
|
+
if (options.save) {
|
|
628
|
+
try {
|
|
629
|
+
const saved = await saveDiscoveredNeighbors({
|
|
630
|
+
loadOptions: {
|
|
631
|
+
cdbFile: options.cdbFile,
|
|
632
|
+
cdbPassword: options.cdbPassword,
|
|
633
|
+
env: options.env,
|
|
634
|
+
},
|
|
635
|
+
neighbors: entries.map((entry) => entry.neighbor),
|
|
636
|
+
group: options.group,
|
|
637
|
+
now: options.now ? new Date(options.now()) : undefined,
|
|
638
|
+
writeOptions: options.writeOptions,
|
|
639
|
+
});
|
|
640
|
+
operation.saved = saved.summary;
|
|
641
|
+
for (const warning of saved.warnings) {
|
|
642
|
+
warnings.push(warning);
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
return buildDiscoverErrorEnvelope(options, timeoutMs, error, operation);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
ok: true,
|
|
651
|
+
data: { count: neighbors.length, neighbors },
|
|
652
|
+
warnings,
|
|
653
|
+
tips: [],
|
|
654
|
+
meta: discoverMeta(operation, options),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function buildDiscoverErrorEnvelope(
|
|
659
|
+
options: DiscoverOptions,
|
|
660
|
+
timeoutMs: number,
|
|
661
|
+
error: unknown,
|
|
662
|
+
operation?: DiscoverOperationMeta,
|
|
663
|
+
): DiscoverEnvelope {
|
|
664
|
+
const centrsError = asCentrsError(error, {
|
|
665
|
+
code: "discover/failed",
|
|
666
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
667
|
+
remediation:
|
|
668
|
+
"Re-run `centrs discover`; check the --port, CDB path, and credentials when using --save.",
|
|
669
|
+
});
|
|
670
|
+
const op: DiscoverOperationMeta = operation ?? {
|
|
671
|
+
command: "discover",
|
|
672
|
+
timeoutMs,
|
|
673
|
+
port: options.port ?? MNDP_PORT,
|
|
674
|
+
packetsReceived: 0,
|
|
675
|
+
packetsDecoded: 0,
|
|
676
|
+
packetsRejected: 0,
|
|
677
|
+
};
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
error: serializeCentrsError(centrsError),
|
|
681
|
+
warnings: [],
|
|
682
|
+
tips: [],
|
|
683
|
+
meta: discoverMeta(op, options),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export type DiscoverOutputFormat = "text" | "json" | "yaml";
|
|
688
|
+
export const discoverOutputFormats: readonly DiscoverOutputFormat[] = [
|
|
689
|
+
"text",
|
|
690
|
+
"json",
|
|
691
|
+
"yaml",
|
|
692
|
+
];
|
|
693
|
+
|
|
694
|
+
export function renderDiscoverEnvelope(
|
|
695
|
+
envelope: DiscoverEnvelope,
|
|
696
|
+
format: DiscoverOutputFormat,
|
|
697
|
+
): string {
|
|
698
|
+
if (format === "json") {
|
|
699
|
+
return JSON.stringify(envelope, null, 2);
|
|
700
|
+
}
|
|
701
|
+
if (format === "yaml") {
|
|
702
|
+
return renderDiscoverYaml(envelope);
|
|
703
|
+
}
|
|
704
|
+
return renderDiscoverText(envelope);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function renderDiscoverText(envelope: DiscoverEnvelope): string {
|
|
708
|
+
const lines: string[] = [];
|
|
709
|
+
if (!envelope.ok) {
|
|
710
|
+
lines.push(`[${envelope.error.code}] ${envelope.error.summary}`);
|
|
711
|
+
if (envelope.error.remediation) {
|
|
712
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
713
|
+
}
|
|
714
|
+
if (envelope.error.detailsUrl) {
|
|
715
|
+
lines.push(`Details: ${envelope.error.detailsUrl}`);
|
|
716
|
+
}
|
|
717
|
+
appendDiscoverWarnings(lines, envelope.warnings);
|
|
718
|
+
return lines.join("\n");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const op = envelope.meta.operation;
|
|
722
|
+
const window = op ? `${Math.round(op.timeoutMs / 1000)}s` : "?";
|
|
723
|
+
lines.push(
|
|
724
|
+
`discovered ${envelope.data.count} neighbor(s) in ${window} on port ${op?.port ?? "?"}`,
|
|
725
|
+
);
|
|
726
|
+
if (envelope.data.neighbors.length > 0) {
|
|
727
|
+
lines.push(["MAC", "IDENTITY", "BOARD", "VERSION", "IPV4"].join("\t"));
|
|
728
|
+
for (const neighbor of envelope.data.neighbors) {
|
|
729
|
+
lines.push(
|
|
730
|
+
[
|
|
731
|
+
neighbor.mac ?? "-",
|
|
732
|
+
neighbor.identity ?? "-",
|
|
733
|
+
neighbor.board ?? "-",
|
|
734
|
+
neighbor.version ?? "-",
|
|
735
|
+
neighbor.ipv4 ?? "-",
|
|
736
|
+
].join("\t"),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (op?.saved) {
|
|
741
|
+
lines.push(
|
|
742
|
+
`saved: ${op.saved.added} added, ${op.saved.skipped} skipped (group ${op.saved.group})`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
appendDiscoverWarnings(lines, envelope.warnings);
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function appendDiscoverWarnings(
|
|
750
|
+
lines: string[],
|
|
751
|
+
warnings: readonly DevicesWarning[],
|
|
752
|
+
): void {
|
|
753
|
+
for (const warning of warnings) {
|
|
754
|
+
lines.push(`warning: [${warning.code}] ${warning.message}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function renderDiscoverYaml(value: unknown, indent = ""): string {
|
|
759
|
+
if (value === null || value === undefined) {
|
|
760
|
+
return `${indent}null`;
|
|
761
|
+
}
|
|
762
|
+
if (typeof value === "string") {
|
|
763
|
+
return JSON.stringify(value);
|
|
764
|
+
}
|
|
765
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
766
|
+
return String(value);
|
|
767
|
+
}
|
|
768
|
+
if (Array.isArray(value)) {
|
|
769
|
+
if (value.length === 0) {
|
|
770
|
+
return "[]";
|
|
771
|
+
}
|
|
772
|
+
return value
|
|
773
|
+
.map(
|
|
774
|
+
(item) => `${indent}- ${renderDiscoverYamlInline(item, `${indent} `)}`,
|
|
775
|
+
)
|
|
776
|
+
.join("\n");
|
|
777
|
+
}
|
|
778
|
+
if (typeof value === "object") {
|
|
779
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
780
|
+
([, v]) => v !== undefined,
|
|
781
|
+
);
|
|
782
|
+
if (entries.length === 0) {
|
|
783
|
+
return "{}";
|
|
784
|
+
}
|
|
785
|
+
return entries
|
|
786
|
+
.map(([k, v]) => {
|
|
787
|
+
const inline = renderDiscoverYamlInline(v, `${indent} `);
|
|
788
|
+
return inline.includes("\n")
|
|
789
|
+
? `${indent}${k}:\n${inline}`
|
|
790
|
+
: `${indent}${k}: ${inline}`;
|
|
791
|
+
})
|
|
792
|
+
.join("\n");
|
|
793
|
+
}
|
|
794
|
+
return String(value);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function renderDiscoverYamlInline(value: unknown, indent: string): string {
|
|
798
|
+
if (Array.isArray(value) || (value !== null && typeof value === "object")) {
|
|
799
|
+
return `\n${renderDiscoverYaml(value, indent)}`;
|
|
800
|
+
}
|
|
801
|
+
return renderDiscoverYaml(value, indent);
|
|
802
|
+
}
|