unbound-cli 1.6.3 → 1.6.5

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 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.6.3",
3
+ "version": "1.6.5",
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
  },
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # WEB-4922 end-to-end verification: `sudo unbound nuke` removes /opt/unbound
3
+ # (plus newsyslog conf + log dir) on Linux when invoked as root.
4
+ #
5
+ # Runs ENTIRELY inside a fresh ubuntu:24.04 container — the host's filesystem
6
+ # is mounted read-only and the repo is copied into the container before
7
+ # `npm link`, so the host's real /opt/unbound (if any) is never touched.
8
+ #
9
+ # Usage: ./scripts/verify-nuke-ubuntu.sh
10
+ set -euo pipefail
11
+
12
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
13
+
14
+ if ! command -v docker >/dev/null 2>&1; then
15
+ echo "FAIL: docker not on PATH" >&2
16
+ exit 2
17
+ fi
18
+
19
+ echo "Running WEB-4922 nuke verification inside ubuntu:24.04..."
20
+ echo "Host repo (mounted read-only): $REPO_ROOT"
21
+ echo
22
+
23
+ docker run --rm \
24
+ -v "$REPO_ROOT:/repo:ro" \
25
+ ubuntu:24.04 bash -c '
26
+ set -euo pipefail
27
+
28
+ # Refuse to run on a host (e.g. if someone copy-pastes this heredoc into a
29
+ # terminal): /.dockerenv is created by the docker engine in every container
30
+ # but never exists on a real host.
31
+ [ -f /.dockerenv ] || { echo "FAIL: refusing to run outside a container" >&2; exit 99; }
32
+
33
+ echo "[setup] installing node + curl..."
34
+ export DEBIAN_FRONTEND=noninteractive
35
+ apt-get update -qq >/dev/null
36
+ apt-get install -y -qq curl ca-certificates >/dev/null
37
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
38
+ apt-get install -y -qq nodejs >/dev/null
39
+
40
+ # Copy repo into a writable spot — host mount is read-only.
41
+ cp -R /repo /work
42
+ cd /work
43
+ echo "[setup] npm link..."
44
+ npm link --silent >/dev/null 2>&1
45
+
46
+ # Fake the pkg layout from setup/packaging/pkg/postinstall.
47
+ echo "[setup] faking /opt/unbound + newsyslog + log dir..."
48
+ mkdir -p /opt/unbound/0.1.5/unbound-hook
49
+ mkdir -p /opt/unbound/0.1.5/unbound-discovery
50
+ mkdir -p /opt/unbound/etc
51
+ echo "#!fake binary" > /opt/unbound/0.1.5/unbound-hook/unbound-hook
52
+ echo "#!fake binary" > /opt/unbound/0.1.5/unbound-discovery/unbound-discovery
53
+ echo "{}" > /opt/unbound/etc/discovery.json
54
+ ln -sfn /opt/unbound/0.1.5 /opt/unbound/current
55
+ mkdir -p /etc/newsyslog.d
56
+ echo "# rotate" > /etc/newsyslog.d/ai.getunbound.conf
57
+ mkdir -p /var/log/unbound
58
+ echo "old log" > /var/log/unbound/discovery.log
59
+
60
+ echo
61
+ echo "===== BEFORE ====="
62
+ ls -la /opt/unbound
63
+ ls -la /etc/newsyslog.d/ai.getunbound.conf
64
+ ls -la /var/log/unbound
65
+ echo
66
+
67
+ # EUID 0 inside the container, so hasRootPrivileges() returns true and the
68
+ # includeMdm branch (where removeBinaryInstall is called) runs.
69
+ # Per-tool MDM clears WILL fail (no real tool installs) — that is best-effort
70
+ # and must not abort the binary install removal.
71
+ echo "[run] unbound nuke --yes (per-tool clears may noisily fail; that is fine)"
72
+ unbound nuke --yes || true
73
+
74
+ echo
75
+ echo "===== AFTER ====="
76
+ ls -la /opt/unbound 2>/dev/null || echo "(gone) /opt/unbound"
77
+ ls -la /etc/newsyslog.d/ai.getunbound.conf 2>/dev/null || echo "(gone) /etc/newsyslog.d/ai.getunbound.conf"
78
+ ls -la /var/log/unbound 2>/dev/null || echo "(gone) /var/log/unbound"
79
+ echo
80
+
81
+ FAIL=0
82
+ [ ! -e /opt/unbound ] || { echo "FAIL: /opt/unbound survived"; FAIL=1; }
83
+ [ ! -e /etc/newsyslog.d/ai.getunbound.conf ] || { echo "FAIL: newsyslog conf survived"; FAIL=1; }
84
+ [ ! -e /var/log/unbound ] || { echo "FAIL: log dir survived"; FAIL=1; }
85
+ if [ "$FAIL" = "0" ]; then
86
+ echo "PASS: WEB-4922 Ubuntu container — all binary install artifacts removed"
87
+ exit 0
88
+ else
89
+ echo "FAIL: at least one artifact survived"
90
+ exit 1
91
+ fi
92
+ '
package/src/api.js CHANGED
@@ -29,7 +29,9 @@ class ApiError extends Error {
29
29
  }
30
30
  }
31
31
 
32
- function request(method, path, { body, query, apiKey, baseUrl } = {}) {
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
- req.setTimeout(30000, () => {
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 30s. Please try again.`));
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) => {
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
- server.close();
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
- server.close();
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
- server.close();
107
+ shutdownServer();
88
108
  }, 120_000);
89
109
 
90
110
  const reminder = setInterval(() => {
@@ -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 { shellEscape, downloadToFile, DISCOVER_BASE_URL } = require('../utils');
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
- return new Promise((resolve, reject) => {
54
- const url = `${DISCOVER_BASE_URL}/${scriptName}`;
55
- const cmd = `curl -fsSL "${url}" | bash -s -- ${args}`;
56
-
57
- const child = spawn(cmd, { shell: true, stdio: 'inherit' });
58
-
59
- child.on('close', (code) => {
60
- const result = classifyDiscoveryExit(code);
61
- if (result === 'failure') {
62
- reject(new Error(`Discovery script failed with exit code ${code}`));
63
- return;
64
- }
65
- if (result === 'unsupported') {
66
- output.warn('AI tool discovery is not supported on this operating system. Skipping the scan — the Unbound CLI works normally.');
67
- }
68
- resolve();
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
- const tmp = path.join(os.tmpdir(), `unbound-discover-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
105
- try {
106
- await downloadToFile(url, tmp);
107
- } catch (err) {
108
- throw new Error(
109
- `${err.message}. Native Windows support requires ${winScript} in the coding-discovery-tool repo.`
110
- );
111
- }
112
- // Map posix flags to PowerShell-style parameter names (install.ps1's convention).
113
- const argv = parsePosixArgs(args).map((a) => a === '--api-key' ? '-ApiKey' : a === '--domain' ? '-Domain' : a);
114
- try {
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
- } finally {
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
 
@@ -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
- if (!ok) return;
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
- if (!ok) return;
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