@mark1russell7/netdiag 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -1
- 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-BTa-Dfwl.js → diagnosis-a4hRzFxp.js} +113 -11
- package/build/diagnosis-a4hRzFxp.js.map +1 -0
- package/build/diagnosis.d.ts +3 -2
- package/build/diagnosis.js +1 -1
- package/build/{index-CP1hzELI.d.ts → index-K2dGiYxW.d.ts} +83 -22
- package/build/index-K2dGiYxW.d.ts.map +1 -0
- package/build/index.d.ts +253 -148
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1084 -161
- package/build/index.js.map +1 -1
- 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 +17 -5
- package/src/address/index.ts +155 -0
- package/src/diagnosis/catalog.ts +8 -8
- package/src/diagnosis/index.ts +13 -0
- package/src/diagnosis/score.ts +160 -0
- package/src/index.ts +35 -0
- package/src/kinds.ts +460 -0
- package/src/probes/native.ts +354 -0
- package/src/probes/socket.ts +520 -0
- package/src/readers.ts +367 -0
- package/src/scope.ts +5 -24
- 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/build/diagnosis-BTa-Dfwl.js.map +0 -1
- package/build/index-CP1hzELI.d.ts.map +0 -1
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { connect as netConnect, isIP, type Socket, type TcpNetConnectOpts } from "node:net";
|
|
4
|
+
import * as tls from "node:tls";
|
|
5
|
+
import type { Clock } from "@mark1russell7/algebra/clock";
|
|
6
|
+
import { observe, type FactTime, type Observation } from "../fact.ts";
|
|
7
|
+
import {
|
|
8
|
+
HTTP_RESPONSE,
|
|
9
|
+
TCP_CONNECT,
|
|
10
|
+
TLS_HANDSHAKE,
|
|
11
|
+
type CertProblem,
|
|
12
|
+
type ConnectOutcome,
|
|
13
|
+
type HandshakeOutcome,
|
|
14
|
+
type HttpOutcome,
|
|
15
|
+
type TlsCertificate,
|
|
16
|
+
} from "../kinds.ts";
|
|
17
|
+
import type { ProbeContext } from "../probe.ts";
|
|
18
|
+
import { target, type Target } from "../target.ts";
|
|
19
|
+
|
|
20
|
+
/** The function that opens a TCP socket. A test can give a function that gives a socket with no connection. */
|
|
21
|
+
export type Dial = (options: TcpNetConnectOpts) => Socket;
|
|
22
|
+
|
|
23
|
+
/** The options of the native probes that connect. */
|
|
24
|
+
export interface ConnectOptions {
|
|
25
|
+
/** The function that opens a TCP socket. The default is `connect` of `node:net`. */
|
|
26
|
+
readonly dial?: Dial | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The time limit of a probe run, when its input gives none: 5 seconds. */
|
|
30
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
31
|
+
|
|
32
|
+
const defaultDial: Dial = (options) => netConnect(options);
|
|
33
|
+
|
|
34
|
+
/** This function gives the dial of the options. */
|
|
35
|
+
export function dialOf(options: ConnectOptions): Dial {
|
|
36
|
+
return options.dial ?? defaultDial;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** This function gives the error code of a thrown value, for example `ECONNREFUSED`. */
|
|
40
|
+
export function codeOf(thrown: unknown): string | undefined {
|
|
41
|
+
if (typeof thrown !== "object" || thrown === null) return undefined;
|
|
42
|
+
const code: unknown = (thrown as { code?: unknown }).code;
|
|
43
|
+
return typeof code === "string" && code.length > 0 ? code : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syscallOf(thrown: unknown): string | undefined {
|
|
47
|
+
if (typeof thrown !== "object" || thrown === null) return undefined;
|
|
48
|
+
const syscall: unknown = (thrown as { syscall?: unknown }).syscall;
|
|
49
|
+
return typeof syscall === "string" ? syscall : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** This function gives the peer of a probe that connects. It throws for an address that is not an IP address. */
|
|
53
|
+
export function peerOf(address: string, port: number): Target {
|
|
54
|
+
if (isIP(address) === 0) {
|
|
55
|
+
throw new TypeError(`"${address}" is not an IP address. Get the addresses of a name with the DNS probe first.`);
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new RangeError(`${port} is not a port from 1 to 65535.`);
|
|
58
|
+
return target(address, port);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** This function gives the time limit of an input. It throws for a limit that is not a positive number. */
|
|
62
|
+
export function timeoutOf(timeoutMs: number | undefined): number {
|
|
63
|
+
const limit = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
if (!Number.isFinite(limit) || limit <= 0) throw new RangeError(`The time limit ${limit} is not a positive number of milliseconds.`);
|
|
65
|
+
return limit;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The deadline and the signal of one probe run.
|
|
70
|
+
*
|
|
71
|
+
* The watch destroys the current socket when the deadline passes or when the signal stops the run. The phase that
|
|
72
|
+
* waits on the socket then gives the outcome `timeout`.
|
|
73
|
+
*/
|
|
74
|
+
export interface Watch {
|
|
75
|
+
expired(): boolean;
|
|
76
|
+
guard(socket: Socket): void;
|
|
77
|
+
close(): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** This function starts the watch of a probe run. */
|
|
81
|
+
export function watch(context: ProbeContext, timeoutMs: number): Watch {
|
|
82
|
+
let expired = false;
|
|
83
|
+
let current: Socket | undefined;
|
|
84
|
+
const stop = (): void => {
|
|
85
|
+
current?.destroy();
|
|
86
|
+
};
|
|
87
|
+
const cancel = context.clock.after(timeoutMs, () => {
|
|
88
|
+
expired = true;
|
|
89
|
+
stop();
|
|
90
|
+
});
|
|
91
|
+
context.signal.addEventListener("abort", stop, { once: true });
|
|
92
|
+
return {
|
|
93
|
+
expired: () => expired,
|
|
94
|
+
guard(socket) {
|
|
95
|
+
current = socket;
|
|
96
|
+
if (expired || context.signal.aborted) socket.destroy();
|
|
97
|
+
},
|
|
98
|
+
close() {
|
|
99
|
+
cancel();
|
|
100
|
+
context.signal.removeEventListener("abort", stop);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ignore = (): void => undefined;
|
|
106
|
+
|
|
107
|
+
/** This function maps an error code of a TCP connect to its outcome. */
|
|
108
|
+
export function connectOutcome(code: string | undefined): ConnectOutcome {
|
|
109
|
+
switch (code) {
|
|
110
|
+
case "ECONNREFUSED":
|
|
111
|
+
return "refused";
|
|
112
|
+
case "ETIMEDOUT":
|
|
113
|
+
return "timeout";
|
|
114
|
+
case "EHOSTUNREACH":
|
|
115
|
+
case "ENETUNREACH":
|
|
116
|
+
case "EHOSTDOWN":
|
|
117
|
+
case "ENETDOWN":
|
|
118
|
+
return "unreachable";
|
|
119
|
+
case "ECONNRESET":
|
|
120
|
+
return "reset";
|
|
121
|
+
default:
|
|
122
|
+
return "error";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* This function tells how a stream ended before its phase was complete.
|
|
128
|
+
*
|
|
129
|
+
* Node.js gives the code `ECONNRESET` for a TCP reset, with the system call that read it. It gives the same code with
|
|
130
|
+
* no system call when the peer closed the connection with a FIN.
|
|
131
|
+
*/
|
|
132
|
+
export function endOf(thrown: unknown, expired: boolean): "timeout" | "reset" | "closed" | undefined {
|
|
133
|
+
if (expired) return "timeout";
|
|
134
|
+
const code = codeOf(thrown);
|
|
135
|
+
if (code === "EPIPE") return "reset";
|
|
136
|
+
if (code !== "ECONNRESET") return undefined;
|
|
137
|
+
return syscallOf(thrown) === undefined ? "closed" : "reset";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const NOT_TLS = new Set(["ERR_SSL_WRONG_VERSION_NUMBER", "ERR_SSL_PACKET_LENGTH_TOO_LONG", "ERR_SSL_HTTP_REQUEST", "ERR_SSL_RECORD_LAYER_FAILURE"]);
|
|
141
|
+
const MISMATCH = new Set(["ERR_SSL_UNSUPPORTED_PROTOCOL", "ERR_SSL_NO_PROTOCOLS_AVAILABLE", "ERR_SSL_NO_SHARED_CIPHER", "ERR_SSL_WRONG_SSL_VERSION"]);
|
|
142
|
+
|
|
143
|
+
/** This function maps an error of a TLS handshake to its outcome. */
|
|
144
|
+
export function handshakeOutcome(thrown: unknown, expired: boolean): HandshakeOutcome {
|
|
145
|
+
const end = endOf(thrown, expired);
|
|
146
|
+
if (end !== undefined) return end;
|
|
147
|
+
const code = codeOf(thrown) ?? "";
|
|
148
|
+
if (NOT_TLS.has(code)) return "not-tls";
|
|
149
|
+
if (code.includes("_ALERT_") || MISMATCH.has(code)) return "alert";
|
|
150
|
+
return "error";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** This function maps the authorization error of a TLS socket to the problem of the certificate. */
|
|
154
|
+
export function certProblem(code: string): CertProblem {
|
|
155
|
+
switch (code) {
|
|
156
|
+
case "CERT_HAS_EXPIRED":
|
|
157
|
+
return "expired";
|
|
158
|
+
case "CERT_NOT_YET_VALID":
|
|
159
|
+
return "not-yet-valid";
|
|
160
|
+
case "ERR_TLS_CERT_ALTNAME_INVALID":
|
|
161
|
+
case "HOSTNAME_MISMATCH":
|
|
162
|
+
return "name";
|
|
163
|
+
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
|
164
|
+
case "SELF_SIGNED_CERT_IN_CHAIN":
|
|
165
|
+
case "UNABLE_TO_GET_ISSUER_CERT":
|
|
166
|
+
case "UNABLE_TO_GET_ISSUER_CERT_LOCALLY":
|
|
167
|
+
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
|
|
168
|
+
case "CERT_UNTRUSTED":
|
|
169
|
+
return "untrusted";
|
|
170
|
+
case "CERT_REVOKED":
|
|
171
|
+
return "revoked";
|
|
172
|
+
default:
|
|
173
|
+
return "other";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The TCP phase of a probe run. The times are readings of the clock of the runner. */
|
|
178
|
+
export interface TcpPhase {
|
|
179
|
+
readonly address: string;
|
|
180
|
+
readonly port: number;
|
|
181
|
+
readonly family: 4 | 6;
|
|
182
|
+
readonly start: number;
|
|
183
|
+
readonly end: number;
|
|
184
|
+
readonly outcome: ConnectOutcome;
|
|
185
|
+
readonly code?: string | undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** This function connects to an address and a port, and gives the socket when it connected. */
|
|
189
|
+
export function connectPhase(dial: Dial, address: string, port: number, w: Watch, clock: Clock): Promise<{ socket?: Socket; phase: TcpPhase }> {
|
|
190
|
+
const family = isIP(address) === 6 ? 6 : 4;
|
|
191
|
+
const start = clock.now();
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const socket = dial({ host: address, port, family });
|
|
194
|
+
socket.on("error", ignore);
|
|
195
|
+
let done = false;
|
|
196
|
+
const finish = (outcome: ConnectOutcome, code?: string): void => {
|
|
197
|
+
if (done) return;
|
|
198
|
+
done = true;
|
|
199
|
+
const phase: TcpPhase = { address, port, family, start, end: clock.now(), outcome, ...(code === undefined ? {} : { code }) };
|
|
200
|
+
if (outcome === "connected") resolve({ socket, phase });
|
|
201
|
+
else {
|
|
202
|
+
socket.destroy();
|
|
203
|
+
resolve({ phase });
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
socket.once("connect", () => finish("connected"));
|
|
207
|
+
socket.once("error", (error) => (w.expired() ? finish("timeout") : finish(connectOutcome(codeOf(error)), codeOf(error))));
|
|
208
|
+
socket.once("close", () => (w.expired() ? finish("timeout") : finish("error", "ECLOSED")));
|
|
209
|
+
w.guard(socket);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** This function gives the observation of a TCP phase. */
|
|
214
|
+
export function tcpObservation(phase: TcpPhase): Observation {
|
|
215
|
+
const { start, end, ...rest } = phase;
|
|
216
|
+
return observe(TCP_CONNECT, target(phase.address, phase.port), { ...rest, ms: end - start }, { start, end });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The trust of the certificates of a TLS probe: the context, and the SHA-256 fingerprints of its roots. */
|
|
220
|
+
export interface TrustStore {
|
|
221
|
+
readonly context: tls.SecureContext;
|
|
222
|
+
readonly publicRoots: ReadonlySet<string>;
|
|
223
|
+
/** The roots of the computer and of the user that are not public roots. */
|
|
224
|
+
readonly privateRoots: ReadonlySet<string>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function systemRoots(): readonly string[] {
|
|
228
|
+
// `getCACertificates` is in Node.js 22.15 and later.
|
|
229
|
+
const get: unknown = (tls as { getCACertificates?: unknown }).getCACertificates;
|
|
230
|
+
if (typeof get !== "function") return [];
|
|
231
|
+
try {
|
|
232
|
+
const roots: unknown = get.call(tls, "system");
|
|
233
|
+
return Array.isArray(roots) ? roots.filter((root): root is string => typeof root === "string") : [];
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* This function makes the store of trust of a TLS probe. It has the public roots of Node.js, the roots of the computer
|
|
241
|
+
* and the roots that the user gives. A chain that ends at a public root has the trust `public`. A chain that ends at a
|
|
242
|
+
* different root of the store has the trust `private`, for example the root of a proxy that examines TLS. The roots
|
|
243
|
+
* in `extraPublic` count as public roots.
|
|
244
|
+
*/
|
|
245
|
+
export function trustStore(extraRoots: readonly string[] = [], extraPublic: readonly string[] = []): TrustStore {
|
|
246
|
+
const local = [...systemRoots(), ...extraRoots];
|
|
247
|
+
const publicRoots = new Set([...tls.rootCertificates, ...extraPublic].flatMap(fingerprintOf));
|
|
248
|
+
const privateRoots = new Set(local.flatMap(fingerprintOf).filter((fingerprint) => !publicRoots.has(fingerprint)));
|
|
249
|
+
const context = tls.createSecureContext({ ca: [...tls.rootCertificates, ...extraPublic, ...local] });
|
|
250
|
+
return { context, publicRoots, privateRoots };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fingerprintOf(pem: string): string[] {
|
|
254
|
+
try {
|
|
255
|
+
return [new X509Certificate(pem).fingerprint256];
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function text(value: string | string[] | undefined): string {
|
|
262
|
+
if (value === undefined) return "";
|
|
263
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isoTime(value: string): string {
|
|
267
|
+
const time = new Date(value);
|
|
268
|
+
return Number.isNaN(time.getTime()) ? value : time.toISOString();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function namesOf(subjectAltName: string | undefined): string[] {
|
|
272
|
+
if (subjectAltName === undefined || subjectAltName.length === 0) return [];
|
|
273
|
+
return subjectAltName
|
|
274
|
+
.split(", ")
|
|
275
|
+
.map((entry) => entry.replace(/^(?:DNS|IP Address):/, ""))
|
|
276
|
+
.filter((entry) => entry.length > 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Node.js links a certificate to itself only when the check of the chain found it as a root. Thus this function
|
|
280
|
+
// examines the certificate: its issuer is its subject, and its own key verifies its signature. It does not use
|
|
281
|
+
// `checkIssued`, because OpenSSL then also requires the key usage `keyCertSign`, and a server certificate has none.
|
|
282
|
+
function isSelfSigned(raw: Buffer): boolean {
|
|
283
|
+
try {
|
|
284
|
+
const certificate = new X509Certificate(raw);
|
|
285
|
+
return certificate.issuer === certificate.subject && certificate.verify(certificate.publicKey);
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function certificateOf(socket: tls.TLSSocket, store: TrustStore): TlsCertificate | undefined {
|
|
292
|
+
const leaf = socket.getPeerCertificate(true);
|
|
293
|
+
if (Object.keys(leaf).length === 0) return undefined;
|
|
294
|
+
let top: tls.DetailedPeerCertificate = leaf;
|
|
295
|
+
let chain = 1;
|
|
296
|
+
const seen = new Set([leaf.fingerprint256]);
|
|
297
|
+
while (top.issuerCertificate !== undefined && !seen.has(top.issuerCertificate.fingerprint256)) {
|
|
298
|
+
top = top.issuerCertificate;
|
|
299
|
+
seen.add(top.fingerprint256);
|
|
300
|
+
chain += 1;
|
|
301
|
+
}
|
|
302
|
+
const authorized = socket.authorized;
|
|
303
|
+
// The trust names the root of the chain, also when the check of the certificate fails. Thus an expired certificate of
|
|
304
|
+
// a public authority keeps the trust `public`, and the certificate of a captive portal gets `none`.
|
|
305
|
+
const trust = store.publicRoots.has(top.fingerprint256) ? "public" : store.privateRoots.has(top.fingerprint256) || authorized ? "private" : "none";
|
|
306
|
+
const reason: unknown = socket.authorizationError;
|
|
307
|
+
return {
|
|
308
|
+
subject: text(leaf.subject.CN) || text(leaf.subject.O),
|
|
309
|
+
issuer: text(leaf.issuer.CN) || text(leaf.issuer.O),
|
|
310
|
+
names: namesOf(leaf.subjectaltname),
|
|
311
|
+
validFrom: isoTime(leaf.valid_from),
|
|
312
|
+
validTo: isoTime(leaf.valid_to),
|
|
313
|
+
fingerprint256: leaf.fingerprint256,
|
|
314
|
+
selfSigned: isSelfSigned(leaf.raw),
|
|
315
|
+
chain,
|
|
316
|
+
trust,
|
|
317
|
+
...(authorized ? {} : { problem: certProblem(reason instanceof Error ? (codeOf(reason) ?? reason.message) : String(reason)) }),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The TLS phase of a probe run. */
|
|
322
|
+
export interface TlsPhase {
|
|
323
|
+
readonly servername?: string | undefined;
|
|
324
|
+
readonly start: number;
|
|
325
|
+
readonly end: number;
|
|
326
|
+
readonly outcome: HandshakeOutcome;
|
|
327
|
+
readonly protocol?: string | undefined;
|
|
328
|
+
readonly cipher?: string | undefined;
|
|
329
|
+
readonly alpn?: string | undefined;
|
|
330
|
+
readonly code?: string | undefined;
|
|
331
|
+
readonly certificate?: TlsCertificate | undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The input of a TLS handshake. */
|
|
335
|
+
export interface HandshakeInput {
|
|
336
|
+
readonly address: string;
|
|
337
|
+
/** The name for SNI and for the check of the certificate. With no name, the check uses the address. */
|
|
338
|
+
readonly servername?: string | undefined;
|
|
339
|
+
readonly alpn?: readonly string[] | undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* This function does a TLS handshake on a connected socket. It does not refuse a certificate that is not valid: it
|
|
344
|
+
* gives the outcome `cert-error` and the problem of the certificate.
|
|
345
|
+
*/
|
|
346
|
+
export function handshakePhase(
|
|
347
|
+
socket: Socket,
|
|
348
|
+
input: HandshakeInput,
|
|
349
|
+
store: TrustStore,
|
|
350
|
+
w: Watch,
|
|
351
|
+
clock: Clock,
|
|
352
|
+
): Promise<{ secure?: tls.TLSSocket; phase: TlsPhase }> {
|
|
353
|
+
const start = clock.now();
|
|
354
|
+
const servername = input.servername !== undefined && isIP(input.servername) === 0 ? input.servername : undefined;
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const secure = tls.connect({
|
|
357
|
+
socket,
|
|
358
|
+
host: input.address,
|
|
359
|
+
...(servername === undefined ? {} : { servername }),
|
|
360
|
+
...(input.alpn === undefined ? {} : { ALPNProtocols: [...input.alpn] }),
|
|
361
|
+
secureContext: store.context,
|
|
362
|
+
// The probe examines a certificate that is not valid, so the handshake does not stop at it. The probe then writes
|
|
363
|
+
// no data. The HTTP probe sends its request only when the check of the certificate passed.
|
|
364
|
+
// codeql[js/disabling-certificate-validation]
|
|
365
|
+
rejectUnauthorized: false,
|
|
366
|
+
});
|
|
367
|
+
secure.on("error", ignore);
|
|
368
|
+
let done = false;
|
|
369
|
+
const base = { ...(servername === undefined ? {} : { servername }), start };
|
|
370
|
+
const finish = (phase: TlsPhase, ok: boolean): void => {
|
|
371
|
+
if (done) return;
|
|
372
|
+
done = true;
|
|
373
|
+
if (ok) resolve({ secure, phase });
|
|
374
|
+
else {
|
|
375
|
+
secure.destroy();
|
|
376
|
+
resolve({ phase });
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
secure.once("secureConnect", () => {
|
|
380
|
+
const cipher = secure.getCipher().name;
|
|
381
|
+
const protocol = secure.getProtocol() ?? undefined;
|
|
382
|
+
const alpn = typeof secure.alpnProtocol === "string" ? secure.alpnProtocol : undefined;
|
|
383
|
+
const certificate = certificateOf(secure, store);
|
|
384
|
+
finish(
|
|
385
|
+
{
|
|
386
|
+
...base,
|
|
387
|
+
end: clock.now(),
|
|
388
|
+
outcome: secure.authorized ? "ok" : "cert-error",
|
|
389
|
+
...(protocol === undefined ? {} : { protocol }),
|
|
390
|
+
...(cipher.length === 0 ? {} : { cipher }),
|
|
391
|
+
...(alpn === undefined ? {} : { alpn }),
|
|
392
|
+
...(certificate === undefined ? {} : { certificate }),
|
|
393
|
+
},
|
|
394
|
+
true,
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
secure.once("error", (error) => {
|
|
398
|
+
const code = codeOf(error);
|
|
399
|
+
finish({ ...base, end: clock.now(), outcome: handshakeOutcome(error, w.expired()), ...(code === undefined ? {} : { code }) }, false);
|
|
400
|
+
});
|
|
401
|
+
secure.once("close", () => finish({ ...base, end: clock.now(), outcome: w.expired() ? "timeout" : "closed" }, false));
|
|
402
|
+
w.guard(secure);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** This function gives the observation of a TLS phase. */
|
|
407
|
+
export function tlsObservation(address: string, port: number, phase: TlsPhase): Observation {
|
|
408
|
+
const { start, end, ...rest } = phase;
|
|
409
|
+
return observe(TLS_HANDSHAKE, target(address, port), { address, port, ...rest, ms: end - start }, { start, end });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The fields of a response head that an HTTP fact keeps. */
|
|
413
|
+
export const KEPT_FIELDS: readonly string[] = ["server", "via", "location", "content-type", "content-length", "alt-svc", "x-cache", "cf-ray"];
|
|
414
|
+
|
|
415
|
+
/** The HTTP phase of a probe run. */
|
|
416
|
+
export interface HttpPhase {
|
|
417
|
+
readonly method: "GET" | "HEAD";
|
|
418
|
+
readonly host: string;
|
|
419
|
+
readonly path: string;
|
|
420
|
+
readonly start: number;
|
|
421
|
+
/** The time of the head of the response. */
|
|
422
|
+
readonly head?: number | undefined;
|
|
423
|
+
readonly end: number;
|
|
424
|
+
readonly outcome: HttpOutcome;
|
|
425
|
+
readonly status?: number | undefined;
|
|
426
|
+
readonly version?: string | undefined;
|
|
427
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
428
|
+
readonly bytes: number;
|
|
429
|
+
readonly truncated: boolean;
|
|
430
|
+
readonly code?: string | undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The input of an HTTP exchange. */
|
|
434
|
+
export interface ExchangeInput {
|
|
435
|
+
readonly method: "GET" | "HEAD";
|
|
436
|
+
readonly host: string;
|
|
437
|
+
readonly path: string;
|
|
438
|
+
readonly maxBytes: number;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function httpOutcome(thrown: unknown, expired: boolean): HttpOutcome {
|
|
442
|
+
const end = endOf(thrown, expired);
|
|
443
|
+
if (end !== undefined) return end;
|
|
444
|
+
return (codeOf(thrown) ?? "").startsWith("HPE_") ? "protocol" : "error";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** This function sends one HTTP/1.1 request on a connected socket, and reads the response up to a limit of bytes. */
|
|
448
|
+
export function exchangePhase(socket: Socket, input: ExchangeInput, w: Watch, clock: Clock): Promise<HttpPhase> {
|
|
449
|
+
return new Promise((resolve) => {
|
|
450
|
+
let done = false;
|
|
451
|
+
let head: number | undefined;
|
|
452
|
+
let status: number | undefined;
|
|
453
|
+
let version: string | undefined;
|
|
454
|
+
let fields: Record<string, string> = {};
|
|
455
|
+
let bytes = 0;
|
|
456
|
+
let truncated = false;
|
|
457
|
+
const start = clock.now();
|
|
458
|
+
const finish = (outcome: HttpOutcome, code?: string): void => {
|
|
459
|
+
if (done) return;
|
|
460
|
+
done = true;
|
|
461
|
+
resolve({
|
|
462
|
+
method: input.method,
|
|
463
|
+
host: input.host,
|
|
464
|
+
path: input.path,
|
|
465
|
+
start,
|
|
466
|
+
...(head === undefined ? {} : { head }),
|
|
467
|
+
end: clock.now(),
|
|
468
|
+
outcome,
|
|
469
|
+
...(status === undefined ? {} : { status }),
|
|
470
|
+
...(version === undefined ? {} : { version }),
|
|
471
|
+
fields,
|
|
472
|
+
bytes,
|
|
473
|
+
truncated,
|
|
474
|
+
...(code === undefined ? {} : { code }),
|
|
475
|
+
});
|
|
476
|
+
socket.destroy();
|
|
477
|
+
};
|
|
478
|
+
const req = request({
|
|
479
|
+
createConnection: () => socket,
|
|
480
|
+
method: input.method,
|
|
481
|
+
path: input.path,
|
|
482
|
+
headers: { host: input.host, "user-agent": "netdiag", accept: "*/*", connection: "close" },
|
|
483
|
+
});
|
|
484
|
+
req.on("error", (error) => finish(httpOutcome(error, w.expired()), codeOf(error)));
|
|
485
|
+
req.on("close", () => finish(w.expired() ? "timeout" : "closed"));
|
|
486
|
+
req.on("response", (res) => {
|
|
487
|
+
head = clock.now();
|
|
488
|
+
status = res.statusCode;
|
|
489
|
+
version = res.httpVersion;
|
|
490
|
+
fields = Object.fromEntries(
|
|
491
|
+
KEPT_FIELDS.flatMap((name) => {
|
|
492
|
+
const value = res.headers[name];
|
|
493
|
+
return value === undefined ? [] : [[name, Array.isArray(value) ? value.join(", ") : value]];
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
res.on("data", (chunk: Buffer) => {
|
|
497
|
+
bytes += chunk.length;
|
|
498
|
+
if (bytes >= input.maxBytes) {
|
|
499
|
+
truncated = true;
|
|
500
|
+
finish("response");
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
res.on("end", () => finish("response"));
|
|
504
|
+
res.on("error", (error) => finish(httpOutcome(error, w.expired()), codeOf(error)));
|
|
505
|
+
});
|
|
506
|
+
req.end();
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** This function gives the observation of an HTTP phase. */
|
|
511
|
+
export function httpObservation(address: string, port: number, scheme: "http" | "https", phase: HttpPhase): Observation {
|
|
512
|
+
const { start, end, head, ...rest } = phase;
|
|
513
|
+
const time: FactTime = { start, end };
|
|
514
|
+
return observe(
|
|
515
|
+
HTTP_RESPONSE,
|
|
516
|
+
target(address, port),
|
|
517
|
+
{ address, port, scheme, ...rest, ...(head === undefined ? {} : { ttfbMs: head - start }), ms: end - start },
|
|
518
|
+
time,
|
|
519
|
+
);
|
|
520
|
+
}
|