@mark1russell7/netdiag 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/build/address.d.ts +56 -0
- package/build/address.d.ts.map +1 -0
- package/build/address.js +172 -0
- package/build/address.js.map +1 -0
- package/build/diagnosis-a4hRzFxp.js +922 -0
- package/build/diagnosis-a4hRzFxp.js.map +1 -0
- package/build/diagnosis.d.ts +8 -0
- package/build/diagnosis.js +1 -0
- package/build/index-K2dGiYxW.d.ts +259 -0
- package/build/index-K2dGiYxW.d.ts.map +1 -0
- package/build/index.d.ts +392 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1359 -0
- package/build/index.js.map +1 -0
- package/build/kinds-CQv769lK.js +341 -0
- package/build/kinds-CQv769lK.js.map +1 -0
- package/build/kinds-_NShfZpU.d.ts +377 -0
- package/build/kinds-_NShfZpU.d.ts.map +1 -0
- package/build/layer-BNg0VYuK.d.ts +22 -0
- package/build/layer-BNg0VYuK.d.ts.map +1 -0
- package/build/tools.d.ts +206 -0
- package/build/tools.d.ts.map +1 -0
- package/build/tools.js +896 -0
- package/build/tools.js.map +1 -0
- package/package.json +81 -3
- package/src/address/index.ts +155 -0
- package/src/diagnosis/catalog.ts +357 -0
- package/src/diagnosis/index.ts +47 -0
- package/src/diagnosis/model.ts +322 -0
- package/src/diagnosis/score.ts +160 -0
- package/src/fact.ts +87 -0
- package/src/index.ts +68 -0
- package/src/kinds.ts +460 -0
- package/src/layer.ts +31 -0
- package/src/probe.ts +61 -0
- package/src/probes/native.ts +354 -0
- package/src/probes/socket.ts +520 -0
- package/src/readers.ts +367 -0
- package/src/run.ts +163 -0
- package/src/scope.ts +152 -0
- package/src/target.ts +116 -0
- package/src/tools/common.ts +47 -0
- package/src/tools/host.ts +91 -0
- package/src/tools/index.ts +38 -0
- package/src/tools/licenses.ts +37 -0
- package/src/tools/mtr.ts +156 -0
- package/src/tools/ping.ts +231 -0
- package/src/tools/traceroute.ts +313 -0
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/run.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Clock } from "@mark1russell7/algebra/clock";
|
|
2
|
+
import type { Fact, FactTime, Observation } from "./fact.ts";
|
|
3
|
+
import type { Probe, ProbeContext } from "./probe.ts";
|
|
4
|
+
import { decideScope, widen, type Grant, type PortLedger, type Scope } from "./scope.ts";
|
|
5
|
+
import { canonicalHost, formatTarget, type Target } from "./target.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The outcome of one run of a probe.
|
|
9
|
+
*
|
|
10
|
+
* - `done`: the probe did its work. The facts include each network failure that it saw.
|
|
11
|
+
* - `refused`: a peer is not in the scope, so the probe did not start.
|
|
12
|
+
* - `unavailable`: the probe cannot operate on this computer.
|
|
13
|
+
* - `failed`: the probe or its parser has a fault. The raw output is present when the run ended.
|
|
14
|
+
* - `aborted`: the signal stopped the run.
|
|
15
|
+
*/
|
|
16
|
+
export type ProbeOutcome<R> =
|
|
17
|
+
| { readonly status: "done"; readonly time: FactTime; readonly raw: R; readonly facts: readonly Fact[] }
|
|
18
|
+
| { readonly status: "refused"; readonly peer: Target; readonly reason: string }
|
|
19
|
+
| { readonly status: "unavailable"; readonly reason: string }
|
|
20
|
+
| { readonly status: "failed"; readonly stage: "probe" | "run" | "parse"; readonly error: string; readonly raw?: R | undefined }
|
|
21
|
+
| { readonly status: "aborted"; readonly reason: string };
|
|
22
|
+
|
|
23
|
+
/** The wall-clock time of one monotonic time of the runner. */
|
|
24
|
+
export interface ClockOrigin {
|
|
25
|
+
readonly wall: string;
|
|
26
|
+
readonly monotonic: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The options of a runner. */
|
|
30
|
+
export interface RunnerOptions {
|
|
31
|
+
readonly scope: Scope;
|
|
32
|
+
readonly clock: Clock;
|
|
33
|
+
/** The wall-clock time of the start, as an ISO 8601 text. The default is the time of the computer. */
|
|
34
|
+
readonly wall?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A runner starts probes in one scope, and keeps the facts of the diagnosis.
|
|
39
|
+
*
|
|
40
|
+
* It gives each fact an id in sequence. It keeps the ledger of the ports of each host, for the limit of ports of the scope.
|
|
41
|
+
*/
|
|
42
|
+
export interface Runner {
|
|
43
|
+
readonly origin: ClockOrigin;
|
|
44
|
+
scope(): Scope;
|
|
45
|
+
facts(): readonly Fact[];
|
|
46
|
+
/** This method adds grants to the scope, for example the addresses of a granted name. */
|
|
47
|
+
grant(grants: readonly Grant[]): void;
|
|
48
|
+
run<I, R>(probe: Probe<I, R>, input: I, signal?: AbortSignal): Promise<ProbeOutcome<R>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function message(thrown: unknown): string {
|
|
52
|
+
if (thrown instanceof Error) return thrown.message;
|
|
53
|
+
return String(thrown);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function abortReason(signal: AbortSignal): string {
|
|
57
|
+
const reason: unknown = signal.reason;
|
|
58
|
+
return reason === undefined ? "The signal stopped the run." : message(reason);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** This function makes a runner. */
|
|
62
|
+
export function createRunner(options: RunnerOptions): Runner {
|
|
63
|
+
const { clock } = options;
|
|
64
|
+
const origin: ClockOrigin = { wall: options.wall ?? new Date().toISOString(), monotonic: clock.now() };
|
|
65
|
+
let scope = options.scope;
|
|
66
|
+
const facts: Fact[] = [];
|
|
67
|
+
let ledger: PortLedger = new Map();
|
|
68
|
+
let next = 1;
|
|
69
|
+
|
|
70
|
+
/** This function gives the ledger with the ports of the peers, or the refusal of the first peer out of the scope. */
|
|
71
|
+
function reserve(peers: readonly Target[]): PortLedger | { readonly peer: Target; readonly reason: string } {
|
|
72
|
+
const planned = new Map([...ledger].map(([host, ports]) => [host, new Set(ports)]));
|
|
73
|
+
for (const peer of peers) {
|
|
74
|
+
const decision = decideScope(scope, peer, planned);
|
|
75
|
+
if (!decision.allowed) return { peer, reason: decision.reason };
|
|
76
|
+
if (peer.port === undefined) continue;
|
|
77
|
+
const host = canonicalHost(peer.host);
|
|
78
|
+
planned.set(host, (planned.get(host) ?? new Set<number>()).add(peer.port));
|
|
79
|
+
}
|
|
80
|
+
return planned;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stamp<I, R>(probe: Probe<I, R>, observations: readonly Observation[], time: FactTime): Fact[] | string {
|
|
84
|
+
const stamped: Fact[] = [];
|
|
85
|
+
for (const observation of observations) {
|
|
86
|
+
const { kind } = observation;
|
|
87
|
+
if (!probe.kinds.some((known) => known.kind === kind.kind)) return `The probe gave the kind "${kind.kind}", which it does not declare.`;
|
|
88
|
+
if (!kind.check(observation.value)) return `The value of a "${kind.kind}" fact does not pass the check of its kind.`;
|
|
89
|
+
stamped.push({
|
|
90
|
+
id: `f${next + stamped.length}`,
|
|
91
|
+
kind: kind.kind,
|
|
92
|
+
layer: kind.layer,
|
|
93
|
+
subject: observation.subject,
|
|
94
|
+
time: observation.time ?? time,
|
|
95
|
+
source: probe.tool === undefined ? { probe: probe.id, version: probe.version } : { probe: probe.id, version: probe.version, tool: probe.tool },
|
|
96
|
+
value: observation.value,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return stamped;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function run<I, R>(probe: Probe<I, R>, input: I, signal: AbortSignal = new AbortController().signal): Promise<ProbeOutcome<R>> {
|
|
103
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
104
|
+
let peers: readonly Target[];
|
|
105
|
+
try {
|
|
106
|
+
peers = probe.peers(input);
|
|
107
|
+
} catch (thrown) {
|
|
108
|
+
return { status: "failed", stage: "probe", error: message(thrown) };
|
|
109
|
+
}
|
|
110
|
+
if (probe.mode === "passive" && peers.length > 0) {
|
|
111
|
+
return { status: "failed", stage: "probe", error: `The passive probe "${probe.id}" gives peers: ${peers.map(formatTarget).join(", ")}.` };
|
|
112
|
+
}
|
|
113
|
+
if (probe.mode === "active" && peers.length === 0) {
|
|
114
|
+
return { status: "failed", stage: "probe", error: `The active probe "${probe.id}" gives no peer.` };
|
|
115
|
+
}
|
|
116
|
+
// The decision and the reservation of the ports happen with no await between them. Thus two runs at the same time
|
|
117
|
+
// cannot both pass the limit of ports. A run that does not start keeps its reservation: the ledger only grows.
|
|
118
|
+
const reserved = reserve(peers);
|
|
119
|
+
if ("reason" in reserved) return { status: "refused", peer: reserved.peer, reason: reserved.reason };
|
|
120
|
+
ledger = reserved;
|
|
121
|
+
const context: ProbeContext = { clock, signal };
|
|
122
|
+
let availability;
|
|
123
|
+
try {
|
|
124
|
+
availability = await probe.available(context);
|
|
125
|
+
} catch (thrown) {
|
|
126
|
+
return { status: "failed", stage: "probe", error: message(thrown) };
|
|
127
|
+
}
|
|
128
|
+
if (!availability.available) return { status: "unavailable", reason: availability.reason };
|
|
129
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
130
|
+
const start = clock.now();
|
|
131
|
+
let raw: R;
|
|
132
|
+
try {
|
|
133
|
+
raw = await probe.run(input, context);
|
|
134
|
+
} catch (thrown) {
|
|
135
|
+
if (signal.aborted) return { status: "aborted", reason: abortReason(signal) };
|
|
136
|
+
return { status: "failed", stage: "run", error: message(thrown) };
|
|
137
|
+
}
|
|
138
|
+
const time: FactTime = { start, end: clock.now() };
|
|
139
|
+
let observations: readonly Observation[];
|
|
140
|
+
try {
|
|
141
|
+
observations = probe.parse(raw, input);
|
|
142
|
+
} catch (thrown) {
|
|
143
|
+
return { status: "failed", stage: "parse", error: message(thrown), raw };
|
|
144
|
+
}
|
|
145
|
+
const stamped = stamp(probe, observations, time);
|
|
146
|
+
if (typeof stamped === "string") return { status: "failed", stage: "parse", error: stamped, raw };
|
|
147
|
+
facts.push(...stamped);
|
|
148
|
+
next += stamped.length;
|
|
149
|
+
return { status: "done", time, raw, facts: stamped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
origin,
|
|
154
|
+
scope: () => scope,
|
|
155
|
+
facts: () => [...facts],
|
|
156
|
+
grant(grants) {
|
|
157
|
+
const widened = widen(scope, grants);
|
|
158
|
+
if (widened.tag === "err") throw new RangeError(widened.error.join(" "));
|
|
159
|
+
scope = widened.value;
|
|
160
|
+
},
|
|
161
|
+
run,
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
3
|
+
import { cidrContains, parseCidr } from "./address/index.ts";
|
|
4
|
+
import { canonicalHost, formatTarget, type Target } from "./target.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The basis of a grant: why a target is in the scope.
|
|
8
|
+
*
|
|
9
|
+
* - `named`: the user named the target for this diagnosis.
|
|
10
|
+
* - `owned`: the user declares that the user owns the network or the host.
|
|
11
|
+
* - `configured`: the configuration of the computer of the user names it, for example the gateway or the DNS resolver.
|
|
12
|
+
* - `resolved`: a DNS answer for a granted name gave this address.
|
|
13
|
+
*/
|
|
14
|
+
export type GrantBasis = "named" | "owned" | "configured" | "resolved";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A grant puts targets in the scope.
|
|
18
|
+
*
|
|
19
|
+
* A `host` grant puts a DNS name in the scope, and with `subdomains` each name under it. A `range` grant puts the
|
|
20
|
+
* addresses of a CIDR range in the scope, for example `192.0.2.0/24` or `2001:db8::/32`.
|
|
21
|
+
*/
|
|
22
|
+
export type Grant = HostGrant | RangeGrant;
|
|
23
|
+
|
|
24
|
+
/** A grant of a DNS name, and with `subdomains` of each name under it. */
|
|
25
|
+
export interface HostGrant {
|
|
26
|
+
readonly kind: "host";
|
|
27
|
+
readonly host: string;
|
|
28
|
+
readonly subdomains?: boolean | undefined;
|
|
29
|
+
readonly basis: GrantBasis;
|
|
30
|
+
readonly note?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A grant of the addresses of a CIDR range, for example `192.0.2.0/24` or `2001:db8::/32`. */
|
|
34
|
+
export interface RangeGrant {
|
|
35
|
+
readonly kind: "range";
|
|
36
|
+
readonly cidr: string;
|
|
37
|
+
readonly basis: GrantBasis;
|
|
38
|
+
readonly note?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The scope of a diagnosis: the grants, and the largest number of ports for each host.
|
|
43
|
+
*
|
|
44
|
+
* An active probe sends packets only to a peer in the scope. The limit of ports stops a port scan: netdiag does no
|
|
45
|
+
* vulnerability scan.
|
|
46
|
+
*/
|
|
47
|
+
export interface Scope {
|
|
48
|
+
readonly grants: readonly Grant[];
|
|
49
|
+
readonly maxPortsPerHost: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The default limit of the different ports of one host in one runner. */
|
|
53
|
+
export const DEFAULT_MAX_PORTS_PER_HOST = 8;
|
|
54
|
+
|
|
55
|
+
/** The decision of the scope for one peer of an active probe. A refusal has the reason in plain words. */
|
|
56
|
+
export type ScopeDecision =
|
|
57
|
+
| { readonly allowed: true; readonly grant: Grant }
|
|
58
|
+
| { readonly allowed: false; readonly reason: string };
|
|
59
|
+
|
|
60
|
+
/** The ports that a runner sent packets to, for each host. */
|
|
61
|
+
export type PortLedger = ReadonlyMap<string, ReadonlySet<number>>;
|
|
62
|
+
|
|
63
|
+
/** This function gives the problems of a grant, or an empty list for a correct grant. */
|
|
64
|
+
export function grantProblems(grant: Grant): string[] {
|
|
65
|
+
if (grant.kind === "range") {
|
|
66
|
+
const range = parseCidr(grant.cidr);
|
|
67
|
+
return range.tag === "err" ? [range.error] : [];
|
|
68
|
+
}
|
|
69
|
+
const host = canonicalHost(grant.host);
|
|
70
|
+
if (host.length === 0) return ["A host grant has an empty host."];
|
|
71
|
+
if (isIP(host) !== 0) return [`The host grant "${grant.host}" is an IP address. Use a range grant for an address.`];
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* This function makes a scope from grants. It refuses a grant with a problem, so a typing error cannot widen the scope.
|
|
77
|
+
*
|
|
78
|
+
* The default limit of ports is `DEFAULT_MAX_PORTS_PER_HOST`.
|
|
79
|
+
*/
|
|
80
|
+
export function scopeOf(grants: readonly Grant[], options: { readonly maxPortsPerHost?: number } = {}): Result<Scope, string[]> {
|
|
81
|
+
const problems = grants.flatMap(grantProblems);
|
|
82
|
+
const maxPortsPerHost = options.maxPortsPerHost ?? DEFAULT_MAX_PORTS_PER_HOST;
|
|
83
|
+
if (!Number.isInteger(maxPortsPerHost) || maxPortsPerHost < 1) problems.push("The limit of ports for each host is not a positive integer.");
|
|
84
|
+
if (problems.length > 0) return err(problems);
|
|
85
|
+
return ok({ grants: [...grants], maxPortsPerHost });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* This function gives the grant for a target that the user names. A name gets a host grant. An IP address gets a
|
|
90
|
+
* range grant of one address.
|
|
91
|
+
*/
|
|
92
|
+
export function namedGrant(t: Target, note?: string): Grant {
|
|
93
|
+
if (t.kind === "name") return { kind: "host", host: t.host, basis: "named", note };
|
|
94
|
+
return { kind: "range", cidr: `${t.host}/${t.kind === "ipv4" ? 32 : 128}`, basis: "named", note };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** This function gives the grant of one address that a DNS answer for a granted name gave. */
|
|
98
|
+
export function resolvedGrant(name: string, address: string, factId?: string): RangeGrant {
|
|
99
|
+
const host = canonicalHost(address);
|
|
100
|
+
const family = isIP(host);
|
|
101
|
+
if (family === 0) throw new RangeError(`"${address}" is not an IP address`);
|
|
102
|
+
const note = factId === undefined ? `${canonicalHost(name)} resolved to it` : `${canonicalHost(name)} resolved to it (fact ${factId})`;
|
|
103
|
+
return { kind: "range", cidr: `${host}/${family === 4 ? 32 : 128}`, basis: "resolved", note };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** This function gives a scope with more grants. The limit of ports does not change. */
|
|
107
|
+
export function widen(scope: Scope, grants: readonly Grant[]): Result<Scope, string[]> {
|
|
108
|
+
return scopeOf([...scope.grants, ...grants], { maxPortsPerHost: scope.maxPortsPerHost });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function grantFor(scope: Scope, peer: Target): Grant | undefined {
|
|
112
|
+
const host = canonicalHost(peer.host);
|
|
113
|
+
const family = isIP(host);
|
|
114
|
+
for (const grant of scope.grants) {
|
|
115
|
+
if (grant.kind === "host") {
|
|
116
|
+
if (family !== 0) continue;
|
|
117
|
+
const granted = canonicalHost(grant.host);
|
|
118
|
+
if (host === granted || (grant.subdomains === true && host.endsWith(`.${granted}`))) return grant;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (family === 0) continue;
|
|
122
|
+
const range = parseCidr(grant.cidr);
|
|
123
|
+
if (range.tag === "ok" && cidrContains(range.value, host)) return grant;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* This function decides if an active probe can send packets to a peer.
|
|
130
|
+
*
|
|
131
|
+
* The peer must match a grant of the scope. A new port of a host must also stay in the limit of ports for each host.
|
|
132
|
+
* The ledger has the ports that the runner sent packets to before.
|
|
133
|
+
*/
|
|
134
|
+
export function decideScope(scope: Scope, peer: Target, ledger: PortLedger = new Map()): ScopeDecision {
|
|
135
|
+
const grant = grantFor(scope, peer);
|
|
136
|
+
if (grant === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
allowed: false,
|
|
139
|
+
reason: `${formatTarget(peer)} is not in the scope. Name it, or add a grant for a network that you own.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (peer.port !== undefined) {
|
|
143
|
+
const ports = ledger.get(canonicalHost(peer.host)) ?? new Set<number>();
|
|
144
|
+
if (!ports.has(peer.port) && ports.size >= scope.maxPortsPerHost) {
|
|
145
|
+
return {
|
|
146
|
+
allowed: false,
|
|
147
|
+
reason: `${formatTarget(peer)} is a new port after ${ports.size} ports of the same host. netdiag does no port scan.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { allowed: true, grant };
|
|
152
|
+
}
|