@mark1russell7/netdiag 0.0.0-bootstrap.0 → 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/LICENSE +21 -0
- package/README.md +91 -1
- package/build/diagnosis-BTa-Dfwl.js +820 -0
- package/build/diagnosis-BTa-Dfwl.js.map +1 -0
- package/build/diagnosis.d.ts +7 -0
- package/build/diagnosis.js +1 -0
- package/build/index-CP1hzELI.d.ts +198 -0
- package/build/index-CP1hzELI.d.ts.map +1 -0
- package/build/index.d.ts +287 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +436 -0
- package/build/index.js.map +1 -0
- package/package.json +63 -4
- package/src/diagnosis/catalog.ts +357 -0
- package/src/diagnosis/index.ts +34 -0
- package/src/diagnosis/model.ts +322 -0
- package/src/fact.ts +87 -0
- package/src/index.ts +33 -0
- package/src/layer.ts +31 -0
- package/src/probe.ts +61 -0
- package/src/run.ts +163 -0
- package/src/scope.ts +171 -0
- package/src/target.ts +116 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
2
|
+
import type { Layer } from "../layer.ts";
|
|
3
|
+
|
|
4
|
+
/** A probability for each value of a feature. The probabilities are more than 0, and their sum is 1. */
|
|
5
|
+
export type Distribution = Readonly<Record<string, number>>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A feature is one discrete observation that the facts give, for example the outcome of a TCP connect.
|
|
9
|
+
*
|
|
10
|
+
* The distribution `healthy` gives the probability of each value when the network has no fault.
|
|
11
|
+
*/
|
|
12
|
+
export interface Feature {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly layer: Layer;
|
|
15
|
+
readonly summary: string;
|
|
16
|
+
readonly values: readonly string[];
|
|
17
|
+
readonly healthy: Distribution;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The parts of the model. Each fault is in one part. */
|
|
21
|
+
export const PARTS = ["interface", "wifi", "gateway", "resolver", "path", "service"] as const;
|
|
22
|
+
|
|
23
|
+
/** A part of the model: the network interface, the Wi-Fi link, the gateway, the DNS resolver, the path or the remote service. */
|
|
24
|
+
export type Part = (typeof PARTS)[number];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reach of a fault.
|
|
28
|
+
*
|
|
29
|
+
* - `shared`: the fault changes the observations of each host, for example a TLS proxy of the local network.
|
|
30
|
+
* - `target`: the fault changes only the observations of the primary target, for example a slow server.
|
|
31
|
+
*/
|
|
32
|
+
export type Reach = "shared" | "target";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The strength of a prediction. The rules of practice use these strengths. The lab runs then replace them with fitted
|
|
36
|
+
* distributions.
|
|
37
|
+
*/
|
|
38
|
+
export type Strength = "sure" | "strong" | "usual" | "weak";
|
|
39
|
+
|
|
40
|
+
/** The probability that each strength gives to the expected values together. */
|
|
41
|
+
export const STRENGTHS: Readonly<Record<Strength, number>> = { sure: 0.98, strong: 0.9, usual: 0.75, weak: 0.6 };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A prediction of a fault for one feature: a full distribution, or the expected values and a strength.
|
|
45
|
+
*
|
|
46
|
+
* With `expect`, the expected values share the probability of the strength equally. The other values share the rest.
|
|
47
|
+
*/
|
|
48
|
+
export type Prediction = Distribution | Expectation;
|
|
49
|
+
|
|
50
|
+
/** The expected values of a feature, and the strength of the prediction. */
|
|
51
|
+
export interface Expectation {
|
|
52
|
+
readonly expect: string | readonly string[];
|
|
53
|
+
readonly strength: Strength;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExpectation(prediction: Prediction): prediction is Expectation {
|
|
57
|
+
return typeof prediction.strength === "string";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A fault of the catalog.
|
|
62
|
+
*
|
|
63
|
+
* The fault predicts a distribution for each feature that it changes. For each other feature, it predicts the
|
|
64
|
+
* distribution `healthy` of the feature. The prior is a relative weight. The default prior is 1.
|
|
65
|
+
*/
|
|
66
|
+
export interface Fault {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly part: Part;
|
|
69
|
+
readonly reach: Reach;
|
|
70
|
+
/** The cause in plain words, for example "No program listens on the port." */
|
|
71
|
+
readonly summary: string;
|
|
72
|
+
/** The next step for the user in plain words. */
|
|
73
|
+
readonly step: string;
|
|
74
|
+
readonly prior?: number | undefined;
|
|
75
|
+
readonly predicts: Readonly<Record<string, Prediction>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The data of a catalog: the features, the faults, and the priors of the hypotheses "no fault" and "unknown cause". */
|
|
79
|
+
export interface CatalogSpec {
|
|
80
|
+
readonly version: string;
|
|
81
|
+
readonly features: readonly Feature[];
|
|
82
|
+
readonly faults: readonly Fault[];
|
|
83
|
+
readonly priors?: { readonly none?: number | undefined; readonly unknown?: number | undefined } | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A hypothesis of the model.
|
|
88
|
+
*
|
|
89
|
+
* - `none`: the network has no fault.
|
|
90
|
+
* - `fault`: one fault of the catalog.
|
|
91
|
+
* - `unknown`: a cause that the catalog does not have. It gives each value of a feature the same probability.
|
|
92
|
+
*/
|
|
93
|
+
export interface Hypothesis {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
readonly kind: "none" | "fault" | "unknown";
|
|
96
|
+
readonly summary: string;
|
|
97
|
+
readonly step: string;
|
|
98
|
+
readonly part?: Part | undefined;
|
|
99
|
+
readonly reach?: Reach | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The id of the hypothesis "no fault". */
|
|
103
|
+
export const NONE = "none";
|
|
104
|
+
/** The id of the hypothesis "unknown cause". */
|
|
105
|
+
export const UNKNOWN = "unknown";
|
|
106
|
+
|
|
107
|
+
/** A catalog with each distribution in its full form, and the logarithms of the probabilities. */
|
|
108
|
+
export interface Catalog {
|
|
109
|
+
readonly version: string;
|
|
110
|
+
readonly features: ReadonlyMap<string, Feature>;
|
|
111
|
+
readonly hypotheses: readonly Hypothesis[];
|
|
112
|
+
/** This function gives the probability of a value of a feature under a hypothesis, for a primary or a reference host. */
|
|
113
|
+
likelihood(hypothesis: string, feature: string, value: string, primary: boolean): number;
|
|
114
|
+
prior(hypothesis: string): number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The lowest probability of a value. With a probability of 0, one wrong observation removes a cause. */
|
|
118
|
+
export const FLOOR = 0.001;
|
|
119
|
+
|
|
120
|
+
const SUM_TOLERANCE = 1e-9;
|
|
121
|
+
|
|
122
|
+
function distributionProblems(where: string, feature: Feature, dist: Distribution): string[] {
|
|
123
|
+
const problems: string[] = [];
|
|
124
|
+
for (const value of Object.keys(dist)) if (!feature.values.includes(value)) problems.push(`${where}: "${value}" is not a value of ${feature.id}.`);
|
|
125
|
+
for (const value of feature.values) {
|
|
126
|
+
const p = dist[value];
|
|
127
|
+
if (p === undefined) problems.push(`${where}: the value "${value}" of ${feature.id} has no probability.`);
|
|
128
|
+
else if (!Number.isFinite(p) || p < FLOOR) problems.push(`${where}: the probability of "${value}" is ${p}. It must be ${FLOOR} or more.`);
|
|
129
|
+
}
|
|
130
|
+
const sum = Object.values(dist).reduce((a, b) => a + b, 0);
|
|
131
|
+
if (Math.abs(sum - 1) > SUM_TOLERANCE) problems.push(`${where}: the sum of the probabilities is ${sum}, not 1.`);
|
|
132
|
+
return problems;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** This function gives the full distribution of a prediction, or the problems of the prediction. */
|
|
136
|
+
export function expand(feature: Feature, prediction: Prediction): Result<Distribution, string[]> {
|
|
137
|
+
if (!isExpectation(prediction)) return ok(prediction);
|
|
138
|
+
const expected = typeof prediction.expect === "string" ? [prediction.expect] : [...prediction.expect];
|
|
139
|
+
const unknown = expected.filter((value) => !feature.values.includes(value));
|
|
140
|
+
if (unknown.length > 0) return err(unknown.map((value) => `"${value}" is not a value of ${feature.id}.`));
|
|
141
|
+
if (expected.length === 0 || new Set(expected).size !== expected.length) return err([`The expected values of ${feature.id} are empty or repeated.`]);
|
|
142
|
+
const others = feature.values.filter((value) => !expected.includes(value));
|
|
143
|
+
const mass = others.length === 0 ? 1 : STRENGTHS[prediction.strength];
|
|
144
|
+
const dist: Record<string, number> = {};
|
|
145
|
+
for (const value of expected) dist[value] = mass / expected.length;
|
|
146
|
+
for (const value of others) dist[value] = (1 - mass) / others.length;
|
|
147
|
+
return ok(dist);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* This function examines a catalog and makes its tables.
|
|
154
|
+
*
|
|
155
|
+
* It refuses a repeated id, an unknown feature or value, a probability below `FLOOR`, and a distribution with a sum that is not 1.
|
|
156
|
+
*/
|
|
157
|
+
export function compileCatalog(spec: CatalogSpec): Result<Catalog, string[]> {
|
|
158
|
+
const problems: string[] = [];
|
|
159
|
+
const features = new Map<string, Feature>();
|
|
160
|
+
for (const feature of spec.features) {
|
|
161
|
+
if (!ID.test(feature.id)) problems.push(`"${feature.id}" is not a valid feature id.`);
|
|
162
|
+
if (features.has(feature.id)) problems.push(`The feature ${feature.id} is repeated.`);
|
|
163
|
+
if (feature.values.length < 2 || new Set(feature.values).size !== feature.values.length) {
|
|
164
|
+
problems.push(`The feature ${feature.id} must have two or more different values.`);
|
|
165
|
+
}
|
|
166
|
+
problems.push(...distributionProblems(`healthy of ${feature.id}`, feature, feature.healthy));
|
|
167
|
+
features.set(feature.id, feature);
|
|
168
|
+
}
|
|
169
|
+
const hypotheses: Hypothesis[] = [{ id: NONE, kind: "none", summary: "The network has no fault.", step: "No step is necessary." }];
|
|
170
|
+
const priors = new Map<string, number>([[NONE, spec.priors?.none ?? 1]]);
|
|
171
|
+
const tables = new Map<string, Map<string, Distribution>>();
|
|
172
|
+
for (const fault of spec.faults) {
|
|
173
|
+
if (!ID.test(fault.id) || fault.id === NONE || fault.id === UNKNOWN) problems.push(`"${fault.id}" is not a valid fault id.`);
|
|
174
|
+
if (priors.has(fault.id)) problems.push(`The fault ${fault.id} is repeated.`);
|
|
175
|
+
priors.set(fault.id, fault.prior ?? 1);
|
|
176
|
+
hypotheses.push({ id: fault.id, kind: "fault", summary: fault.summary, step: fault.step, part: fault.part, reach: fault.reach });
|
|
177
|
+
const table = new Map<string, Distribution>();
|
|
178
|
+
for (const [featureId, prediction] of Object.entries(fault.predicts)) {
|
|
179
|
+
const feature = features.get(featureId);
|
|
180
|
+
if (feature === undefined) {
|
|
181
|
+
problems.push(`The fault ${fault.id} predicts the unknown feature ${featureId}.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const dist = expand(feature, prediction);
|
|
185
|
+
if (dist.tag === "err") problems.push(...dist.error.map((problem) => `${fault.id}: ${problem}`));
|
|
186
|
+
else {
|
|
187
|
+
problems.push(...distributionProblems(`${fault.id} for ${featureId}`, feature, dist.value));
|
|
188
|
+
table.set(featureId, dist.value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (table.size === 0) problems.push(`The fault ${fault.id} predicts no feature.`);
|
|
192
|
+
tables.set(fault.id, table);
|
|
193
|
+
}
|
|
194
|
+
hypotheses.push({
|
|
195
|
+
id: UNKNOWN,
|
|
196
|
+
kind: "unknown",
|
|
197
|
+
summary: "The catalog does not have the cause.",
|
|
198
|
+
step: "Collect more facts, and report the facts as a new fault of the catalog.",
|
|
199
|
+
});
|
|
200
|
+
priors.set(UNKNOWN, spec.priors?.unknown ?? 1);
|
|
201
|
+
for (const [id, prior] of priors) if (!Number.isFinite(prior) || prior <= 0) problems.push(`The prior of ${id} is ${prior}. It must be more than 0.`);
|
|
202
|
+
if (problems.length > 0) return err(problems);
|
|
203
|
+
|
|
204
|
+
const total = [...priors.values()].reduce((a, b) => a + b, 0);
|
|
205
|
+
const reach = new Map(hypotheses.map((h) => [h.id, h.reach]));
|
|
206
|
+
return ok({
|
|
207
|
+
version: spec.version,
|
|
208
|
+
features,
|
|
209
|
+
hypotheses,
|
|
210
|
+
prior: (hypothesis) => (priors.get(hypothesis) ?? 0) / total,
|
|
211
|
+
likelihood(hypothesis, featureId, value, primary) {
|
|
212
|
+
const feature = features.get(featureId);
|
|
213
|
+
if (feature === undefined || !feature.values.includes(value)) return Number.NaN;
|
|
214
|
+
if (hypothesis === UNKNOWN) return 1 / feature.values.length;
|
|
215
|
+
const fault = tables.get(hypothesis);
|
|
216
|
+
const changed = fault?.get(featureId);
|
|
217
|
+
const applies = primary || reach.get(hypothesis) === "shared";
|
|
218
|
+
return (applies && changed !== undefined ? changed : feature.healthy)[value] ?? Number.NaN;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One observation of a feature.
|
|
225
|
+
*
|
|
226
|
+
* `primary` tells if the observation is of the primary target or of a reference host. `facts` gives the ids of the facts
|
|
227
|
+
* that gave the observation, for the explanation.
|
|
228
|
+
*/
|
|
229
|
+
export interface Evidence {
|
|
230
|
+
readonly feature: string;
|
|
231
|
+
readonly value: string;
|
|
232
|
+
readonly primary: boolean;
|
|
233
|
+
readonly facts?: readonly string[] | undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A cause with its probability. */
|
|
237
|
+
export interface Cause extends Hypothesis {
|
|
238
|
+
readonly probability: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The result of a diagnosis: the causes from the most probable, and the evidence that the model did not use, with the reason. */
|
|
242
|
+
export interface Diagnosis {
|
|
243
|
+
readonly causes: readonly Cause[];
|
|
244
|
+
readonly used: readonly Evidence[];
|
|
245
|
+
readonly ignored: readonly { readonly evidence: Evidence; readonly reason: string }[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function usable(catalog: Catalog, evidence: readonly Evidence[]): Pick<Diagnosis, "used" | "ignored"> {
|
|
249
|
+
const used: Evidence[] = [];
|
|
250
|
+
const ignored: { evidence: Evidence; reason: string }[] = [];
|
|
251
|
+
for (const item of evidence) {
|
|
252
|
+
const feature = catalog.features.get(item.feature);
|
|
253
|
+
if (feature === undefined) ignored.push({ evidence: item, reason: `The catalog has no feature ${item.feature}.` });
|
|
254
|
+
else if (!feature.values.includes(item.value)) ignored.push({ evidence: item, reason: `"${item.value}" is not a value of ${item.feature}.` });
|
|
255
|
+
else used.push(item);
|
|
256
|
+
}
|
|
257
|
+
return { used, ignored };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* This function gives the probability of each hypothesis from the evidence, with the rule of Bayes.
|
|
262
|
+
*
|
|
263
|
+
* The model takes the observations as independent for a given hypothesis. The calculation uses logarithms, so a long
|
|
264
|
+
* list of evidence does not go to zero. The order of the evidence does not change the result.
|
|
265
|
+
* An exact tie keeps the order of the catalog, with "no fault" first.
|
|
266
|
+
*/
|
|
267
|
+
export function diagnose(catalog: Catalog, evidence: readonly Evidence[]): Diagnosis {
|
|
268
|
+
const { used, ignored } = usable(catalog, evidence);
|
|
269
|
+
const logs = catalog.hypotheses.map((h) => {
|
|
270
|
+
let sum = Math.log(catalog.prior(h.id));
|
|
271
|
+
for (const item of used) sum += Math.log(catalog.likelihood(h.id, item.feature, item.value, item.primary));
|
|
272
|
+
return sum;
|
|
273
|
+
});
|
|
274
|
+
const top = Math.max(...logs);
|
|
275
|
+
const weights = logs.map((log) => Math.exp(log - top));
|
|
276
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
277
|
+
// An exact tie keeps the order of the catalog: "no fault" first, then the faults, then "unknown cause". Thus a fault
|
|
278
|
+
// whose features are not observed does not come before "no fault".
|
|
279
|
+
const causes = catalog.hypotheses
|
|
280
|
+
.map((h, i) => ({ ...h, probability: (weights[i] ?? 0) / total, order: i }))
|
|
281
|
+
.sort((a, b) => b.probability - a.probability || a.order - b.order)
|
|
282
|
+
.map(({ order: _order, ...cause }) => cause);
|
|
283
|
+
return { causes, used, ignored };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The part of one observation in the comparison of two hypotheses. */
|
|
287
|
+
export interface Contribution {
|
|
288
|
+
readonly evidence: Evidence;
|
|
289
|
+
/** The ratio of the likelihoods of the two hypotheses, as a base-10 logarithm. 1 is a factor of 10 for the first hypothesis. */
|
|
290
|
+
readonly weight: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* This function tells why the model prefers one hypothesis to a second hypothesis.
|
|
295
|
+
*
|
|
296
|
+
* It gives each observation with its weight, from the largest weight in absolute value. The default second hypothesis is
|
|
297
|
+
* "no fault".
|
|
298
|
+
*/
|
|
299
|
+
export function explain(catalog: Catalog, evidence: readonly Evidence[], hypothesis: string, versus: string = NONE): Contribution[] {
|
|
300
|
+
const { used } = usable(catalog, evidence);
|
|
301
|
+
return used
|
|
302
|
+
.map((item) => ({
|
|
303
|
+
evidence: item,
|
|
304
|
+
weight: Math.log10(catalog.likelihood(hypothesis, item.feature, item.value, item.primary)) -
|
|
305
|
+
Math.log10(catalog.likelihood(versus, item.feature, item.value, item.primary)),
|
|
306
|
+
}))
|
|
307
|
+
.sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* This function gives the probability of each value of a feature that is not observed yet, from the probabilities of
|
|
312
|
+
* the causes. The choice of the next probe uses it.
|
|
313
|
+
*/
|
|
314
|
+
export function predictive(catalog: Catalog, diagnosis: Diagnosis, feature: string, primary: boolean): Distribution {
|
|
315
|
+
const known = catalog.features.get(feature);
|
|
316
|
+
if (known === undefined) throw new RangeError(`The catalog has no feature ${feature}`);
|
|
317
|
+
const dist: Record<string, number> = {};
|
|
318
|
+
for (const value of known.values) {
|
|
319
|
+
dist[value] = diagnosis.causes.reduce((sum, cause) => sum + cause.probability * catalog.likelihood(cause.id, feature, value, primary), 0);
|
|
320
|
+
}
|
|
321
|
+
return dist;
|
|
322
|
+
}
|
package/src/fact.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Layer } from "./layer.ts";
|
|
2
|
+
import type { Target } from "./target.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The time of a fact, in milliseconds on the monotonic clock of the runner.
|
|
6
|
+
*
|
|
7
|
+
* The field `origin` of the runner gives the wall-clock time of one monotonic time. Thus each fact time has a wall-clock time.
|
|
8
|
+
*/
|
|
9
|
+
export interface FactTime {
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The source of a fact: the probe and its version, and the outside tool when the probe starts one. */
|
|
15
|
+
export interface FactSource {
|
|
16
|
+
readonly probe: string;
|
|
17
|
+
readonly version: string;
|
|
18
|
+
readonly tool?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A typed fact of one layer of the network stack.
|
|
23
|
+
*
|
|
24
|
+
* A fact has a layer, a time, a source and a value. Its kind names the type of its value, for example `tcp.connect`.
|
|
25
|
+
* The subject is the target of the fact. A fact is plain data, so a record of facts is JSON.
|
|
26
|
+
*/
|
|
27
|
+
export interface Fact<V = unknown> {
|
|
28
|
+
/** The id is unique in the facts of one runner, for example `f12`. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly kind: string;
|
|
31
|
+
readonly layer: Layer;
|
|
32
|
+
readonly subject: Target;
|
|
33
|
+
readonly time: FactTime;
|
|
34
|
+
readonly source: FactSource;
|
|
35
|
+
readonly value: V;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The definition of a kind of fact: its name, its layer and the check of its value.
|
|
40
|
+
*
|
|
41
|
+
* The check is the contract of the value. The runner refuses a value that does not pass it, thus each fact of a kind has a
|
|
42
|
+
* value of the type `V`.
|
|
43
|
+
*/
|
|
44
|
+
export interface FactKind<V> {
|
|
45
|
+
readonly kind: string;
|
|
46
|
+
readonly layer: Layer;
|
|
47
|
+
readonly summary: string;
|
|
48
|
+
readonly check: (value: unknown) => value is V;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The value type of a fact kind. */
|
|
52
|
+
export type ValueOf<K> = K extends FactKind<infer V> ? V : never;
|
|
53
|
+
|
|
54
|
+
const KIND_NAME = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
55
|
+
|
|
56
|
+
/** This function makes a fact kind. The name has lower-case words with dots, for example `tls.handshake`. */
|
|
57
|
+
export function defineFactKind<V>(spec: FactKind<V>): FactKind<V> {
|
|
58
|
+
if (!KIND_NAME.test(spec.kind)) throw new RangeError(`"${spec.kind}" is not a valid name of a fact kind`);
|
|
59
|
+
return Object.freeze({ ...spec });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This function tells if a fact is of a kind, and if its value passes the check of the kind. */
|
|
63
|
+
export function isFactOf<V>(kind: FactKind<V>, fact: Fact): fact is Fact<V> {
|
|
64
|
+
return fact.kind === kind.kind && fact.layer === kind.layer && kind.check(fact.value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** This function gives the facts of one kind, in their order. */
|
|
68
|
+
export function factsOf<V>(kind: FactKind<V>, facts: readonly Fact[]): Fact<V>[] {
|
|
69
|
+
return facts.filter((fact): fact is Fact<V> => isFactOf(kind, fact));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An observation is a fact that a probe parsed, before the runner gives it an id, a layer and a source.
|
|
74
|
+
*
|
|
75
|
+
* The time is optional. Without a time, the fact gets the time of the run of the probe.
|
|
76
|
+
*/
|
|
77
|
+
export interface Observation<V = unknown> {
|
|
78
|
+
readonly kind: FactKind<V>;
|
|
79
|
+
readonly subject: Target;
|
|
80
|
+
readonly value: V;
|
|
81
|
+
readonly time?: FactTime | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** This function makes an observation with a type check of its value against its kind. */
|
|
85
|
+
export function observe<V>(kind: FactKind<V>, subject: Target, value: V, time?: FactTime): Observation<V> {
|
|
86
|
+
return time === undefined ? { kind, subject, value } : { kind, subject, value, time };
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { LAYERS, isLayer, layerRank, type Layer } from "./layer.ts";
|
|
2
|
+
export { canonicalHost, formatTarget, hostKind, parseTarget, sameTarget, target, type HostKind, type Target } from "./target.ts";
|
|
3
|
+
export {
|
|
4
|
+
defineFactKind,
|
|
5
|
+
factsOf,
|
|
6
|
+
isFactOf,
|
|
7
|
+
observe,
|
|
8
|
+
type Fact,
|
|
9
|
+
type FactKind,
|
|
10
|
+
type FactSource,
|
|
11
|
+
type FactTime,
|
|
12
|
+
type Observation,
|
|
13
|
+
type ValueOf,
|
|
14
|
+
} from "./fact.ts";
|
|
15
|
+
export { defineProbe, type Availability, type Probe, type ProbeContext, type ProbeMode } from "./probe.ts";
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_MAX_PORTS_PER_HOST,
|
|
18
|
+
decideScope,
|
|
19
|
+
grantProblems,
|
|
20
|
+
namedGrant,
|
|
21
|
+
resolvedGrant,
|
|
22
|
+
scopeOf,
|
|
23
|
+
widen,
|
|
24
|
+
type Grant,
|
|
25
|
+
type GrantBasis,
|
|
26
|
+
type HostGrant,
|
|
27
|
+
type PortLedger,
|
|
28
|
+
type RangeGrant,
|
|
29
|
+
type Scope,
|
|
30
|
+
type ScopeDecision,
|
|
31
|
+
} from "./scope.ts";
|
|
32
|
+
export { createRunner, type ClockOrigin, type ProbeOutcome, type Runner, type RunnerOptions } from "./run.ts";
|
|
33
|
+
export * from "./diagnosis/index.ts";
|
package/src/layer.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layers of the network stack that netdiag examines, from the bottom to the top.
|
|
3
|
+
*
|
|
4
|
+
* - `link`: the network interface and its physical link.
|
|
5
|
+
* - `wifi`: the Wi-Fi link: the signal, the channel and the rate.
|
|
6
|
+
* - `ip`: the addresses of the interfaces.
|
|
7
|
+
* - `route`: the routes of this computer and its gateway.
|
|
8
|
+
* - `path`: the hops through the provider networks to the remote host.
|
|
9
|
+
* - `dns`: the resolution of names to addresses.
|
|
10
|
+
* - `tcp`: the connections to a port.
|
|
11
|
+
* - `tls`: the handshake and the certificate of the server.
|
|
12
|
+
* - `http`: the request and the response.
|
|
13
|
+
*/
|
|
14
|
+
export const LAYERS = ["link", "wifi", "ip", "route", "path", "dns", "tcp", "tls", "http"] as const;
|
|
15
|
+
|
|
16
|
+
/** A layer of the network stack. The list `LAYERS` gives each layer in its order. */
|
|
17
|
+
export type Layer = (typeof LAYERS)[number];
|
|
18
|
+
|
|
19
|
+
const RANKS: ReadonlyMap<string, number> = new Map(LAYERS.map((layer, rank) => [layer, rank]));
|
|
20
|
+
|
|
21
|
+
/** This function tells if a value is the name of a layer. */
|
|
22
|
+
export function isLayer(value: unknown): value is Layer {
|
|
23
|
+
return typeof value === "string" && RANKS.has(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** This function gives the position of a layer in `LAYERS`. The bottom layer `link` has the position 0. */
|
|
27
|
+
export function layerRank(layer: Layer): number {
|
|
28
|
+
const rank = RANKS.get(layer);
|
|
29
|
+
if (rank === undefined) throw new RangeError(`"${String(layer)}" is not a layer`);
|
|
30
|
+
return rank;
|
|
31
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Clock } from "@mark1russell7/algebra/clock";
|
|
2
|
+
import type { FactKind, Observation } from "./fact.ts";
|
|
3
|
+
import type { Layer } from "./layer.ts";
|
|
4
|
+
import type { Target } from "./target.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The mode of a probe.
|
|
8
|
+
*
|
|
9
|
+
* - `active`: the probe sends packets to its peers, for example a TCP connect.
|
|
10
|
+
* - `passive`: the probe reads only the state of this computer, for example its routes. It has no peers.
|
|
11
|
+
*/
|
|
12
|
+
export type ProbeMode = "active" | "passive";
|
|
13
|
+
|
|
14
|
+
/** The context of a run: the clock of the runner, and the signal that stops the run. */
|
|
15
|
+
export interface ProbeContext {
|
|
16
|
+
readonly clock: Clock;
|
|
17
|
+
readonly signal: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The availability of a probe on this computer. An unavailable probe tells why, for example a tool that is not installed. */
|
|
21
|
+
export type Availability =
|
|
22
|
+
| { readonly available: true; readonly version?: string | undefined }
|
|
23
|
+
| { readonly available: false; readonly reason: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The contract of a probe. A probe is an adapter: it tells if it is available, it does its work, and it parses its raw output into
|
|
27
|
+
* observations.
|
|
28
|
+
*
|
|
29
|
+
* - `peers` gives each host that `run` sends packets to. A passive probe gives no peers. The runner examines each peer
|
|
30
|
+
* against the scope before `run` starts.
|
|
31
|
+
* - `run` does the effects and gives the raw output. A network failure is an observation, not an error. For example, a
|
|
32
|
+
* TCP connect that times out gives the outcome `timeout`. `run` throws only for a fault of the probe.
|
|
33
|
+
* - `parse` is a pure function of the raw output and the input. Thus a record of a raw output gives the same facts again,
|
|
34
|
+
* also after a correction of the parser.
|
|
35
|
+
*
|
|
36
|
+
* The input and the raw output are plain JSON data, so a record can keep them.
|
|
37
|
+
*/
|
|
38
|
+
export interface Probe<I, R> {
|
|
39
|
+
/** The id has lower-case words with dots, for example `tcp.connect`. */
|
|
40
|
+
readonly id: string;
|
|
41
|
+
/** The version of the probe changes when its facts change. */
|
|
42
|
+
readonly version: string;
|
|
43
|
+
readonly summary: string;
|
|
44
|
+
readonly layer: Layer;
|
|
45
|
+
readonly mode: ProbeMode;
|
|
46
|
+
/** The kinds of the facts that the probe gives. The runner refuses an observation of a different kind. */
|
|
47
|
+
readonly kinds: readonly FactKind<unknown>[];
|
|
48
|
+
/** The outside tool that the probe starts, for example `ping`. A native probe has no tool. */
|
|
49
|
+
readonly tool?: string | undefined;
|
|
50
|
+
peers(input: I): readonly Target[];
|
|
51
|
+
available(context: ProbeContext): Promise<Availability>;
|
|
52
|
+
run(input: I, context: ProbeContext): Promise<R>;
|
|
53
|
+
parse(raw: R, input: I): readonly Observation[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** This function makes a probe, and examines its fields. */
|
|
57
|
+
export function defineProbe<I, R>(probe: Probe<I, R>): Probe<I, R> {
|
|
58
|
+
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(probe.id)) throw new RangeError(`"${probe.id}" is not a valid probe id`);
|
|
59
|
+
if (probe.kinds.length === 0) throw new RangeError(`The probe "${probe.id}" gives no kind of fact`);
|
|
60
|
+
return probe;
|
|
61
|
+
}
|