@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/target.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { err, ok, type Result } from "@mark1russell7/optional";
|
|
3
|
+
|
|
4
|
+
/** The kind of the host of a target: a DNS name, an IPv4 address or an IPv6 address. */
|
|
5
|
+
export type HostKind = "name" | "ipv4" | "ipv6";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A host that a probe examines, and an optional port.
|
|
9
|
+
*
|
|
10
|
+
* The host is in its canonical form: lower case, with no final dot and no brackets around an IPv6 address.
|
|
11
|
+
*/
|
|
12
|
+
export interface Target {
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly kind: HostKind;
|
|
15
|
+
readonly port?: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The ports of the URL schemes that a target can have. */
|
|
19
|
+
const SCHEME_PORTS: ReadonlyMap<string, number> = new Map([
|
|
20
|
+
["http:", 80],
|
|
21
|
+
["https:", 443],
|
|
22
|
+
["ws:", 80],
|
|
23
|
+
["wss:", 443],
|
|
24
|
+
["dns:", 53],
|
|
25
|
+
["tls:", 443],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** This function gives the kind of a host in its canonical form. */
|
|
29
|
+
export function hostKind(host: string): HostKind {
|
|
30
|
+
const family = isIP(host);
|
|
31
|
+
if (family === 4) return "ipv4";
|
|
32
|
+
if (family === 6) return "ipv6";
|
|
33
|
+
return "name";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This function gives the canonical form of a host: lower case, with no final dot and no brackets. */
|
|
37
|
+
export function canonicalHost(host: string): string {
|
|
38
|
+
let text = host.trim().toLowerCase();
|
|
39
|
+
if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1);
|
|
40
|
+
if (text.endsWith(".")) text = text.slice(0, -1);
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** This function makes a target from a host and an optional port. */
|
|
45
|
+
export function target(host: string, port?: number): Target {
|
|
46
|
+
const canonical = canonicalHost(host);
|
|
47
|
+
return port === undefined ? { host: canonical, kind: hostKind(canonical) } : { host: canonical, kind: hostKind(canonical), port };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** This function gives the text of a target: `host`, `host:port`, or `[address]:port` for an IPv6 address. */
|
|
51
|
+
export function formatTarget(t: Target): string {
|
|
52
|
+
const host = t.kind === "ipv6" && t.port !== undefined ? `[${t.host}]` : t.host;
|
|
53
|
+
return t.port === undefined ? host : `${host}:${t.port}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** This function tells if two targets have the same host and the same port. */
|
|
57
|
+
export function sameTarget(a: Target, b: Target): boolean {
|
|
58
|
+
return a.host === b.host && a.port === b.port;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
|
|
62
|
+
|
|
63
|
+
function validName(host: string): boolean {
|
|
64
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
65
|
+
return host.split(".").every((label) => LABEL.test(label) || label === "_" || /^_[a-z0-9-]{1,62}$/.test(label));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function portOf(text: string): number | undefined {
|
|
69
|
+
if (!/^\d{1,5}$/.test(text)) return undefined;
|
|
70
|
+
const port = Number(text);
|
|
71
|
+
return port >= 1 && port <= 65535 ? port : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* This function reads a target from the text that a user gives.
|
|
76
|
+
*
|
|
77
|
+
* It accepts a URL (`https://example.com/a`), a host (`example.com`) and a host with a port (`example.com:8443`).
|
|
78
|
+
* It also accepts an IPv4 address, and an IPv6 address in brackets with a port (`[2001:db8::1]:443`).
|
|
79
|
+
* A URL with a known scheme and no port gets the port of its scheme.
|
|
80
|
+
*/
|
|
81
|
+
export function parseTarget(text: string): Result<Target, string> {
|
|
82
|
+
const input = text.trim();
|
|
83
|
+
if (input.length === 0) return err("The target is empty.");
|
|
84
|
+
if (input.includes("://")) {
|
|
85
|
+
let url: URL;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(input);
|
|
88
|
+
} catch {
|
|
89
|
+
return err(`"${input}" is not a valid URL.`);
|
|
90
|
+
}
|
|
91
|
+
const host = canonicalHost(url.hostname);
|
|
92
|
+
if (host.length === 0) return err(`The URL "${input}" has no host.`);
|
|
93
|
+
const port = url.port === "" ? SCHEME_PORTS.get(url.protocol) : Number(url.port);
|
|
94
|
+
return checked(host, port, input);
|
|
95
|
+
}
|
|
96
|
+
if (isIP(input) === 6) return checked(canonicalHost(input), undefined, input);
|
|
97
|
+
const bracket = /^\[([^\]]+)\](?::(.*))?$/.exec(input);
|
|
98
|
+
if (bracket !== null) {
|
|
99
|
+
const host = bracket[1] ?? "";
|
|
100
|
+
if (isIP(host) !== 6) return err(`"${host}" is not an IPv6 address.`);
|
|
101
|
+
if (bracket[2] === undefined) return checked(canonicalHost(host), undefined, input);
|
|
102
|
+
const port = portOf(bracket[2]);
|
|
103
|
+
return port === undefined ? err(`"${bracket[2]}" is not a port from 1 to 65535.`) : checked(canonicalHost(host), port, input);
|
|
104
|
+
}
|
|
105
|
+
const colon = input.lastIndexOf(":");
|
|
106
|
+
if (colon === -1) return checked(canonicalHost(input), undefined, input);
|
|
107
|
+
const port = portOf(input.slice(colon + 1));
|
|
108
|
+
if (port === undefined) return err(`"${input.slice(colon + 1)}" is not a port from 1 to 65535.`);
|
|
109
|
+
return checked(canonicalHost(input.slice(0, colon)), port, input);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checked(host: string, port: number | undefined, input: string): Result<Target, string> {
|
|
113
|
+
const kind = hostKind(host);
|
|
114
|
+
if (kind === "name" && !validName(host)) return err(`"${input}" has no valid host name.`);
|
|
115
|
+
return ok(port === undefined ? { host, kind } : { host, kind, port });
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|
package/src/tools/mtr.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { observe, type Observation } from "../fact.ts";
|
|
2
|
+
import { PATH_REPORT, type PathReport, type ReportHop } from "../kinds.ts";
|
|
3
|
+
import { defineProbe, type Availability, type Probe, type ProbeContext } from "../probe.ts";
|
|
4
|
+
import { target, type Target } from "../target.ts";
|
|
5
|
+
import { ENGLISH, firstLine, integerIn, sameAddress, toolAddress, type ToolAddress } from "./common.ts";
|
|
6
|
+
import { AVAILABILITY_TIMEOUT_MS, availabilityOf, runTool, TOOL_MARGIN_MS, type ToolCommand, type ToolHost, type ToolRaw } from "./host.ts";
|
|
7
|
+
import { DEFAULT_TRACE_HOPS, MAX_TRACE_HOPS } from "./traceroute.ts";
|
|
8
|
+
|
|
9
|
+
// The adapter of the tool mtr. It reads the JSON report of `mtr --json` (mtr 0.92 or later) on Linux and macOS. mtr
|
|
10
|
+
// sends one cycle of probes each second. A user without privileges cannot set a shorter interval.
|
|
11
|
+
|
|
12
|
+
/** The input of the mtr probe: many cycles of probes on the path to one IP address. */
|
|
13
|
+
export interface MtrInput {
|
|
14
|
+
readonly address: string;
|
|
15
|
+
/** The number of cycles. The default is `DEFAULT_MTR_CYCLES`. */
|
|
16
|
+
readonly cycles?: number | undefined;
|
|
17
|
+
/** The largest TTL. The default is `DEFAULT_TRACE_HOPS`. */
|
|
18
|
+
readonly maxHops?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The default number of cycles. It is `LOSS_MIN_SENT` of the readers, so a report gives the feature `path.loss`. */
|
|
22
|
+
export const DEFAULT_MTR_CYCLES = 20;
|
|
23
|
+
|
|
24
|
+
/** The largest number of cycles of one input. */
|
|
25
|
+
export const MAX_MTR_CYCLES = 100;
|
|
26
|
+
|
|
27
|
+
/** The time that mtr waits for the last answers after the last cycle, in milliseconds (its option `--gracetime`). */
|
|
28
|
+
export const MTR_GRACE_MS = 5000;
|
|
29
|
+
|
|
30
|
+
interface CheckedInput extends ToolAddress {
|
|
31
|
+
readonly cycles: number;
|
|
32
|
+
readonly maxHops: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checked(input: MtrInput): CheckedInput {
|
|
36
|
+
return {
|
|
37
|
+
...toolAddress(input.address),
|
|
38
|
+
cycles: integerIn(input.cycles ?? DEFAULT_MTR_CYCLES, 1, MAX_MTR_CYCLES, "a number of cycles"),
|
|
39
|
+
maxHops: integerIn(input.maxHops ?? DEFAULT_TRACE_HOPS, 1, MAX_TRACE_HOPS, "a number of hops"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* This function gives the command of mtr for an operating system and an input. On Windows and on an operating system
|
|
45
|
+
* that the adapter does not know, it gives `undefined`. It throws for an input that is not valid.
|
|
46
|
+
*/
|
|
47
|
+
export function mtrCommand(platform: string, input: MtrInput): ToolCommand | undefined {
|
|
48
|
+
const { address, family, cycles, maxHops } = checked(input);
|
|
49
|
+
if (platform !== "linux" && platform !== "darwin") return undefined;
|
|
50
|
+
return {
|
|
51
|
+
command: "mtr",
|
|
52
|
+
args: ["--json", "--no-dns", family === "ipv6" ? "-6" : "-4", "-c", String(cycles), "-m", String(maxHops), address],
|
|
53
|
+
timeoutMs: cycles * 1000 + MTR_GRACE_MS + TOOL_MARGIN_MS,
|
|
54
|
+
env: ENGLISH,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** This function tells if mtr is available on the host. The adapter knows Linux and macOS. */
|
|
59
|
+
export async function mtrAvailability(host: ToolHost, context: ProbeContext): Promise<Availability> {
|
|
60
|
+
if (host.platform !== "linux" && host.platform !== "darwin") return { available: false, reason: `netdiag starts mtr only on Linux and macOS, not on ${host.platform}.` };
|
|
61
|
+
const output = await host.run("mtr", ["--version"], { timeoutMs: AVAILABILITY_TIMEOUT_MS, signal: context.signal, env: ENGLISH });
|
|
62
|
+
return availabilityOf("mtr", output, /mtr (\d[\d.]*)/);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function finite(value: unknown): number | undefined {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** This function reads one hub of the JSON report, or gives `undefined` when netdiag cannot read it. */
|
|
70
|
+
function readHub(hub: unknown): ReportHop | undefined {
|
|
71
|
+
if (typeof hub !== "object" || hub === null) return undefined;
|
|
72
|
+
const record = hub as Readonly<Record<string, unknown>>;
|
|
73
|
+
// mtr 0.92 writes the count as a text, and mtr 0.93 and later as a number.
|
|
74
|
+
const ttl = Number(record["count"]);
|
|
75
|
+
const sent = record["Snt"];
|
|
76
|
+
const loss = finite(record["Loss%"]);
|
|
77
|
+
const host = record["host"];
|
|
78
|
+
if (!Number.isInteger(ttl) || ttl < 1 || typeof sent !== "number" || !Number.isInteger(sent) || sent < 0 || loss === undefined || loss > 100) return undefined;
|
|
79
|
+
if (typeof host !== "string") return undefined;
|
|
80
|
+
const received = sent - Math.round((sent * loss) / 100);
|
|
81
|
+
// mtr writes "???" for a hop with no answer.
|
|
82
|
+
if (host === "???") return received === 0 ? { ttl, sent, received } : undefined;
|
|
83
|
+
if (received === 0) return { ttl, address: host, sent, received };
|
|
84
|
+
const times = { bestMs: finite(record["Best"]), meanMs: finite(record["Avg"]), worstMs: finite(record["Wrst"]) };
|
|
85
|
+
return {
|
|
86
|
+
ttl,
|
|
87
|
+
address: host,
|
|
88
|
+
sent,
|
|
89
|
+
received,
|
|
90
|
+
...(times.bestMs === undefined ? {} : { bestMs: times.bestMs }),
|
|
91
|
+
...(times.meanMs === undefined ? {} : { meanMs: times.meanMs }),
|
|
92
|
+
...(times.worstMs === undefined ? {} : { worstMs: times.worstMs }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** This function gives the hubs of the JSON report of mtr, or `undefined` when the text is not such a report. */
|
|
97
|
+
function hubsOf(text: string): readonly unknown[] | undefined {
|
|
98
|
+
try {
|
|
99
|
+
const parsed: unknown = JSON.parse(text);
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
101
|
+
const report = (parsed as { readonly report?: unknown }).report;
|
|
102
|
+
if (typeof report !== "object" || report === null) return undefined;
|
|
103
|
+
const hubs = (report as { readonly hubs?: unknown }).hubs;
|
|
104
|
+
return Array.isArray(hubs) ? hubs : undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* This function reads the raw output of the mtr probe into the value of a fact `path.report`.
|
|
112
|
+
*
|
|
113
|
+
* - The number of answers of a hop comes from its loss and its number of probes.
|
|
114
|
+
* - Output that netdiag cannot read gives the outcome `error`.
|
|
115
|
+
*/
|
|
116
|
+
export function readMtr(raw: ToolRaw, input: { readonly address: string; readonly cycles?: number | undefined }): PathReport {
|
|
117
|
+
const { address } = input;
|
|
118
|
+
const cycles = input.cycles ?? DEFAULT_MTR_CYCLES;
|
|
119
|
+
const error = (detail: string): PathReport => ({ address, outcome: "error", cycles, hops: [], detail });
|
|
120
|
+
if (raw.error !== undefined) return error(raw.error);
|
|
121
|
+
if (raw.timedOut === true) return error("The time limit stopped mtr before its report.");
|
|
122
|
+
const hubs = hubsOf(raw.stdout);
|
|
123
|
+
if (hubs === undefined) return error(firstLine(raw.stderr) ?? "netdiag cannot read the JSON report of mtr.");
|
|
124
|
+
const hops: ReportHop[] = [];
|
|
125
|
+
for (const hub of hubs) {
|
|
126
|
+
const hop = readHub(hub);
|
|
127
|
+
if (hop === undefined) return error(`netdiag cannot read the hub ${hops.length + 1} of the report of mtr.`);
|
|
128
|
+
if (hop.ttl <= (hops.at(-1)?.ttl ?? 0)) return error(`The hubs of the report of mtr are not in the order of their TTL.`);
|
|
129
|
+
hops.push(hop);
|
|
130
|
+
}
|
|
131
|
+
const last = hops.at(-1);
|
|
132
|
+
if (last === undefined) return error(firstLine(raw.stderr) ?? "The report of mtr has no hub.");
|
|
133
|
+
const reached = last.address !== undefined && last.received > 0 && sameAddress(last.address, address);
|
|
134
|
+
return { address, outcome: reached ? "reached" : "stopped", cycles, hops };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** This function makes the probe `path.mtr`: many cycles of probes on the path to one IP address with the tool mtr. */
|
|
138
|
+
export function mtrProbe(host: ToolHost): Probe<MtrInput, ToolRaw> {
|
|
139
|
+
return defineProbe<MtrInput, ToolRaw>({
|
|
140
|
+
id: "path.mtr",
|
|
141
|
+
version: "1",
|
|
142
|
+
summary: "The loss and the round-trip times of each hop of the path to one IP address, from the tool mtr.",
|
|
143
|
+
layer: "path",
|
|
144
|
+
mode: "active",
|
|
145
|
+
kinds: [PATH_REPORT],
|
|
146
|
+
tool: "mtr",
|
|
147
|
+
peers: (input): readonly Target[] => [target(checked(input).address)],
|
|
148
|
+
available: (context) => mtrAvailability(host, context),
|
|
149
|
+
async run(input, context) {
|
|
150
|
+
const command = mtrCommand(host.platform, input);
|
|
151
|
+
if (command === undefined) throw new Error(`netdiag has no command of mtr for ${host.platform}.`);
|
|
152
|
+
return runTool(host, command, context);
|
|
153
|
+
},
|
|
154
|
+
parse: (raw, input): readonly Observation[] => [observe(PATH_REPORT, target(input.address), readMtr(raw, input), { start: raw.start, end: raw.end })],
|
|
155
|
+
});
|
|
156
|
+
}
|