@get-dx/cli 0.7.0 → 0.9.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.
@@ -0,0 +1,119 @@
1
+ import dayjs from "dayjs";
2
+ import { inspectConfigFile, parseWebBaseUrl, resolveApiBaseUrlWithSource, resolveWebBaseUrlWithSource, } from "../config.js";
3
+ import { getTokenWithSource } from "../secrets.js";
4
+ import { maskToken } from "../ui.js";
5
+ import { formatBytes, tildify } from "./format.js";
6
+ import { check } from "./types.js";
7
+ export async function checkConfig() {
8
+ const checks = [];
9
+ checks.push(configFileCheck());
10
+ const api = resolveApiBaseUrlWithSource();
11
+ const web = resolveWebBaseUrlWithSource();
12
+ checks.push(check("api_base_url", "API base URL", "ok", api.url, {
13
+ source: api.source,
14
+ data: { url: api.url },
15
+ }), check("web_base_url", "Web base URL", "ok", web.url, {
16
+ source: web.source,
17
+ data: { url: web.url },
18
+ }));
19
+ if (process.env.DX_API_BASE_URL && !process.env.DX_WEB_BASE_URL) {
20
+ checks.push(check("base_url_env", "Base URL env vars", "fail", "DX_API_BASE_URL is set but DX_WEB_BASE_URL is not", {
21
+ hint: "Set DX_WEB_BASE_URL to your DX web app URL (e.g. https://acme.getdx.io) or unset DX_API_BASE_URL.",
22
+ }));
23
+ }
24
+ checks.push(deploymentCheck(web.url));
25
+ const { check: tokenCheck, token } = await tokenCheckFor(api.url);
26
+ checks.push(tokenCheck);
27
+ return {
28
+ section: { id: "config", title: "Config", checks },
29
+ facts: { apiBaseUrl: api.url, webBaseUrl: web.url, token },
30
+ };
31
+ }
32
+ function configFileCheck() {
33
+ const inspection = inspectConfigFile();
34
+ const displayPath = tildify(inspection.path);
35
+ switch (inspection.status) {
36
+ case "missing":
37
+ if (process.env.DX_API_TOKEN) {
38
+ return check("config_file", "Config file", "ok", `${displayPath} not present (using env/defaults)`, { data: { path: displayPath, exists: false } });
39
+ }
40
+ return check("config_file", "Config file", "warn", `${displayPath} not present`, {
41
+ hint: "This usually means `dx auth login` has never completed. Run `dx auth login`.",
42
+ data: { path: displayPath, exists: false },
43
+ });
44
+ case "invalid":
45
+ return check("config_file", "Config file", "fail", `${displayPath} could not be read (${inspection.error})`, {
46
+ hint: "The file is ignored while it is invalid. Fix or delete it, then run `dx auth login`.",
47
+ data: { path: displayPath, exists: true, error: inspection.error },
48
+ });
49
+ case "ok": {
50
+ const modifiedAt = dayjs(inspection.modifiedAt).format();
51
+ return check("config_file", "Config file", "ok", `${displayPath} (${formatBytes(inspection.sizeBytes)}, modified ${modifiedAt})`, {
52
+ data: {
53
+ path: displayPath,
54
+ exists: true,
55
+ size_bytes: inspection.sizeBytes,
56
+ modified_at: modifiedAt,
57
+ },
58
+ });
59
+ }
60
+ }
61
+ }
62
+ function deploymentCheck(webBaseUrl) {
63
+ const parsed = parseWebBaseUrl(webBaseUrl);
64
+ switch (parsed.type) {
65
+ case "cloud":
66
+ return check("deployment", "Deployment", "ok", "cloud", {
67
+ data: { type: "cloud" },
68
+ });
69
+ case "dedicated":
70
+ return check("deployment", "Deployment", "ok", `dedicated (${parsed.accountName})`, { data: { type: "dedicated", account: parsed.accountName } });
71
+ case "managed":
72
+ return check("deployment", "Deployment", "ok", "managed (custom domain)", {
73
+ data: { type: "managed" },
74
+ });
75
+ case "invalid":
76
+ return check("deployment", "Deployment", "fail", `could not recognize web base URL "${parsed.input}"`, {
77
+ hint: "Expected https://app.getdx.com, https://<account>.getdx.io, or a custom domain.",
78
+ data: { type: "invalid" },
79
+ });
80
+ }
81
+ }
82
+ async function tokenCheckFor(apiBaseUrl) {
83
+ const lookup = await getTokenWithSource(apiBaseUrl);
84
+ switch (lookup.source) {
85
+ case "env":
86
+ case "keychain": {
87
+ const masked = maskToken(lookup.token);
88
+ return {
89
+ token: lookup.token,
90
+ check: check("token", "Token", "ok", masked ?? "", {
91
+ source: lookup.source,
92
+ data: { source: lookup.source, masked_token: masked },
93
+ }),
94
+ };
95
+ }
96
+ case "none":
97
+ return {
98
+ token: null,
99
+ check: check("token", "Token", "fail", `no token found (checked DX_API_TOKEN and ${lookup.storeName})`, {
100
+ hint: "Run `dx auth login`, or set DX_API_TOKEN.",
101
+ data: { source: null },
102
+ }),
103
+ };
104
+ case "error":
105
+ return {
106
+ token: null,
107
+ check: check("token", "Token", "fail", `could not read ${lookup.storeName ?? "the OS secret store"}: ${lookup.error}`, {
108
+ hint: tokenStoreHint(),
109
+ data: { source: null, error: lookup.error },
110
+ }),
111
+ };
112
+ }
113
+ }
114
+ function tokenStoreHint() {
115
+ if (process.platform === "linux") {
116
+ return "Install `libsecret-tools` and make sure a Secret Service provider (e.g. GNOME Keyring) is running, or set DX_API_TOKEN instead.";
117
+ }
118
+ return "Set DX_API_TOKEN in the environment instead of storing the token in the OS secret store.";
119
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import { maskToken } from "../ui.js";
3
+ import { redactUrlCredentials, tildify } from "./format.js";
4
+ import { check } from "./types.js";
5
+ const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"];
6
+ /** Variable names that likely hold credentials, so their values are masked. */
7
+ const SENSITIVE_NAME_PATTERN = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_?KEY|AUTH|COOKIE|SESSION_?KEY/i;
8
+ export function isSensitiveEnvName(name) {
9
+ return SENSITIVE_NAME_PATTERN.test(name);
10
+ }
11
+ export function checkEnvironment(env = process.env) {
12
+ const checks = [];
13
+ const names = Object.keys(env).sort();
14
+ for (const name of names.filter((name) => name.startsWith("DX_"))) {
15
+ const raw = env[name] ?? "";
16
+ const sensitive = isSensitiveEnvName(name);
17
+ const value = sensitive ? (maskToken(raw) ?? "") : raw;
18
+ checks.push(check(name.toLowerCase(), name, "ok", value, {
19
+ data: { masked: sensitive },
20
+ }));
21
+ }
22
+ for (const name of names.filter((name) => PROXY_VARS.includes(name.toUpperCase()))) {
23
+ const value = redactUrlCredentials(env[name] ?? "");
24
+ checks.push(check(name.toLowerCase(), name, "warn", value, {
25
+ hint: "dx-cli does not use proxy environment variables. Requests go directly to the DX API.",
26
+ data: { value },
27
+ }));
28
+ }
29
+ const extraCaCerts = env.NODE_EXTRA_CA_CERTS;
30
+ if (extraCaCerts) {
31
+ const displayPath = tildify(extraCaCerts);
32
+ const exists = fs.existsSync(extraCaCerts);
33
+ checks.push(exists
34
+ ? check("node_extra_ca_certs", "NODE_EXTRA_CA_CERTS", "ok", `${displayPath} (file exists)`, { data: { path: displayPath, exists } })
35
+ : check("node_extra_ca_certs", "NODE_EXTRA_CA_CERTS", "warn", `${displayPath} (file not found)`, {
36
+ hint: "Node ignores this setting when the file does not exist. Point it at your CA bundle in PEM format.",
37
+ data: { path: displayPath, exists },
38
+ }));
39
+ }
40
+ const useSystemCa = env.NODE_USE_SYSTEM_CA;
41
+ if (useSystemCa) {
42
+ checks.push(check("node_use_system_ca", "NODE_USE_SYSTEM_CA", "ok", useSystemCa));
43
+ }
44
+ const rejectUnauthorized = env.NODE_TLS_REJECT_UNAUTHORIZED;
45
+ if (rejectUnauthorized !== undefined) {
46
+ checks.push(rejectUnauthorized === "0"
47
+ ? check("node_tls_reject_unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED", "warn", "0 (certificate verification disabled)", {
48
+ hint: "This disables TLS certificate checks and is insecure. Use NODE_EXTRA_CA_CERTS to trust your corporate CA instead.",
49
+ })
50
+ : check("node_tls_reject_unauthorized", "NODE_TLS_REJECT_UNAUTHORIZED", "ok", rejectUnauthorized));
51
+ }
52
+ const nodeOptions = env.NODE_OPTIONS;
53
+ if (nodeOptions) {
54
+ checks.push(check("node_options", "NODE_OPTIONS", "ok", nodeOptions));
55
+ }
56
+ if (checks.length === 0) {
57
+ checks.push(check("none", "Variables", "ok", "(none set)"));
58
+ }
59
+ return { id: "environment", title: "Environment", checks };
60
+ }
@@ -0,0 +1,68 @@
1
+ import os from "node:os";
2
+ import { describeFetchFailure } from "../http.js";
3
+ import { CHECK_TIMEOUT_MS, ProbeTimeoutError } from "./probes.js";
4
+ /** Replace the home directory with `~` so paths don't reveal usernames. */
5
+ export function tildify(filePath) {
6
+ const home = os.homedir();
7
+ if (home &&
8
+ (filePath === home ||
9
+ filePath.startsWith(home + "/") ||
10
+ filePath.startsWith(home + "\\"))) {
11
+ return "~" + filePath.slice(home.length);
12
+ }
13
+ return filePath;
14
+ }
15
+ export function formatBytes(bytes) {
16
+ if (bytes < 1024) {
17
+ return `${bytes} B`;
18
+ }
19
+ const units = ["KB", "MB", "GB"];
20
+ let value = bytes / 1024;
21
+ let unit = 0;
22
+ while (value >= 1024 && unit < units.length - 1) {
23
+ value /= 1024;
24
+ unit += 1;
25
+ }
26
+ return `${value.toFixed(1)} ${units[unit]}`;
27
+ }
28
+ /** Remove credentials from a URL-like string, e.g. a proxy URL. */
29
+ export function redactUrlCredentials(value) {
30
+ return value.replace(/\/\/[^/@\s]+@/g, "//***@");
31
+ }
32
+ export function isTimeout(error) {
33
+ if (error instanceof ProbeTimeoutError) {
34
+ return true;
35
+ }
36
+ let current = error;
37
+ while (current instanceof Error) {
38
+ if (current.name === "TimeoutError") {
39
+ return true;
40
+ }
41
+ current = current.cause;
42
+ }
43
+ return false;
44
+ }
45
+ /** The error code (e.g. `ECONNRESET`) of an error or anything in its cause chain. */
46
+ export function errorCode(error) {
47
+ let current = error;
48
+ while (current instanceof Error) {
49
+ const { code } = current;
50
+ if (typeof code === "string") {
51
+ return code;
52
+ }
53
+ current = current.cause;
54
+ }
55
+ return null;
56
+ }
57
+ export function describeError(error, action) {
58
+ if (isTimeout(error)) {
59
+ const timeoutMs = error instanceof ProbeTimeoutError ? error.timeoutMs : CHECK_TIMEOUT_MS;
60
+ return `${action} timed out after ${timeoutMs / 1000}s`;
61
+ }
62
+ return describeFetchFailure(error);
63
+ }
64
+ export async function timed(fn) {
65
+ const start = performance.now();
66
+ const value = await fn();
67
+ return { value, ms: Math.round(performance.now() - start) };
68
+ }
@@ -0,0 +1,81 @@
1
+ import { buildLogger } from "../runtime.js";
2
+ import { checkAuth } from "./auth.js";
3
+ import { checkCli, defaultLatestVersionLookup, detectInstall, } from "./cli.js";
4
+ import { checkConfig } from "./config.js";
5
+ import { checkEnvironment } from "./environment.js";
6
+ import { checkNetwork } from "./network.js";
7
+ import { defaultProbes } from "./probes.js";
8
+ import { checkSkill } from "./skill.js";
9
+ import cliPackage from "../../package.json" with { type: "json" };
10
+ const defaultDependencies = {
11
+ probes: defaultProbes,
12
+ latestVersion: defaultLatestVersionLookup,
13
+ install: () => detectInstall(),
14
+ now: () => new Date(),
15
+ };
16
+ /**
17
+ * Run every doctor check. Never throws: problems are reported as checks.
18
+ *
19
+ * Checks run in dependency order (config before network before auth), but the
20
+ * report lists sections in reading order.
21
+ */
22
+ export async function runDoctor(context, options = {}) {
23
+ const deps = { ...defaultDependencies, ...options.dependencies };
24
+ const onPhase = options.onPhase ?? (() => { });
25
+ const now = deps.now();
26
+ onPhase("config");
27
+ const environment = checkEnvironment();
28
+ const config = await checkConfig();
29
+ const { apiBaseUrl, webBaseUrl, token } = config.facts;
30
+ const runtime = token
31
+ ? {
32
+ apiBaseUrl,
33
+ webBaseUrl,
34
+ token,
35
+ context,
36
+ version: cliPackage.version,
37
+ logger: buildLogger(context),
38
+ }
39
+ : null;
40
+ onPhase("network");
41
+ const network = await checkNetwork({
42
+ apiBaseUrl,
43
+ webBaseUrl,
44
+ version: cliPackage.version,
45
+ probes: deps.probes,
46
+ });
47
+ const { apiReachable } = network.facts;
48
+ onPhase("auth");
49
+ const auth = await checkAuth({ runtime, apiReachable, now });
50
+ onPhase("version");
51
+ const cli = await checkCli({
52
+ runtime,
53
+ apiReachable,
54
+ install: deps.install(),
55
+ latestVersion: deps.latestVersion,
56
+ now,
57
+ });
58
+ const skill = await checkSkill();
59
+ return buildReport([
60
+ cli,
61
+ environment,
62
+ config.section,
63
+ network.section,
64
+ auth,
65
+ skill,
66
+ ]);
67
+ }
68
+ function buildReport(sections) {
69
+ const summary = {
70
+ ok: 0,
71
+ warn: 0,
72
+ fail: 0,
73
+ skip: 0,
74
+ };
75
+ for (const section of sections) {
76
+ for (const check of section.checks) {
77
+ summary[check.status] += 1;
78
+ }
79
+ }
80
+ return { ok: summary.fail === 0, summary, sections };
81
+ }
@@ -0,0 +1,224 @@
1
+ import { describeError, errorCode, timed } from "./format.js";
2
+ import { CHECK_TIMEOUT_MS, DOCTOR_HEADER, } from "./probes.js";
3
+ import { check } from "./types.js";
4
+ const TLS_VERIFY_HINT = "Your network may be intercepting HTTPS traffic with its own certificate.\nSet NODE_EXTRA_CA_CERTS to your corporate CA bundle (PEM), or ask your network team to allow {host}.";
5
+ const CONNECTION_RESET_HINT = "A firewall or proxy on your network is likely interrupting HTTPS traffic to {host}.\nAsk your network team to allow {host}, or set NODE_EXTRA_CA_CERTS to your corporate CA bundle.";
6
+ export async function checkNetwork(options) {
7
+ const { apiBaseUrl, webBaseUrl, version, probes } = options;
8
+ const checks = [];
9
+ let url;
10
+ try {
11
+ url = new URL(apiBaseUrl);
12
+ }
13
+ catch {
14
+ checks.push(check("dns", "DNS", "fail", `invalid API base URL "${apiBaseUrl}"`, {
15
+ hint: "Check DX_API_BASE_URL or the apiBaseUrl in your config file.",
16
+ }), await webAppCheck(webBaseUrl, version, probes));
17
+ return {
18
+ section: { id: "network", title: "Network", checks },
19
+ facts: { apiReachable: false },
20
+ };
21
+ }
22
+ const host = url.hostname;
23
+ const isHttps = url.protocol === "https:";
24
+ const port = url.port ? Number(url.port) : isHttps ? 443 : 80;
25
+ const fill = (text) => text.replaceAll("{host}", host);
26
+ let skipReason = null;
27
+ const skip = (id, label) => check(id, label, "skip", `skipped because ${skipReason}`);
28
+ // 1. DNS
29
+ try {
30
+ const { value: addresses, ms } = await timed(() => probes.lookup(host));
31
+ checks.push(check("dns", "DNS", "ok", `resolved to ${addresses.join(", ")} (${ms}ms)`, { data: { addresses, ms } }));
32
+ }
33
+ catch (error) {
34
+ const code = errorCode(error);
35
+ checks.push(check("dns", "DNS", "fail", describeError(error, "DNS lookup"), {
36
+ hint: code === "ENOTFOUND"
37
+ ? fill("{host} could not be resolved. Check the API base URL, your VPN, and your DNS settings.")
38
+ : "Check your network connection, VPN, and DNS settings.",
39
+ data: { code },
40
+ }));
41
+ skipReason = "DNS lookup failed";
42
+ }
43
+ // 2. TCP
44
+ if (skipReason) {
45
+ checks.push(skip("tcp", "TCP connect"));
46
+ }
47
+ else {
48
+ try {
49
+ const { ms } = await timed(() => probes.tcpConnect(host, port));
50
+ checks.push(check("tcp", "TCP connect", "ok", `port ${port} open (${ms}ms)`, {
51
+ data: { port, ms },
52
+ }));
53
+ }
54
+ catch (error) {
55
+ checks.push(check("tcp", "TCP connect", "fail", describeError(error, `connecting to port ${port}`), {
56
+ hint: fill(`A firewall may be blocking outbound connections to {host} on port ${port}.`),
57
+ data: { port, code: errorCode(error) },
58
+ }));
59
+ skipReason = "the TCP connection failed";
60
+ }
61
+ }
62
+ // 3. TLS
63
+ if (!isHttps) {
64
+ checks.push(check("tls", "TLS handshake", "skip", "skipped because the API base URL does not use HTTPS"));
65
+ }
66
+ else if (skipReason) {
67
+ checks.push(skip("tls", "TLS handshake"));
68
+ }
69
+ else {
70
+ const tlsCheck = await tlsHandshakeCheck(host, port, probes, fill);
71
+ checks.push(tlsCheck);
72
+ if (tlsCheck.status === "fail") {
73
+ skipReason = "the TLS handshake failed";
74
+ }
75
+ }
76
+ // 4. HTTPS request to the API
77
+ let apiReachable = false;
78
+ if (skipReason) {
79
+ checks.push(skip("https", "HTTPS"));
80
+ }
81
+ else {
82
+ const httpsCheck = await apiRequestCheck(apiBaseUrl, version, probes, fill);
83
+ checks.push(httpsCheck);
84
+ apiReachable = httpsCheck.status === "ok";
85
+ }
86
+ // 5. Web app, independent of the API checks
87
+ checks.push(await webAppCheck(webBaseUrl, version, probes));
88
+ return {
89
+ section: { id: "network", title: `Network (${host})`, checks },
90
+ facts: { apiReachable },
91
+ };
92
+ }
93
+ async function tlsHandshakeCheck(host, port, probes, fill) {
94
+ try {
95
+ const { value: result, ms } = await timed(() => probes.tlsHandshake(host, port));
96
+ const { certificate } = result;
97
+ const parts = [
98
+ result.protocol,
99
+ certificate.issuer ? `issuer "${certificate.issuer}"` : null,
100
+ certificate.validTo
101
+ ? `valid until ${certificate.validTo.slice(0, 10)}`
102
+ : null,
103
+ ].filter(Boolean);
104
+ const summary = `${parts.join("; ")} (${ms}ms)`;
105
+ const data = {
106
+ protocol: result.protocol,
107
+ subject: certificate.subject,
108
+ issuer: certificate.issuer,
109
+ valid_to: certificate.validTo,
110
+ authorized: result.authorized,
111
+ authorization_error: result.authorizationError,
112
+ root: result.rootIssuer,
113
+ root_is_bundled: result.rootIsBundled,
114
+ ms,
115
+ };
116
+ if (!result.authorized) {
117
+ return check("tls", "TLS handshake", "fail", `certificate not trusted (${result.authorizationError ?? "unknown error"}); ${summary}`, { hint: fill(TLS_VERIFY_HINT), data });
118
+ }
119
+ if (result.rootIsBundled === false) {
120
+ return check("tls", "TLS handshake", "warn", `trusted via a non-public CA "${result.rootIssuer}"; ${summary}`, {
121
+ hint: `Traffic appears to be intercepted by "${result.rootIssuer}". This works because that CA is trusted locally, but requests are being inspected by your network.`,
122
+ data,
123
+ });
124
+ }
125
+ return check("tls", "TLS handshake", "ok", summary, { data });
126
+ }
127
+ catch (error) {
128
+ const code = errorCode(error);
129
+ const reset = code === "ECONNRESET" || code === "EPIPE";
130
+ return check("tls", "TLS handshake", "fail", reset
131
+ ? `connection reset by peer during handshake (${code})`
132
+ : describeError(error, "TLS handshake"), {
133
+ hint: fill(reset ? CONNECTION_RESET_HINT : TLS_VERIFY_HINT),
134
+ data: { code },
135
+ });
136
+ }
137
+ }
138
+ async function apiRequestCheck(apiBaseUrl, version, probes, fill) {
139
+ const url = `${apiBaseUrl}/auth.info`;
140
+ try {
141
+ const { value: response, ms } = await timed(async () => {
142
+ const response = await probes.fetch(url, {
143
+ headers: {
144
+ ...clientHeaders(version),
145
+ ...DOCTOR_HEADER,
146
+ Accept: "application/json",
147
+ },
148
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
149
+ });
150
+ return { status: response.status, text: await response.text() };
151
+ });
152
+ const data = { url, status: response.status, ms };
153
+ if (!isJson(response.text)) {
154
+ return check("https", "HTTPS", "fail", `unexpected non-JSON response (HTTP ${response.status}) (${ms}ms)`, {
155
+ hint: fill("Something other than the DX API answered. A proxy, firewall, or captive portal may be intercepting requests to {host}."),
156
+ data,
157
+ });
158
+ }
159
+ if (response.status >= 500) {
160
+ return check("https", "HTTPS", "fail", `DX API returned HTTP ${response.status} (${ms}ms)`, {
161
+ hint: "The DX API is having problems. Try again in a few minutes.",
162
+ data,
163
+ });
164
+ }
165
+ const message = response.status === 401
166
+ ? `DX API responded (401 without token, as expected) (${ms}ms)`
167
+ : `DX API responded (HTTP ${response.status}) (${ms}ms)`;
168
+ return check("https", "HTTPS", "ok", message, { data });
169
+ }
170
+ catch (error) {
171
+ return check("https", "HTTPS", "fail", describeError(error, "request"), {
172
+ hint: fill("Requests to {host} are failing even though the connection succeeded. A proxy or firewall may be interfering."),
173
+ data: { url, code: errorCode(error) },
174
+ });
175
+ }
176
+ }
177
+ async function webAppCheck(webBaseUrl, version, probes) {
178
+ try {
179
+ const { value: status, ms } = await timed(async () => {
180
+ const response = await probes.fetch(webBaseUrl, {
181
+ headers: { ...clientHeaders(version), ...DOCTOR_HEADER },
182
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
183
+ });
184
+ await response.body?.cancel();
185
+ return response.status;
186
+ });
187
+ const data = { url: webBaseUrl, status, ms };
188
+ if (status >= 500) {
189
+ return check("web_app", "Web app reachable", "fail", `${webBaseUrl} returned HTTP ${status} (${ms}ms)`, {
190
+ hint: "The DX web app is having problems. Browser login may not work.",
191
+ data,
192
+ });
193
+ }
194
+ if (status >= 400) {
195
+ return check("web_app", "Web app reachable", "warn", `${webBaseUrl} returned HTTP ${status} (${ms}ms)`, {
196
+ hint: "The web app answered with an error. A proxy or firewall may be blocking it, and browser login may not work.",
197
+ data,
198
+ });
199
+ }
200
+ return check("web_app", "Web app reachable", "ok", `${webBaseUrl} responded ${status} (${ms}ms)`, { data });
201
+ }
202
+ catch (error) {
203
+ return check("web_app", "Web app reachable", "fail", `${webBaseUrl}: ${describeError(error, "request")}`, {
204
+ hint: "Browser login (`dx auth login` without --token) needs the web app. Check your network, or log in with --token instead.",
205
+ data: { url: webBaseUrl, code: errorCode(error) },
206
+ });
207
+ }
208
+ }
209
+ function clientHeaders(version) {
210
+ return {
211
+ "X-Client-Type": "dx-cli",
212
+ "X-Client-Version": version,
213
+ "User-Agent": `dx-cli/${version}`,
214
+ };
215
+ }
216
+ function isJson(text) {
217
+ try {
218
+ JSON.parse(text);
219
+ return true;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
@@ -0,0 +1,130 @@
1
+ import { X509Certificate } from "node:crypto";
2
+ import dns from "node:dns";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ export const CHECK_TIMEOUT_MS = 10_000;
6
+ /** Headers sent on doctor's requests, so they can be told apart in logs. */
7
+ export const DOCTOR_HEADER = { "X-DX-Doctor": "1" };
8
+ export class ProbeTimeoutError extends Error {
9
+ timeoutMs;
10
+ constructor(timeoutMs) {
11
+ super(`timed out after ${timeoutMs / 1000}s`);
12
+ this.name = "ProbeTimeoutError";
13
+ this.timeoutMs = timeoutMs;
14
+ }
15
+ }
16
+ export const defaultProbes = {
17
+ lookup: (hostname) => withTimeout(dns.promises
18
+ .lookup(hostname, { all: true })
19
+ .then((results) => results.map((result) => result.address))),
20
+ tcpConnect,
21
+ tlsHandshake,
22
+ fetch: (input, init) => globalThis.fetch(input, init),
23
+ };
24
+ function withTimeout(promise) {
25
+ return new Promise((resolve, reject) => {
26
+ const timer = setTimeout(() => reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS)), CHECK_TIMEOUT_MS);
27
+ timer.unref?.();
28
+ promise.then((value) => {
29
+ clearTimeout(timer);
30
+ resolve(value);
31
+ }, (error) => {
32
+ clearTimeout(timer);
33
+ reject(error);
34
+ });
35
+ });
36
+ }
37
+ function tcpConnect(hostname, port) {
38
+ return new Promise((resolve, reject) => {
39
+ const socket = net.connect({ host: hostname, port });
40
+ socket.setTimeout(CHECK_TIMEOUT_MS, () => {
41
+ socket.destroy();
42
+ reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS));
43
+ });
44
+ socket.once("connect", () => {
45
+ socket.destroy();
46
+ resolve();
47
+ });
48
+ socket.once("error", (error) => {
49
+ socket.destroy();
50
+ reject(error);
51
+ });
52
+ });
53
+ }
54
+ function tlsHandshake(hostname, port) {
55
+ return new Promise((resolve, reject) => {
56
+ // Verify manually after the handshake so a bad chain is reported with its
57
+ // details instead of only as an error.
58
+ const socket = tls.connect({
59
+ host: hostname,
60
+ port,
61
+ servername: net.isIP(hostname) ? undefined : hostname,
62
+ rejectUnauthorized: false,
63
+ });
64
+ socket.setTimeout(CHECK_TIMEOUT_MS, () => {
65
+ socket.destroy();
66
+ reject(new ProbeTimeoutError(CHECK_TIMEOUT_MS));
67
+ });
68
+ socket.once("secureConnect", () => {
69
+ const peer = socket.getPeerCertificate(true);
70
+ const root = findChainRoot(peer);
71
+ const authorizationError = socket.authorizationError;
72
+ resolve({
73
+ protocol: socket.getProtocol() ?? null,
74
+ authorized: socket.authorized,
75
+ authorizationError: authorizationError
76
+ ? String(authorizationError.code ??
77
+ authorizationError)
78
+ : null,
79
+ certificate: {
80
+ subject: certificateName(peer.subject),
81
+ issuer: certificateName(peer.issuer),
82
+ validTo: peer.valid_to ? new Date(peer.valid_to).toISOString() : null,
83
+ },
84
+ rootIsBundled: root
85
+ ? bundledRootFingerprints().has(root.fingerprint256)
86
+ : null,
87
+ rootIssuer: root ? certificateName(root.subject) : null,
88
+ });
89
+ socket.destroy();
90
+ });
91
+ socket.once("error", (error) => {
92
+ socket.destroy();
93
+ reject(error);
94
+ });
95
+ });
96
+ }
97
+ /** The self-signed root at the top of a peer chain, if Node resolved one. */
98
+ function findChainRoot(certificate) {
99
+ let current = certificate;
100
+ const seen = new Set();
101
+ while (current?.fingerprint256 && !seen.has(current.fingerprint256)) {
102
+ seen.add(current.fingerprint256);
103
+ const issuer = current.issuerCertificate;
104
+ if (!issuer || issuer.fingerprint256 === current.fingerprint256) {
105
+ return issuer ? current : null;
106
+ }
107
+ current = issuer;
108
+ }
109
+ return null;
110
+ }
111
+ let bundledFingerprints;
112
+ function bundledRootFingerprints() {
113
+ bundledFingerprints ??= new Set(tls.rootCertificates.map((pem) => new X509Certificate(pem).fingerprint256));
114
+ return bundledFingerprints;
115
+ }
116
+ function certificateName(name) {
117
+ if (!name) {
118
+ return null;
119
+ }
120
+ const value = (key) => {
121
+ const field = name[key];
122
+ return Array.isArray(field) ? field[0] : field;
123
+ };
124
+ const commonName = value("CN");
125
+ const organization = value("O");
126
+ if (commonName && organization && commonName !== organization) {
127
+ return `${commonName} (${organization})`;
128
+ }
129
+ return commonName ?? organization ?? null;
130
+ }