unbound-cli 1.6.4 → 1.7.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/README.md +25 -0
- package/package.json +2 -1
- package/src/api.js +29 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/doctor.js +17 -4
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +109 -115
- package/src/commands/status.js +18 -3
- package/src/index.js +32 -1
- package/src/run-id.js +27 -0
- package/src/setup-report.js +119 -0
- package/src/telemetry.js +201 -0
- package/src/toolHealth.js +112 -11
- package/src/utils.js +86 -10
- package/test/api-validate-key.test.js +107 -0
- package/test/auth-server-leak.test.js +201 -0
- package/test/cli-flush.test.js +101 -0
- package/test/download-pipe-hole.test.js +221 -0
- package/test/onboard-failure-exit.test.js +125 -0
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
- package/test/tool-health.test.js +237 -11
package/README.md
CHANGED
|
@@ -299,3 +299,28 @@ All list/get commands support `--json` for machine-readable JSON output.
|
|
|
299
299
|
unbound policy list --json
|
|
300
300
|
unbound users list --json | jq '.members[].email'
|
|
301
301
|
```
|
|
302
|
+
|
|
303
|
+
## Onboarding failure reporting & telemetry
|
|
304
|
+
|
|
305
|
+
So onboarding steps never fail silently, the CLI:
|
|
306
|
+
|
|
307
|
+
- **Fails loud.** A failed setup or discovery step exits non-zero (no fake
|
|
308
|
+
successes). A broken script download (GitHub down/404/empty body) is detected
|
|
309
|
+
before the script runs, so it can no longer "succeed" with an empty script.
|
|
310
|
+
- **Reports failures to the backend.** When a setup step fails, the CLI makes a
|
|
311
|
+
best-effort `POST /api/v1/setup/failed/` with the failing step name and exit
|
|
312
|
+
code so the failure is visible server-side. The device is identified by
|
|
313
|
+
`os.hostname()` (used only as an org-scoped key, not a true hardware serial).
|
|
314
|
+
This report is best-effort: if it can't be sent (offline / rate-limited), the
|
|
315
|
+
CLI still shows the real error and keeps its non-zero exit code.
|
|
316
|
+
|
|
317
|
+
### Error reporting (Sentry) — opt-out
|
|
318
|
+
|
|
319
|
+
Crash/error reporting is **off by default** and only activates when a DSN is
|
|
320
|
+
configured. Secrets (API/discovery keys), home-directory paths, and your OS
|
|
321
|
+
username are scrubbed before any event is sent.
|
|
322
|
+
|
|
323
|
+
| Env var | Effect |
|
|
324
|
+
|---------|--------|
|
|
325
|
+
| `UNBOUND_CLI_SENTRY_DSN` | Set to enable error reporting. **Unset = fully disabled** (nothing initializes, no network). |
|
|
326
|
+
| `UNBOUND_TELEMETRY` | Set to `0`, `false`, or `no` to disable error reporting even if a DSN is configured. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unbound-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "CLI tool for Unbound - AI Gateway management",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"private": false,
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@iarna/toml": "^2.2.5",
|
|
24
|
+
"@sentry/node": "^10.59.0",
|
|
24
25
|
"commander": "^12.1.0",
|
|
25
26
|
"open": "^10.1.0"
|
|
26
27
|
},
|
package/src/api.js
CHANGED
|
@@ -29,7 +29,9 @@ class ApiError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
33
|
+
|
|
34
|
+
function request(method, path, { body, query, apiKey, baseUrl, timeoutMs } = {}) {
|
|
33
35
|
// Explicit baseUrl wins over env-var-aware getBaseUrl(). Used by login /
|
|
34
36
|
// setup / onboard so a user's just-passed --backend-url isn't shadowed by a
|
|
35
37
|
// stale UNBOUND_API_URL env var from a prior shell session.
|
|
@@ -83,9 +85,13 @@ function request(method, path, { body, query, apiKey, baseUrl } = {}) {
|
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
// Callers may bound a request more tightly than the default (e.g. the
|
|
89
|
+
// best-effort failure ledger uses 3s so a hung backend can't delay surfacing
|
|
90
|
+
// the user's real error).
|
|
91
|
+
const effectiveTimeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
92
|
+
req.setTimeout(effectiveTimeout, () => {
|
|
87
93
|
req.destroy();
|
|
88
|
-
reject(new Error(`Request to ${url.host} timed out after
|
|
94
|
+
reject(new Error(`Request to ${url.host} timed out after ${Math.round(effectiveTimeout / 1000)}s. Please try again.`));
|
|
89
95
|
});
|
|
90
96
|
|
|
91
97
|
req.on('error', (err) => {
|
|
@@ -135,6 +141,25 @@ function getRaw(url) {
|
|
|
135
141
|
});
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
// WEB-4949: Probe whether a key is tenant-valid by hitting the same endpoint
|
|
145
|
+
// `unbound login` uses. 200 -> valid, 401/403 -> invalid, anything else
|
|
146
|
+
// (timeout, DNS, 5xx) -> unknown. Callers in status/doctor fail open on
|
|
147
|
+
// 'unknown' so an offline laptop doesn't false-tamper. Short default timeout
|
|
148
|
+
// (3s) keeps `unbound status` snappy even when N keys are validated in
|
|
149
|
+
// parallel against a slow link.
|
|
150
|
+
async function validateApiKey(apiKey, { baseUrl, timeoutMs = 3000 } = {}) {
|
|
151
|
+
if (!apiKey) return 'invalid';
|
|
152
|
+
try {
|
|
153
|
+
await request('GET', '/api/v1/users/privileges/', { apiKey, baseUrl, timeoutMs });
|
|
154
|
+
return 'valid';
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err instanceof ApiError && (err.statusCode === 401 || err.statusCode === 403)) {
|
|
157
|
+
return 'invalid';
|
|
158
|
+
}
|
|
159
|
+
return 'unknown';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
138
163
|
module.exports = {
|
|
139
164
|
ApiError,
|
|
140
165
|
get: (path, opts) => request('GET', path, opts),
|
|
@@ -142,4 +167,5 @@ module.exports = {
|
|
|
142
167
|
put: (path, opts) => request('PUT', path, opts),
|
|
143
168
|
del: (path, opts) => request('DELETE', path, opts),
|
|
144
169
|
getRaw,
|
|
170
|
+
validateApiKey,
|
|
145
171
|
};
|
package/src/auth.js
CHANGED
|
@@ -17,7 +17,7 @@ const { getDeviceSerial } = require('./device-serial');
|
|
|
17
17
|
* @param {string} frontendUrl - The frontend URL to open
|
|
18
18
|
* @returns {Promise<{email: string, orgName: string}>}
|
|
19
19
|
*/
|
|
20
|
-
async function loginWithBrowser(frontendUrl) {
|
|
20
|
+
async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
21
21
|
const server = http.createServer();
|
|
22
22
|
let callbackResolve;
|
|
23
23
|
let callbackReject;
|
|
@@ -27,11 +27,31 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
27
27
|
callbackReject = reject;
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// server.close() only stops accepting NEW connections; it does NOT destroy a
|
|
31
|
+
// socket the browser is holding open via HTTP/1.1 keep-alive. That socket stays
|
|
32
|
+
// ref'd and keeps the event loop (the whole CLI) alive ~60s until the browser
|
|
33
|
+
// drops it. Responses set `Connection: close` so the answered socket closes
|
|
34
|
+
// gracefully once its body flushes; this also force-drops any idle / pre-connected
|
|
35
|
+
// / timed-out socket so the process exits immediately. closeAllConnections is
|
|
36
|
+
// Node >=18.2 — guard for the package's >=18 engine floor.
|
|
37
|
+
//
|
|
38
|
+
// Calling this synchronously right after res.end() is safe ONLY because the
|
|
39
|
+
// callback responses are a few hundred bytes of static HTML — they fit in the
|
|
40
|
+
// socket send buffer, so the body reaches the kernel before closeAllConnections
|
|
41
|
+
// destroys the socket. If these pages ever grow to multi-KB (inline assets,
|
|
42
|
+
// styling), defer teardown to res.on('finish') to avoid truncating the response.
|
|
43
|
+
function shutdownServer() {
|
|
44
|
+
server.close();
|
|
45
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
46
|
+
server.closeAllConnections();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
server.on('request', (req, res) => {
|
|
31
51
|
const url = new URL(req.url, `http://localhost`);
|
|
32
52
|
|
|
33
53
|
if (url.pathname !== '/callback') {
|
|
34
|
-
res.writeHead(404);
|
|
54
|
+
res.writeHead(404, { 'Connection': 'close' });
|
|
35
55
|
res.end('Not found');
|
|
36
56
|
return;
|
|
37
57
|
}
|
|
@@ -41,10 +61,10 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
41
61
|
const orgName = url.searchParams.get('org');
|
|
42
62
|
|
|
43
63
|
if (!apiKey) {
|
|
44
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
64
|
+
res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' });
|
|
45
65
|
res.end('<html><body><h2>Authentication failed</h2><p>No API key received. Please try again.</p></body></html>');
|
|
46
66
|
callbackReject(new Error('No API key received in callback'));
|
|
47
|
-
|
|
67
|
+
shutdownServer();
|
|
48
68
|
return;
|
|
49
69
|
}
|
|
50
70
|
|
|
@@ -54,11 +74,11 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
54
74
|
if (orgName) cfg.org_name = orgName;
|
|
55
75
|
config.writeConfig(cfg);
|
|
56
76
|
|
|
57
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
77
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' });
|
|
58
78
|
res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
|
|
59
79
|
|
|
60
80
|
callbackResolve({ email, orgName });
|
|
61
|
-
|
|
81
|
+
shutdownServer();
|
|
62
82
|
});
|
|
63
83
|
|
|
64
84
|
await new Promise((resolve, reject) => {
|
|
@@ -70,7 +90,7 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
70
90
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
71
91
|
const authUrl = `${frontendUrl}/automations/api-key-callback?callback_url=${encodeURIComponent(callbackUrl)}&app_type=cli`;
|
|
72
92
|
|
|
73
|
-
const open = (await import('open')).default;
|
|
93
|
+
const open = openFn || (await import('open')).default;
|
|
74
94
|
|
|
75
95
|
output.info('Opening browser for authentication...');
|
|
76
96
|
output.info(`If the browser does not open, visit:\n${authUrl}`);
|
|
@@ -84,7 +104,7 @@ async function loginWithBrowser(frontendUrl) {
|
|
|
84
104
|
|
|
85
105
|
const timeout = setTimeout(() => {
|
|
86
106
|
callbackReject(new Error('Authentication timed out after 120 seconds'));
|
|
87
|
-
|
|
107
|
+
shutdownServer();
|
|
88
108
|
}, 120_000);
|
|
89
109
|
|
|
90
110
|
const reminder = setInterval(() => {
|
package/src/commands/discover.js
CHANGED
|
@@ -4,7 +4,8 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const config = require('../config');
|
|
6
6
|
const output = require('../output');
|
|
7
|
-
const
|
|
7
|
+
const telemetry = require('../telemetry');
|
|
8
|
+
const { shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL } = require('../utils');
|
|
8
9
|
const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
|
|
9
10
|
|
|
10
11
|
// install.sh exits with this code when the OS isn't supported for discovery
|
|
@@ -46,29 +47,46 @@ function isRoot() {
|
|
|
46
47
|
* install.ps1 automatically; any other bash-only script (e.g.
|
|
47
48
|
* setup-scheduled-scan.sh) remains unsupported on native Windows.
|
|
48
49
|
*/
|
|
49
|
-
function runDiscoveryScript(scriptName, args) {
|
|
50
|
+
async function runDiscoveryScript(scriptName, args) {
|
|
50
51
|
if (isWindowsNative()) {
|
|
51
52
|
return runDiscoveryScriptWindows(scriptName, args);
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
54
|
+
// Download-then-run instead of `curl -fsSL ... | bash -s --`. The pipe's exit
|
|
55
|
+
// code was bash's, not curl's, and /bin/sh=dash has no pipefail — so a failed
|
|
56
|
+
// download (GitHub down/404/partial) silently ran an empty/partial script and
|
|
57
|
+
// exited 0 = fake success. downloadToFile rejects on non-200/empty body BEFORE
|
|
58
|
+
// bash runs, closing that hole. Mirrors runDiscoveryScriptWindows.
|
|
59
|
+
const url = `${DISCOVER_BASE_URL}/${scriptName}`;
|
|
60
|
+
// The downloaded script is executed (as root under `sudo unbound …`), so it
|
|
61
|
+
// must live in a per-run PRIVATE temp dir an attacker can't pre-create or
|
|
62
|
+
// symlink. withSecureTempFile creates it (mode 0700) and cleans it up after.
|
|
63
|
+
return withSecureTempFile('.sh', async (tmp) => {
|
|
64
|
+
try {
|
|
65
|
+
await downloadToFile(url, tmp);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
err.downloadFailed = true;
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
return await new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn('bash', [tmp, ...parsePosixArgs(args)], {
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
shell: false,
|
|
74
|
+
});
|
|
75
|
+
child.on('close', (code) => {
|
|
76
|
+
const result = classifyDiscoveryExit(code);
|
|
77
|
+
if (result === 'failure') {
|
|
78
|
+
const e = new Error(`Discovery script failed with exit code ${code}`);
|
|
79
|
+
e.exitCode = code;
|
|
80
|
+
reject(e);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (result === 'unsupported') {
|
|
84
|
+
output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
|
|
85
|
+
}
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
child.on('error', reject);
|
|
69
89
|
});
|
|
70
|
-
|
|
71
|
-
child.on('error', reject);
|
|
72
90
|
});
|
|
73
91
|
}
|
|
74
92
|
|
|
@@ -101,17 +119,18 @@ async function runDiscoveryScriptWindows(scriptName, args) {
|
|
|
101
119
|
}
|
|
102
120
|
const winScript = 'install.ps1';
|
|
103
121
|
const url = `${DISCOVER_BASE_URL}/${winScript}`;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
// Same hardening as the bash path: download into a per-run private temp dir
|
|
123
|
+
// and run from there, then clean up.
|
|
124
|
+
return withSecureTempFile('.ps1', async (tmp) => {
|
|
125
|
+
try {
|
|
126
|
+
await downloadToFile(url, tmp);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${err.message}. Native Windows support requires ${winScript} in the coding-discovery-tool repo.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
// Map posix flags to PowerShell-style parameter names (install.ps1's convention).
|
|
133
|
+
const argv = parsePosixArgs(args).map((a) => a === '--api-key' ? '-ApiKey' : a === '--domain' ? '-Domain' : a);
|
|
115
134
|
await new Promise((resolve, reject) => {
|
|
116
135
|
const child = spawn(
|
|
117
136
|
'powershell',
|
|
@@ -131,9 +150,7 @@ async function runDiscoveryScriptWindows(scriptName, args) {
|
|
|
131
150
|
});
|
|
132
151
|
child.on('error', reject);
|
|
133
152
|
});
|
|
134
|
-
}
|
|
135
|
-
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
136
|
-
}
|
|
153
|
+
});
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
/**
|
|
@@ -197,8 +214,10 @@ Examples:
|
|
|
197
214
|
$ unbound discover --api-key KEY Scan current user only
|
|
198
215
|
$ sudo unbound discover --api-key KEY --domain https://custom.backend.com
|
|
199
216
|
`)
|
|
200
|
-
.action(async (opts) => {
|
|
217
|
+
.action(telemetry.wrapAction('discover', async (opts) => {
|
|
201
218
|
let scanSucceeded = false;
|
|
219
|
+
// Register the discovery key so beforeSend redacts it from any event.
|
|
220
|
+
telemetry.rememberSecret(opts.apiKey);
|
|
202
221
|
try {
|
|
203
222
|
if (!opts.apiKey) {
|
|
204
223
|
output.error('--api-key is required.');
|
|
@@ -224,6 +243,7 @@ Examples:
|
|
|
224
243
|
console.log('');
|
|
225
244
|
output.success('Discovery complete');
|
|
226
245
|
} catch (err) {
|
|
246
|
+
telemetry.captureError(err, { tags: { flow: 'discover' } });
|
|
227
247
|
output.error(err.message);
|
|
228
248
|
// Hint only when the scan completed and the user actually asked for
|
|
229
249
|
// cron setup — guards against a future code path between scanSucceeded
|
|
@@ -236,7 +256,7 @@ Examples:
|
|
|
236
256
|
}
|
|
237
257
|
process.exitCode = 1;
|
|
238
258
|
}
|
|
239
|
-
});
|
|
259
|
+
}));
|
|
240
260
|
|
|
241
261
|
// --- Schedule / Unschedule / Status ---
|
|
242
262
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ const config = require('../config');
|
|
|
3
3
|
const api = require('../api');
|
|
4
4
|
const output = require('../output');
|
|
5
5
|
const { getDeviceSerial } = require('../device-serial');
|
|
6
|
-
const { detectTools } = require('../toolHealth');
|
|
6
|
+
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
7
7
|
const { hasRootPrivileges } = require('./setup');
|
|
8
8
|
|
|
9
9
|
// Validate the stored API key against the backend. Returns one of:
|
|
@@ -61,12 +61,16 @@ Examples:
|
|
|
61
61
|
.option('--json', 'Output raw JSON')
|
|
62
62
|
.action(async (opts) => {
|
|
63
63
|
try {
|
|
64
|
-
const apiKey = config.getApiKey();
|
|
65
64
|
const gatewayUrl = config.getGatewayUrl();
|
|
65
|
+
const baseUrl = config.getBaseUrl();
|
|
66
66
|
|
|
67
67
|
const spin = output.spinner('Running diagnostics...');
|
|
68
68
|
const key = await checkApiKey();
|
|
69
|
-
const tools = detectTools({ gatewayUrl
|
|
69
|
+
const tools = detectTools({ gatewayUrl });
|
|
70
|
+
// WEB-4949: per-tool API keys are validated against the gateway in
|
|
71
|
+
// parallel (fail-open on network errors).
|
|
72
|
+
await validateToolKeys(tools, (k) => api.validateApiKey(k, { baseUrl }));
|
|
73
|
+
const keySkippedCount = countValidationSkipped(tools);
|
|
70
74
|
spin.stop();
|
|
71
75
|
|
|
72
76
|
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
@@ -81,9 +85,13 @@ Examples:
|
|
|
81
85
|
mode: t.mode,
|
|
82
86
|
status: t.status,
|
|
83
87
|
conflict: !!t.conflict,
|
|
84
|
-
checks: t.checks.map((c) => ({
|
|
88
|
+
checks: t.checks.map((c) => ({
|
|
89
|
+
name: c.name, ok: c.ok, kind: c.kind, detail: c.detail,
|
|
90
|
+
warn: !!c.warn, validation_skipped: !!c.validationSkipped,
|
|
91
|
+
})),
|
|
85
92
|
})),
|
|
86
93
|
healthy: !unhealthy,
|
|
94
|
+
key_validations_skipped: keySkippedCount,
|
|
87
95
|
});
|
|
88
96
|
if (unhealthy) process.exitCode = 1;
|
|
89
97
|
return;
|
|
@@ -116,6 +124,11 @@ Examples:
|
|
|
116
124
|
} else s = C.dim('○ Not set up');
|
|
117
125
|
console.log(` ${labelOf(t).padEnd(W)} ${s}`);
|
|
118
126
|
}
|
|
127
|
+
// WEB-4949: surface fail-open so CI/operators don't read an offline
|
|
128
|
+
// run as "everything verified". Mirrors the unverified-key-state line.
|
|
129
|
+
if (keySkippedCount > 0) {
|
|
130
|
+
console.log(` ${C.dim(`Note: ${keySkippedCount} per-tool API key validation${keySkippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
|
|
131
|
+
}
|
|
119
132
|
console.log('');
|
|
120
133
|
|
|
121
134
|
// Fix routing. User-level tools are fixable by anyone (`unbound doctor
|
package/src/commands/onboard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { Option } = require('commander');
|
|
2
2
|
const config = require('../config');
|
|
3
3
|
const output = require('../output');
|
|
4
|
+
const telemetry = require('../telemetry');
|
|
4
5
|
const { ensureLoggedIn } = require('../auth');
|
|
5
6
|
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
6
7
|
const { runDiscoveryScan } = require('./discover');
|
|
@@ -55,9 +56,13 @@ Examples:
|
|
|
55
56
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
56
57
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron
|
|
57
58
|
`)
|
|
58
|
-
.action(async (opts) => {
|
|
59
|
+
.action(telemetry.wrapAction('onboard', async (opts) => {
|
|
59
60
|
const apiKeyOpt = opts.apiKey || opts.adminApiKey || process.env.UNBOUND_API_KEY;
|
|
60
61
|
const discoveryKeyOpt = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
|
|
62
|
+
// Register the concrete secret values so beforeSend redacts the exact
|
|
63
|
+
// strings (not just key-shaped guesses) from any captured event.
|
|
64
|
+
telemetry.rememberSecret(apiKeyOpt);
|
|
65
|
+
telemetry.rememberSecret(discoveryKeyOpt);
|
|
61
66
|
const isMdm = hasRootPrivileges();
|
|
62
67
|
|
|
63
68
|
if (!discoveryKeyOpt) {
|
|
@@ -109,7 +114,10 @@ Examples:
|
|
|
109
114
|
const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
|
|
110
115
|
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
111
116
|
});
|
|
112
|
-
|
|
117
|
+
// A failed setup must NOT report onboarding success. runBatch already
|
|
118
|
+
// reported the failure to the ledger and set exitCode; make the
|
|
119
|
+
// non-zero exit explicit here so onboard never returns 0 on failure.
|
|
120
|
+
if (!ok) { process.exitCode = 1; return; }
|
|
113
121
|
setupSucceeded = true;
|
|
114
122
|
|
|
115
123
|
console.log('');
|
|
@@ -131,7 +139,10 @@ Examples:
|
|
|
131
139
|
const { ok, skipped } = await runSetupAllBundle(apiKey, {
|
|
132
140
|
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
133
141
|
});
|
|
134
|
-
|
|
142
|
+
// A failed setup must NOT report onboarding success. runBatch already
|
|
143
|
+
// reported the failure to the ledger and set exitCode; make the
|
|
144
|
+
// non-zero exit explicit here so onboard never returns 0 on failure.
|
|
145
|
+
if (!ok) { process.exitCode = 1; return; }
|
|
135
146
|
setupSucceeded = true;
|
|
136
147
|
|
|
137
148
|
console.log('');
|
|
@@ -173,6 +184,9 @@ Examples:
|
|
|
173
184
|
? 'Onboarding complete — tools managed by MDM were skipped (see above)'
|
|
174
185
|
: 'Onboarding complete');
|
|
175
186
|
} catch (err) {
|
|
187
|
+
// Capture the real onboarding error to Sentry (no-op unless enabled).
|
|
188
|
+
// The action catches its own errors, so capture here before we swallow.
|
|
189
|
+
telemetry.captureError(err, { tags: { flow: 'onboard' } });
|
|
176
190
|
if (!err.displayed) output.error(err.message);
|
|
177
191
|
const suffix = domainHintSuffix(discoveryDomain);
|
|
178
192
|
const sudo = isMdm ? 'sudo ' : '';
|
|
@@ -193,7 +207,7 @@ Examples:
|
|
|
193
207
|
}
|
|
194
208
|
process.exitCode = 1;
|
|
195
209
|
}
|
|
196
|
-
});
|
|
210
|
+
}));
|
|
197
211
|
|
|
198
212
|
// --- onboard unschedule ---
|
|
199
213
|
|