@mark1russell7/netdiag 0.1.0 → 0.1.1

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.
Files changed (45) hide show
  1. package/README.md +153 -1
  2. package/build/address.d.ts +56 -0
  3. package/build/address.d.ts.map +1 -0
  4. package/build/address.js +172 -0
  5. package/build/address.js.map +1 -0
  6. package/build/{diagnosis-BTa-Dfwl.js → diagnosis-a4hRzFxp.js} +113 -11
  7. package/build/diagnosis-a4hRzFxp.js.map +1 -0
  8. package/build/diagnosis.d.ts +3 -2
  9. package/build/diagnosis.js +1 -1
  10. package/build/{index-CP1hzELI.d.ts → index-K2dGiYxW.d.ts} +83 -22
  11. package/build/index-K2dGiYxW.d.ts.map +1 -0
  12. package/build/index.d.ts +253 -148
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1084 -161
  15. package/build/index.js.map +1 -1
  16. package/build/kinds-CQv769lK.js +341 -0
  17. package/build/kinds-CQv769lK.js.map +1 -0
  18. package/build/kinds-_NShfZpU.d.ts +377 -0
  19. package/build/kinds-_NShfZpU.d.ts.map +1 -0
  20. package/build/layer-BNg0VYuK.d.ts +22 -0
  21. package/build/layer-BNg0VYuK.d.ts.map +1 -0
  22. package/build/tools.d.ts +206 -0
  23. package/build/tools.d.ts.map +1 -0
  24. package/build/tools.js +896 -0
  25. package/build/tools.js.map +1 -0
  26. package/package.json +17 -5
  27. package/src/address/index.ts +155 -0
  28. package/src/diagnosis/catalog.ts +8 -8
  29. package/src/diagnosis/index.ts +13 -0
  30. package/src/diagnosis/score.ts +160 -0
  31. package/src/index.ts +35 -0
  32. package/src/kinds.ts +460 -0
  33. package/src/probes/native.ts +354 -0
  34. package/src/probes/socket.ts +520 -0
  35. package/src/readers.ts +367 -0
  36. package/src/scope.ts +5 -24
  37. package/src/tools/common.ts +47 -0
  38. package/src/tools/host.ts +91 -0
  39. package/src/tools/index.ts +38 -0
  40. package/src/tools/licenses.ts +37 -0
  41. package/src/tools/mtr.ts +156 -0
  42. package/src/tools/ping.ts +231 -0
  43. package/src/tools/traceroute.ts +313 -0
  44. package/build/diagnosis-BTa-Dfwl.js.map +0 -1
  45. package/build/index-CP1hzELI.d.ts.map +0 -1
package/src/readers.ts ADDED
@@ -0,0 +1,367 @@
1
+ import { addressKind, canonicalAddress } from "./address/index.ts";
2
+ import type { Evidence } from "./diagnosis/model.ts";
3
+ import { isFactOf, type Fact, type FactKind } from "./fact.ts";
4
+ import {
5
+ DNS_ANSWER,
6
+ HTTP_RESPONSE,
7
+ ICMP_ECHO,
8
+ PATH_REPORT,
9
+ PATH_TRACE,
10
+ TCP_CONNECT,
11
+ TLS_HANDSHAKE,
12
+ type ConnectOutcome,
13
+ type DnsAnswer,
14
+ type DnsOutcome,
15
+ type EchoOutcome,
16
+ type HandshakeOutcome,
17
+ type HttpResponse,
18
+ type IcmpEcho,
19
+ type PathReport,
20
+ type PathTrace,
21
+ type TcpConnect,
22
+ type TlsHandshake,
23
+ type Trust,
24
+ } from "./kinds.ts";
25
+
26
+ // The readers change facts into evidence for the model. Each value that they give is a value of a feature of the
27
+ // standard catalog, and a test holds it.
28
+
29
+ /** A fact that gave no evidence, with the reason in plain words. */
30
+ export interface Skipped {
31
+ readonly fact: string;
32
+ readonly reason: string;
33
+ }
34
+
35
+ /** The evidence of a list of facts, and the facts that gave no evidence. */
36
+ export interface Reading {
37
+ readonly evidence: readonly Evidence[];
38
+ readonly skipped: readonly Skipped[];
39
+ }
40
+
41
+ /** The options of `readEvidence`. */
42
+ export interface ReadOptions {
43
+ /** This function tells if a fact is about the target (`true`) or about a reference host (`false`). The default is `true`. */
44
+ readonly primary?: ((fact: Fact) => boolean) | undefined;
45
+ /**
46
+ * The IP address of the default gateway. The ICMP echo facts of this address give the feature `gateway.reply`. The
47
+ * ICMP echo facts of the other addresses give the feature `icmp.reply`.
48
+ */
49
+ readonly gateway?: string | undefined;
50
+ }
51
+
52
+ /** A bin of a time or a ratio. A number below `below` gets `value`. The last bin has no limit. */
53
+ export interface Bin {
54
+ readonly below?: number | undefined;
55
+ readonly value: string;
56
+ }
57
+
58
+ /** The bins of the feature `tcp.time`, in milliseconds. A connect of 900 ms or more sent its SYN again. */
59
+ export const TCP_TIME_BINS: readonly Bin[] = [{ below: 150, value: "short" }, { below: 900, value: "long" }, { value: "retry" }];
60
+
61
+ /** The bins of the feature `tls.ratio`: the time of the TLS handshake divided by the time of the TCP connect. */
62
+ export const TLS_RATIO_BINS: readonly Bin[] = [{ below: 3, value: "low" }, { below: 8, value: "mid" }, { value: "high" }];
63
+
64
+ /** The bins of the feature `http.ttfb`: the time to the first byte divided by the time of the TCP connect. */
65
+ export const HTTP_TTFB_BINS: readonly Bin[] = [{ below: 4, value: "low" }, { below: 20, value: "mid" }, { value: "high" }];
66
+
67
+ /**
68
+ * The smallest time of a TCP connect in a ratio, in milliseconds. On a local link, a ratio with a smaller time measures
69
+ * only the cost of the cryptography.
70
+ */
71
+ export const MIN_CONNECT_MS = 1;
72
+
73
+ /** The value of the feature `dns.answer` for each outcome of a DNS query. `nodata` tells that the name exists. */
74
+ export const DNS_ANSWER_VALUES: Readonly<Partial<Record<DnsOutcome, string>>> = {
75
+ ok: "ok",
76
+ nodata: "ok",
77
+ nxdomain: "nxdomain",
78
+ servfail: "servfail",
79
+ refused: "refused",
80
+ timeout: "timeout",
81
+ unreachable: "unreachable",
82
+ };
83
+
84
+ /** The value of the feature `tcp.connect` for each outcome of a TCP connect. */
85
+ export const TCP_CONNECT_VALUES: Readonly<Partial<Record<ConnectOutcome, string>>> = {
86
+ connected: "connected",
87
+ refused: "refused",
88
+ timeout: "timeout",
89
+ unreachable: "unreachable",
90
+ reset: "reset",
91
+ };
92
+
93
+ /**
94
+ * The value of the feature `tls.handshake` for each outcome of a TLS handshake. The catalog has no value for `not-tls`
95
+ * and `closed` yet. The lab runs (ND-7) tell which faults cause them.
96
+ */
97
+ export const TLS_HANDSHAKE_VALUES: Readonly<Partial<Record<HandshakeOutcome, string>>> = {
98
+ ok: "ok",
99
+ "cert-error": "cert-error",
100
+ alert: "alert",
101
+ timeout: "timeout",
102
+ reset: "reset",
103
+ };
104
+
105
+ /** The value of the feature `tls.trust` for each trust. A chain that ends at no root of the store has no public root. */
106
+ export const TLS_TRUST_VALUES: Readonly<Record<Trust, string>> = { public: "public", private: "private", none: "private" };
107
+
108
+ /** The value of the feature `icmp.reply` for each outcome of the ICMP echo requests to an address. */
109
+ export const ICMP_REPLY_VALUES: Readonly<Partial<Record<EchoOutcome, string>>> = { reply: "reply", none: "none", unreachable: "unreachable" };
110
+
111
+ /**
112
+ * The value of the feature `gateway.reply` for each outcome of the ICMP echo requests to the gateway. When this computer
113
+ * tells that the gateway is unreachable, the gateway gives no reply.
114
+ */
115
+ export const GATEWAY_REPLY_VALUES: Readonly<Partial<Record<EchoOutcome, string>>> = { reply: "reply", none: "none", unreachable: "none" };
116
+
117
+ /**
118
+ * The bins of the feature `path.loss`, on the share of the echo requests with a reply. A share of 1 has no loss. A share
119
+ * of 0.95 or more has a loss of up to 5 %.
120
+ */
121
+ export const PATH_LOSS_BINS: readonly Bin[] = [{ below: 0.95, value: "high" }, { below: 1, value: "some" }, { value: "none" }];
122
+
123
+ /**
124
+ * The smallest number of echo requests that gives the feature `path.loss`. With 20 requests, one lost request is a loss
125
+ * of 5 %. With no reply, the feature `icmp.reply` tells the result, not `path.loss`.
126
+ */
127
+ export const LOSS_MIN_SENT = 20;
128
+
129
+ /** The suffixes of the names of local networks and of the special-use names (RFC 6761, RFC 6762 and RFC 8375). */
130
+ export const LOCAL_SUFFIXES: readonly string[] = ["local", "localhost", "test", "invalid", "example", "home.arpa", "internal", "lan", "home", "corp", "intranet", "private"];
131
+
132
+ /** This function gives the value of the first bin that holds `x`. */
133
+ export function binOf(bins: readonly Bin[], x: number): string {
134
+ for (const bin of bins) if (bin.below === undefined || x < bin.below) return bin.value;
135
+ throw new RangeError("The last bin has a limit.");
136
+ }
137
+
138
+ /** This function tells if a name is a local name: a name with one label, or a name with a local suffix. */
139
+ export function isLocalName(name: string): boolean {
140
+ const lower = name.toLowerCase().replace(/\.$/, "");
141
+ if (!lower.includes(".")) return true;
142
+ return LOCAL_SUFFIXES.some((suffix) => lower === suffix || lower.endsWith(`.${suffix}`));
143
+ }
144
+
145
+ interface Slot {
146
+ readonly feature: string;
147
+ readonly primary: boolean;
148
+ value: string;
149
+ readonly facts: string[];
150
+ }
151
+
152
+ interface Facts {
153
+ readonly dns: Fact<DnsAnswer>[];
154
+ readonly tcp: Fact<TcpConnect>[];
155
+ readonly tls: Fact<TlsHandshake>[];
156
+ readonly http: Fact<HttpResponse>[];
157
+ readonly icmp: Fact<IcmpEcho>[];
158
+ readonly report: Fact<PathReport>[];
159
+ readonly trace: Fact<PathTrace>[];
160
+ }
161
+
162
+ const READ_KINDS: readonly [keyof Facts, FactKind<unknown>][] = [
163
+ ["dns", DNS_ANSWER],
164
+ ["tcp", TCP_CONNECT],
165
+ ["tls", TLS_HANDSHAKE],
166
+ ["http", HTTP_RESPONSE],
167
+ ["icmp", ICMP_ECHO],
168
+ ["report", PATH_REPORT],
169
+ ["trace", PATH_TRACE],
170
+ ];
171
+
172
+ /**
173
+ * This function changes facts into evidence for the model.
174
+ *
175
+ * - The reader gives one evidence for each feature, subject and role. When a subject has more facts, the last fact decides.
176
+ * - For `dns.answer`, one answer `ok` of a name and a resolver decides. For `dns.address`, one private address decides.
177
+ * - The order of the facts is the order of the runner. A ratio uses the last TCP connect before the handshake or the request.
178
+ * - An ICMP echo fact gives `gateway.reply` for the gateway, and `icmp.reply` for each other address.
179
+ * - An ICMP echo fact with replies to `LOSS_MIN_SENT` requests or more gives `path.loss`. A report of `mtr` gives it from
180
+ * the last hop, when the address answered there.
181
+ * - A trace of `traceroute` gives no evidence. The catalog has no feature for the hops of a path.
182
+ */
183
+ export function readEvidence(facts: readonly Fact[], options: ReadOptions = {}): Reading {
184
+ const primaryOf = options.primary ?? (() => true);
185
+ const slots = new Map<string, Slot>();
186
+ const skipped: Skipped[] = [];
187
+ const sorted: Facts = { dns: [], tcp: [], tls: [], http: [], icmp: [], report: [], trace: [] };
188
+
189
+ for (const fact of facts) {
190
+ const entry = READ_KINDS.find(([, kind]) => kind.kind === fact.kind);
191
+ if (entry === undefined) skipped.push({ fact: fact.id, reason: `netdiag has no reader for the kind ${fact.kind}.` });
192
+ else if (!entry[1].check(fact.value)) skipped.push({ fact: fact.id, reason: `The value does not pass the check of the kind ${fact.kind}.` });
193
+ else (sorted[entry[0]] as Fact[]).push(fact);
194
+ }
195
+
196
+ /** This function puts a value in the slot of a feature and a group. A `sticky` value stays when a later fact disagrees. */
197
+ const note = (feature: string, group: string, fact: Fact, value: string, sticky?: string): void => {
198
+ const primary = primaryOf(fact);
199
+ const key = `${feature}\u0000${primary ? 1 : 0}\u0000${group}`;
200
+ const slot = slots.get(key);
201
+ if (slot === undefined) slots.set(key, { feature, primary, value, facts: [fact.id] });
202
+ else {
203
+ if (slot.value !== sticky) slot.value = value;
204
+ slot.facts.push(fact.id);
205
+ }
206
+ };
207
+ const skip = (fact: Fact, feature: string, outcome: string): void => {
208
+ skipped.push({ fact: fact.id, reason: `The outcome "${outcome}" of ${fact.kind} has no value of the feature ${feature}.` });
209
+ };
210
+
211
+ for (const fact of sorted.dns) {
212
+ const answer = fact.value;
213
+ const group = `${answer.name.toLowerCase()}\u0000${answer.resolver}`;
214
+ const value = DNS_ANSWER_VALUES[answer.outcome];
215
+ if (value === undefined) {
216
+ skip(fact, "dns.answer", answer.outcome);
217
+ continue;
218
+ }
219
+ note("dns.answer", group, fact, value, "ok");
220
+ if (answer.addresses.length > 0 && !isLocalName(answer.name)) {
221
+ const kinds = answer.addresses.map(addressKind);
222
+ note("dns.address", group, fact, kinds.includes("private") ? "private" : "public", "private");
223
+ }
224
+ }
225
+
226
+ readFamily(sorted, primaryOf, note);
227
+
228
+ const subject = (value: { readonly address: string; readonly port: number }): string => `${value.address}\u0000${value.port}`;
229
+ for (const fact of sorted.tcp) {
230
+ const connect = fact.value;
231
+ const value = TCP_CONNECT_VALUES[connect.outcome];
232
+ if (value === undefined) {
233
+ skip(fact, "tcp.connect", connect.outcome);
234
+ continue;
235
+ }
236
+ note("tcp.connect", subject(connect), fact, value);
237
+ if (connect.outcome === "connected") note("tcp.time", subject(connect), fact, binOf(TCP_TIME_BINS, connect.ms));
238
+ }
239
+
240
+ const connectBefore = (fact: Fact, value: { readonly address: string; readonly port: number }): Fact<TcpConnect> | undefined => {
241
+ let best: Fact<TcpConnect> | undefined;
242
+ for (const tcp of sorted.tcp) {
243
+ const same = tcp.value.address === value.address && tcp.value.port === value.port && primaryOf(tcp) === primaryOf(fact);
244
+ if (!same || tcp.value.outcome !== "connected" || tcp.time.end > fact.time.start) continue;
245
+ if (best === undefined || tcp.time.end >= best.time.end) best = tcp;
246
+ }
247
+ return best;
248
+ };
249
+ const ratio = (ms: number, tcp: Fact<TcpConnect>): number => ms / Math.max(tcp.value.ms, MIN_CONNECT_MS);
250
+
251
+ for (const fact of sorted.tls) {
252
+ const handshake = fact.value;
253
+ const group = `${subject(handshake)}\u0000${handshake.servername ?? ""}`;
254
+ const value = TLS_HANDSHAKE_VALUES[handshake.outcome];
255
+ if (value === undefined) {
256
+ skip(fact, "tls.handshake", handshake.outcome);
257
+ continue;
258
+ }
259
+ note("tls.handshake", group, fact, value);
260
+ if (handshake.certificate !== undefined) note("tls.trust", group, fact, TLS_TRUST_VALUES[handshake.certificate.trust]);
261
+ const tcp = handshake.outcome === "ok" || handshake.outcome === "cert-error" ? connectBefore(fact, handshake) : undefined;
262
+ if (tcp !== undefined) note("tls.ratio", group, fact, binOf(TLS_RATIO_BINS, ratio(handshake.ms, tcp)));
263
+ }
264
+
265
+ for (const fact of sorted.http) {
266
+ const response = fact.value;
267
+ const group = [subject(response), response.scheme, response.host, response.method, response.path].join("\u0000");
268
+ const status = statusValue(response);
269
+ if (status === undefined) {
270
+ skip(fact, "http.status", response.outcome === "response" ? `response ${response.status ?? ""}`.trim() : response.outcome);
271
+ continue;
272
+ }
273
+ note("http.status", group, fact, status);
274
+ const tcp = response.ttfbMs === undefined ? undefined : connectBefore(fact, response);
275
+ if (tcp !== undefined && response.ttfbMs !== undefined) note("http.ttfb", group, fact, binOf(HTTP_TTFB_BINS, ratio(response.ttfbMs, tcp)));
276
+ }
277
+
278
+ const gateway = options.gateway === undefined ? undefined : canonicalAddress(options.gateway);
279
+ for (const fact of sorted.icmp) {
280
+ const echo = fact.value;
281
+ const address = canonicalAddress(echo.address);
282
+ const isGateway = address === gateway;
283
+ const feature = isGateway ? "gateway.reply" : "icmp.reply";
284
+ const value = (isGateway ? GATEWAY_REPLY_VALUES : ICMP_REPLY_VALUES)[echo.outcome];
285
+ if (value === undefined) {
286
+ skip(fact, feature, echo.outcome);
287
+ continue;
288
+ }
289
+ note(feature, address, fact, value);
290
+ }
291
+
292
+ // The feature path.loss comes from ping and from mtr. The facts of both kinds go in the order of the runner.
293
+ const lossFacts = new Set<Fact>([...sorted.icmp, ...sorted.report]);
294
+ for (const fact of facts) {
295
+ if (!lossFacts.has(fact)) continue;
296
+ const loss = lossOf(fact);
297
+ if (typeof loss === "string") skipped.push({ fact: fact.id, reason: loss });
298
+ else if (loss !== undefined) note("path.loss", loss.address, fact, binOf(PATH_LOSS_BINS, loss.share));
299
+ }
300
+
301
+ for (const fact of sorted.trace) {
302
+ skipped.push({ fact: fact.id, reason: "The catalog has no feature for the hops of a path. The lab runs fit such a feature later." });
303
+ }
304
+
305
+ const evidence = [...slots.values()].map(({ feature, value, primary, facts: ids }) => ({ feature, value, primary, facts: ids }));
306
+ return { evidence, skipped };
307
+ }
308
+
309
+ /**
310
+ * This function gives the share of the probes with an answer, for the feature `path.loss`. It gives a reason when a report
311
+ * of `mtr` gives no evidence, and `undefined` when an echo fact gives no share.
312
+ */
313
+ function lossOf(fact: Fact): { readonly address: string; readonly share: number } | string | undefined {
314
+ if (isFactOf(ICMP_ECHO, fact)) {
315
+ const echo = fact.value;
316
+ return echo.received > 0 && echo.sent >= LOSS_MIN_SENT ? { address: canonicalAddress(echo.address), share: echo.received / echo.sent } : undefined;
317
+ }
318
+ if (!isFactOf(PATH_REPORT, fact)) return undefined;
319
+ const report = fact.value;
320
+ const last = report.hops.at(-1);
321
+ if (report.outcome !== "reached" || last === undefined) return `The outcome "${report.outcome}" of ${fact.kind} has no value of the feature path.loss.`;
322
+ if (last.sent < LOSS_MIN_SENT) return `The last hop of the report has ${last.sent} probes. The feature path.loss needs ${LOSS_MIN_SENT} probes or more.`;
323
+ return { address: canonicalAddress(report.address), share: last.received / last.sent };
324
+ }
325
+
326
+ function statusValue(response: HttpResponse): string | undefined {
327
+ if (response.outcome === "error") return undefined;
328
+ if (response.outcome !== "response") return "none";
329
+ const status = response.status ?? 0;
330
+ if (status >= 200 && status < 400) return "ok";
331
+ if (status >= 400 && status < 500) return "client-error";
332
+ if (status >= 500 && status < 600) return "server-error";
333
+ return undefined;
334
+ }
335
+
336
+ /**
337
+ * This function gives the feature `ip.family` for a name with IPv4 and IPv6 addresses. It needs a TCP connect to an
338
+ * address of each family.
339
+ */
340
+ function readFamily(sorted: Facts, primaryOf: (fact: Fact) => boolean, note: (feature: string, group: string, fact: Fact, value: string) => void): void {
341
+ const names = new Map<string, { v4: Set<string>; v6: Set<string>; facts: Fact[]; primary: boolean }>();
342
+ for (const fact of sorted.dns) {
343
+ if (fact.value.outcome !== "ok") continue;
344
+ const key = `${primaryOf(fact) ? 1 : 0}\u0000${fact.value.name.toLowerCase()}`;
345
+ const entry = names.get(key) ?? { v4: new Set<string>(), v6: new Set<string>(), facts: [], primary: primaryOf(fact) };
346
+ for (const address of fact.value.addresses) (fact.value.record === "A" ? entry.v4 : entry.v6).add(address);
347
+ entry.facts.push(fact);
348
+ names.set(key, entry);
349
+ }
350
+ for (const [key, entry] of names) {
351
+ if (entry.v4.size === 0 || entry.v6.size === 0) continue;
352
+ const tried = sorted.tcp.filter((tcp) => primaryOf(tcp) === entry.primary && (entry.v4.has(tcp.value.address) || entry.v6.has(tcp.value.address)));
353
+ const works = (family: Set<string>): boolean | undefined => {
354
+ const connects = tried.filter((tcp) => family.has(tcp.value.address));
355
+ return connects.length === 0 ? undefined : connects.some((tcp) => tcp.value.outcome === "connected");
356
+ };
357
+ const v4 = works(entry.v4);
358
+ const v6 = works(entry.v6);
359
+ if (v4 === undefined || v6 === undefined || (!v4 && !v6)) continue;
360
+ const value = v4 && v6 ? "both" : v4 ? "ipv4-only" : "ipv6-only";
361
+ const evidenceFacts = [...entry.facts, ...tried];
362
+ const [first, ...rest] = evidenceFacts;
363
+ if (first === undefined) continue;
364
+ note("ip.family", key, first, value);
365
+ for (const fact of rest) note("ip.family", key, fact, value);
366
+ }
367
+ }
package/src/scope.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { BlockList, isIP } from "node:net";
1
+ import { isIP } from "node:net";
2
2
  import { err, ok, type Result } from "@mark1russell7/optional";
3
+ import { cidrContains, parseCidr } from "./address/index.ts";
3
4
  import { canonicalHost, formatTarget, type Target } from "./target.ts";
4
5
 
5
6
  /**
@@ -59,27 +60,10 @@ export type ScopeDecision =
59
60
  /** The ports that a runner sent packets to, for each host. */
60
61
  export type PortLedger = ReadonlyMap<string, ReadonlySet<number>>;
61
62
 
62
- interface Range {
63
- readonly address: string;
64
- readonly prefix: number;
65
- readonly family: "ipv4" | "ipv6";
66
- }
67
-
68
- function parseRange(cidr: string): Result<Range, string> {
69
- const slash = cidr.indexOf("/");
70
- const address = canonicalHost(slash === -1 ? cidr : cidr.slice(0, slash));
71
- const family = isIP(address);
72
- if (family === 0) return err(`"${cidr}" is not an IP address or a CIDR range.`);
73
- const max = family === 4 ? 32 : 128;
74
- const prefixText = slash === -1 ? String(max) : cidr.slice(slash + 1);
75
- if (!/^\d{1,3}$/.test(prefixText) || Number(prefixText) > max) return err(`"${cidr}" has no prefix from 0 to ${max}.`);
76
- return ok({ address, prefix: Number(prefixText), family: family === 4 ? "ipv4" : "ipv6" });
77
- }
78
-
79
63
  /** This function gives the problems of a grant, or an empty list for a correct grant. */
80
64
  export function grantProblems(grant: Grant): string[] {
81
65
  if (grant.kind === "range") {
82
- const range = parseRange(grant.cidr);
66
+ const range = parseCidr(grant.cidr);
83
67
  return range.tag === "err" ? [range.error] : [];
84
68
  }
85
69
  const host = canonicalHost(grant.host);
@@ -135,11 +119,8 @@ function grantFor(scope: Scope, peer: Target): Grant | undefined {
135
119
  continue;
136
120
  }
137
121
  if (family === 0) continue;
138
- const range = parseRange(grant.cidr);
139
- if (range.tag === "err" || (range.value.family === "ipv4") !== (family === 4)) continue;
140
- const list = new BlockList();
141
- list.addSubnet(range.value.address, range.value.prefix, range.value.family);
142
- if (list.check(host, family === 4 ? "ipv4" : "ipv6")) return grant;
122
+ const range = parseCidr(grant.cidr);
123
+ if (range.tag === "ok" && cidrContains(range.value, host)) return grant;
143
124
  }
144
125
  return undefined;
145
126
  }
@@ -0,0 +1,47 @@
1
+ import { addressFamily, canonicalAddress, type AddressFamily } from "../address/index.ts";
2
+
3
+ // The parts that the adapters share. The entry `@mark1russell7/netdiag/tools` does not export this module.
4
+
5
+ /** The environment that makes the output of a tool English on Linux and macOS. */
6
+ export const ENGLISH: Readonly<Record<string, string>> = { LC_ALL: "C" };
7
+
8
+ /** The end of a line of output, on each operating system. */
9
+ export const LINES: RegExp = /\r?\n/;
10
+
11
+ /** The messages of a local send that tell that the address is unreachable, on Linux and macOS. */
12
+ export const LOCAL_UNREACHABLE: RegExp = /\b(Network is unreachable|No route to host|Host is down|Network is down)\b/i;
13
+
14
+ /** The IP address of a probe of an outside tool: its canonical form and its family. */
15
+ export interface ToolAddress {
16
+ readonly address: string;
17
+ readonly family: AddressFamily;
18
+ }
19
+
20
+ /**
21
+ * This function gives the canonical form and the family of an IP address. It throws a `TypeError` for a name, because
22
+ * an adapter sends probes only to an address.
23
+ */
24
+ export function toolAddress(address: string): ToolAddress {
25
+ const family = addressFamily(address);
26
+ if (family === undefined) throw new TypeError(`"${address}" is not an IP address. Get the addresses of a name with the DNS probe first.`);
27
+ return { address: canonicalAddress(address), family };
28
+ }
29
+
30
+ /** This function throws a `RangeError` when a value is not an integer from `min` to `max`. It gives the value. */
31
+ export function integerIn(value: number, min: number, max: number, what: string): number {
32
+ if (!Number.isInteger(value) || value < min || value > max) throw new RangeError(`${value} is not ${what} from ${min} to ${max}.`);
33
+ return value;
34
+ }
35
+
36
+ /** This function tells if two IP addresses are the same address. */
37
+ export function sameAddress(a: string, b: string): boolean {
38
+ return canonicalAddress(a) === canonicalAddress(b);
39
+ }
40
+
41
+ /** This function gives the first line of a text that is not empty, with no spaces at its ends. */
42
+ export function firstLine(text: string): string | undefined {
43
+ return text
44
+ .split(LINES)
45
+ .find((line) => line.trim().length > 0)
46
+ ?.trim();
47
+ }
@@ -0,0 +1,91 @@
1
+ import type { Availability, ProbeContext } from "../probe.ts";
2
+
3
+ /**
4
+ * The output of one command of an outside tool.
5
+ *
6
+ * The function `runCommand` of `@procedures/shell` gives this shape, so it is a host with no change (refer to the README).
7
+ */
8
+ export interface ToolOutput {
9
+ /** The exit code of the program. */
10
+ readonly exitCode: number;
11
+ readonly stdout: string;
12
+ readonly stderr: string;
13
+ /** This field is true when the time limit stopped the program. */
14
+ readonly timedOut?: boolean | undefined;
15
+ /** The error that kept the program from its start, for example `spawn ping ENOENT`. */
16
+ readonly error?: string | undefined;
17
+ }
18
+
19
+ /** The options of one command. */
20
+ export interface ToolRunOptions {
21
+ /** The time limit of the command, in milliseconds. After the limit, the host stops the program. */
22
+ readonly timeoutMs: number;
23
+ readonly signal: AbortSignal;
24
+ /** The environment variables to add to the environment of the program, for example `LC_ALL=C` for English output. */
25
+ readonly env?: Readonly<Record<string, string>> | undefined;
26
+ }
27
+
28
+ /**
29
+ * The host of the outside tools: the operating system, and the function that starts a program.
30
+ *
31
+ * - `platform` has the values of `process.platform`, for example `linux`, `darwin` or `win32`.
32
+ * - `run` starts a program with a list of arguments and no shell. It does not throw. A failure is in the output.
33
+ */
34
+ export interface ToolHost {
35
+ readonly platform: string;
36
+ run(command: string, args: readonly string[], options: ToolRunOptions): Promise<ToolOutput>;
37
+ }
38
+
39
+ /** The raw output of a probe of an outside tool: the command, its output, and the times on the clock of the runner. */
40
+ export interface ToolRaw extends ToolOutput {
41
+ readonly platform: string;
42
+ readonly command: string;
43
+ readonly args: readonly string[];
44
+ readonly start: number;
45
+ readonly end: number;
46
+ }
47
+
48
+ /** The time limit of the command that examines if a tool is available, in milliseconds. */
49
+ export const AVAILABILITY_TIMEOUT_MS = 5000;
50
+
51
+ /** The time that an adapter adds to the time limit of a command, in milliseconds. In this time, a tool writes its last lines. */
52
+ export const TOOL_MARGIN_MS = 3000;
53
+
54
+ /**
55
+ * This function gives the availability of a tool from the output of a harmless command. A tool is available when its
56
+ * program starts. The first group of `version` gives the version of the tool.
57
+ */
58
+ export function availabilityOf(tool: string, output: ToolOutput, version?: RegExp): Availability {
59
+ if (output.error !== undefined) return { available: false, reason: `The tool ${tool} does not start: ${output.error}` };
60
+ const found = version?.exec(`${output.stdout}\n${output.stderr}`)?.[1];
61
+ return found === undefined ? { available: true } : { available: true, version: found };
62
+ }
63
+
64
+ /** A command of an outside tool: the program, its arguments, its time limit and the environment variables to add. */
65
+ export interface ToolCommand {
66
+ readonly command: string;
67
+ readonly args: readonly string[];
68
+ readonly timeoutMs: number;
69
+ readonly env?: Readonly<Record<string, string>> | undefined;
70
+ }
71
+
72
+ /** This function starts a command on the host, and gives its raw output with the times of the clock of the runner. */
73
+ export async function runTool(host: ToolHost, tool: ToolCommand, context: ProbeContext): Promise<ToolRaw> {
74
+ const { command, args, timeoutMs, env } = tool;
75
+ const start = context.clock.now();
76
+ const output = await host.run(command, args, env === undefined ? { timeoutMs, signal: context.signal } : { timeoutMs, signal: context.signal, env });
77
+ const end = context.clock.now();
78
+ context.signal.throwIfAborted();
79
+ return {
80
+ platform: host.platform,
81
+ command,
82
+ args: [...args],
83
+ start,
84
+ end,
85
+ exitCode: output.exitCode,
86
+ stdout: output.stdout,
87
+ stderr: output.stderr,
88
+ ...(output.timedOut === true ? { timedOut: true } : {}),
89
+ ...(output.error === undefined ? {} : { error: output.error }),
90
+ };
91
+ }
@@ -0,0 +1,38 @@
1
+ // The entry `@mark1russell7/netdiag/tools`: the adapters of outside tools. netdiag bundles no tool. The host starts each
2
+ // tool with a list of arguments and no shell.
3
+ export {
4
+ AVAILABILITY_TIMEOUT_MS,
5
+ availabilityOf,
6
+ runTool,
7
+ TOOL_MARGIN_MS,
8
+ type ToolCommand,
9
+ type ToolHost,
10
+ type ToolOutput,
11
+ type ToolRaw,
12
+ type ToolRunOptions,
13
+ } from "./host.ts";
14
+ export { TOOL_LICENSES, toolLicense, type ToolLicense, type ToolUse } from "./licenses.ts";
15
+ export {
16
+ DEFAULT_PING_COUNT,
17
+ DEFAULT_PING_WAIT_MS,
18
+ MAX_PING_COUNT,
19
+ PING6_LAST_WAIT_MS,
20
+ PING_INTERVAL_MS,
21
+ pingAvailability,
22
+ pingCommand,
23
+ pingProbe,
24
+ readPing,
25
+ type PingInput,
26
+ } from "./ping.ts";
27
+ export {
28
+ DEFAULT_TRACE_HOPS,
29
+ DEFAULT_TRACE_WAIT_MS,
30
+ MAX_TRACE_HOPS,
31
+ TRACE_QUERIES,
32
+ readTraceroute,
33
+ tracerouteAvailability,
34
+ tracerouteCommand,
35
+ tracerouteProbe,
36
+ type TraceInput,
37
+ } from "./traceroute.ts";
38
+ export { DEFAULT_MTR_CYCLES, MAX_MTR_CYCLES, MTR_GRACE_MS, mtrAvailability, mtrCommand, mtrProbe, readMtr, type MtrInput } from "./mtr.ts";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The use of an outside tool in netdiag.
3
+ *
4
+ * - `adapter`: netdiag has an optional adapter. The user installs the tool, and netdiag starts it when it is available.
5
+ * - `not-bundled`: netdiag does not start the tool, because its license is restrictive.
6
+ */
7
+ export type ToolUse = "adapter" | "not-bundled";
8
+
9
+ /** A row of the table of the licenses of the outside tools. */
10
+ export interface ToolLicense {
11
+ /** The name of the program, for example `ping`. */
12
+ readonly tool: string;
13
+ readonly license: string;
14
+ readonly use: ToolUse;
15
+ /** The id of the probe of the adapter. A planned adapter has no probe yet. */
16
+ readonly probe?: string | undefined;
17
+ }
18
+
19
+ /**
20
+ * The licenses of the outside tools, as data. netdiag bundles no tool. A test makes sure that the probe of each adapter
21
+ * has a row with the use `adapter`.
22
+ */
23
+ export const TOOL_LICENSES: readonly ToolLicense[] = [
24
+ { tool: "ping", license: "The license of the operating system", use: "adapter", probe: "icmp.ping" },
25
+ { tool: "traceroute", license: "GPL on Linux", use: "adapter", probe: "path.traceroute" },
26
+ { tool: "mtr", license: "GPL", use: "adapter", probe: "path.mtr" },
27
+ { tool: "tshark", license: "GPL", use: "adapter" },
28
+ { tool: "iperf3", license: "BSD", use: "adapter" },
29
+ { tool: "nmap", license: "NPSL", use: "not-bundled" },
30
+ { tool: "masscan", license: "AGPL", use: "not-bundled" },
31
+ { tool: "speedtest", license: "Ookla EULA", use: "not-bundled" },
32
+ ];
33
+
34
+ /** This function gives the row of a tool, or `undefined` for a tool that is not in the table. */
35
+ export function toolLicense(tool: string): ToolLicense | undefined {
36
+ return TOOL_LICENSES.find((row) => row.tool === tool);
37
+ }