unbound-cli 1.1.2 → 1.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -7,6 +7,7 @@ const { URL } = require('url');
7
7
  const config = require('./config');
8
8
  const output = require('./output');
9
9
  const api = require('./api');
10
+ const { getDeviceSerial } = require('./device-serial');
10
11
 
11
12
  /**
12
13
  * Opens the browser for authentication and waits for the callback.
@@ -117,7 +118,13 @@ async function loginWithApiKey(apiKey, { baseUrl } = {}) {
117
118
  const spin = output.spinner('Authenticating...');
118
119
  let response;
119
120
  try {
120
- response = await api.get('/api/v1/users/privileges/', { apiKey, baseUrl });
121
+ // Report this machine's serial so the backend can map the device to the
122
+ // authenticated user. Optional + best-effort: omitted when undeterminable.
123
+ // Awaited (not sync) so the probe never blocks the spinner on a cache miss.
124
+ const deviceSerial = await getDeviceSerial();
125
+ response = await api.get('/api/v1/users/privileges/', {
126
+ apiKey, baseUrl, query: { device_serial: deviceSerial },
127
+ });
121
128
  } catch (err) {
122
129
  let msg;
123
130
  if (err.statusCode === 401 || err.statusCode === 403) {
@@ -3,6 +3,7 @@ const config = require('../config');
3
3
  const output = require('../output');
4
4
  const api = require('../api');
5
5
  const { loginWithBrowser, loginWithApiKey } = require('../auth');
6
+ const { getDeviceSerial } = require('../device-serial');
6
7
 
7
8
  function register(program) {
8
9
  program
@@ -79,7 +80,10 @@ Examples:
79
80
  // Validate the just-stored key against the explicit backend if one
80
81
  // was just persisted; otherwise fall back to the env-var-aware
81
82
  // getter. api.get reads the stored key from config internally.
82
- await api.get('/api/v1/users/privileges/', { baseUrl: explicitBaseUrl });
83
+ const deviceSerial = await getDeviceSerial();
84
+ await api.get('/api/v1/users/privileges/', {
85
+ baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
86
+ });
83
87
  }
84
88
 
85
89
  const cfg = config.readConfig();
@@ -1,6 +1,7 @@
1
1
  const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
+ const { getDeviceSerial } = require('../device-serial');
4
5
 
5
6
  function register(program) {
6
7
  program
@@ -36,7 +37,10 @@ Examples:
36
37
  if (loggedIn) {
37
38
  const spin = output.spinner('Checking API connectivity...');
38
39
  try {
39
- const privileges = await api.get('/api/v1/users/privileges/');
40
+ const deviceSerial = await getDeviceSerial();
41
+ const privileges = await api.get('/api/v1/users/privileges/', {
42
+ query: { device_serial: deviceSerial },
43
+ });
40
44
  config.backfillUserInfo(privileges);
41
45
  spin.stop();
42
46
  connectivity = 'Connected';
@@ -1,6 +1,7 @@
1
1
  const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
+ const { getDeviceSerial } = require('../device-serial');
4
5
 
5
6
  function roleFromPrivileges(privileges) {
6
7
  if (privileges.is_admin) return 'Admin';
@@ -34,7 +35,10 @@ Examples:
34
35
  const cfg = config.readConfig();
35
36
  const spin = output.spinner('Fetching user info...');
36
37
  try {
37
- const privileges = await api.get('/api/v1/users/privileges/');
38
+ const deviceSerial = await getDeviceSerial();
39
+ const privileges = await api.get('/api/v1/users/privileges/', {
40
+ query: { device_serial: deviceSerial },
41
+ });
38
42
  spin.stop();
39
43
  config.backfillUserInfo(privileges);
40
44
 
@@ -0,0 +1,159 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { execFile } = require('child_process');
5
+ const { promisify } = require('util');
6
+ const { CONFIG_DIR } = require('./config');
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ // Shared with the endpoint hooks: they cache the same value under this key so a
11
+ // machine reports one identity across the CLI and the installed hooks.
12
+ const IDENTITY_CACHE_FILE = path.join(CONFIG_DIR, 'identity.json');
13
+ const PROBE_TIMEOUT_MS = 3000;
14
+
15
+ // DMI/BIOS serial fields are often unset on VMs and OEM boards and come back as a
16
+ // shared sentinel (with a zero exit code), which would map many machines onto one
17
+ // fake serial. Treat these as "no serial" and fall through. Mirrors the hooks +
18
+ // discovery scanner so all three agree on a machine's identity.
19
+ const PLACEHOLDER_SERIALS = new Set([
20
+ '', '0', '00000000', '000000000', '0000000000', 'none', 'na', 'n/a',
21
+ 'unknown', 'default', 'default string', 'to be filled by o.e.m.',
22
+ 'to be filled by oem', 'system serial number', 'serial number',
23
+ 'not applicable', 'not specified', 'not available', 'oem', 'o.e.m.',
24
+ 'invalid', '123456789', 'xxxxxxxx',
25
+ ]);
26
+
27
+ // Best-effort serial is intentionally non-fatal, so failures are swallowed rather
28
+ // than surfaced to the user. Set UNBOUND_DEBUG=1 to trace why a serial was omitted.
29
+ function debug(msg) {
30
+ if (process.env.UNBOUND_DEBUG) {
31
+ try { process.stderr.write(`[unbound] device-serial: ${msg}\n`); } catch { /* ignore */ }
32
+ }
33
+ }
34
+
35
+ function isValidSerial(value) {
36
+ return typeof value === 'string'
37
+ && value.trim() !== ''
38
+ && !PLACEHOLDER_SERIALS.has(value.trim().toLowerCase());
39
+ }
40
+
41
+ // Run a probe command asynchronously so the event loop (and the caller's spinner)
42
+ // stays alive during the probe. Bounded by a timeout, never throws, resolves to
43
+ // stdout or null (nonzero exit / timeout / missing binary all map to null).
44
+ async function run(cmd, args) {
45
+ try {
46
+ const { stdout } = await execFileAsync(cmd, args, {
47
+ timeout: PROBE_TIMEOUT_MS, windowsHide: true, encoding: 'utf8',
48
+ });
49
+ return typeof stdout === 'string' ? stdout : null;
50
+ } catch (err) {
51
+ debug(`probe '${cmd}' failed: ${err && err.message}`);
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // Best-effort hardware serial, mirroring the MDM setup scripts and discovery
57
+ // scanner. Filters OEM/VM placeholders so two machines never collide on the same
58
+ // fake serial, falling through to a stable per-install id (machine-id / MachineGuid).
59
+ async function probeSerial() {
60
+ const platform = os.platform();
61
+
62
+ if (platform === 'darwin') {
63
+ const out = await run('system_profiler', ['SPHardwareDataType']);
64
+ if (out) {
65
+ for (const line of out.split('\n')) {
66
+ if (line.includes('Serial Number')) {
67
+ const idx = line.indexOf(': ');
68
+ if (idx !== -1) {
69
+ const value = line.slice(idx + 2).trim();
70
+ if (isValidSerial(value)) return value;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ if (platform === 'linux') {
79
+ const dmi = await run('dmidecode', ['-s', 'system-serial-number']); // needs root; falls through otherwise
80
+ if (dmi && isValidSerial(dmi)) return dmi.trim();
81
+ for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
82
+ try {
83
+ const value = fs.readFileSync(p, 'utf8').trim();
84
+ if (isValidSerial(value)) return value;
85
+ } catch {
86
+ // try the next path
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ if (platform === 'win32') {
93
+ const bios = await run('powershell', ['-NoProfile', '-Command',
94
+ '(Get-CimInstance -ClassName Win32_BIOS).SerialNumber']);
95
+ if (bios && isValidSerial(bios)) return bios.trim();
96
+ const guid = await run('powershell', ['-NoProfile', '-Command',
97
+ "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid"]);
98
+ if (guid && isValidSerial(guid)) return guid.trim();
99
+ return null;
100
+ }
101
+
102
+ debug(`unsupported platform: ${platform}`);
103
+ return null;
104
+ }
105
+
106
+ function readCache() {
107
+ try {
108
+ const data = JSON.parse(fs.readFileSync(IDENTITY_CACHE_FILE, 'utf8'));
109
+ if (data && typeof data === 'object') return data;
110
+ } catch (err) {
111
+ if (err.code !== 'ENOENT') debug(`cache read failed: ${err.message}`);
112
+ }
113
+ return {};
114
+ }
115
+
116
+ // Merge + atomic write so a torn file can't corrupt the cache the hooks share.
117
+ function writeCache(data) {
118
+ try {
119
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
120
+ const tmp = path.join(CONFIG_DIR, `.identity.${process.pid}.tmp`);
121
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
122
+ fs.renameSync(tmp, IDENTITY_CACHE_FILE);
123
+ } catch (err) {
124
+ // an unwritable cache is fine — we still return the probed value
125
+ debug(`cache write failed: ${err.message}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Best-effort hardware serial, shared with the endpoint hooks via
131
+ * ~/.unbound/identity.json. Reads the cache first (instant); probes asynchronously
132
+ * and persists only when missing, so the caller's spinner keeps animating. Never
133
+ * throws — resolves to null when the serial can't be determined, so callers simply
134
+ * omit it from the request. Set UNBOUND_DEBUG=1 to trace omissions.
135
+ */
136
+ async function getDeviceSerial() {
137
+ try {
138
+ const data = readCache();
139
+ // Validate the cached value, not just its presence — an older CLI or hook could
140
+ // have persisted a placeholder (e.g. "System Serial Number"). Treat junk as a
141
+ // miss and re-probe, so a polluted cache self-heals instead of leaking a sentinel.
142
+ const cached = data.device_serial;
143
+ if (isValidSerial(cached)) return cached.trim();
144
+
145
+ const serial = await probeSerial();
146
+ if (serial) {
147
+ data.device_serial = serial;
148
+ writeCache(data);
149
+ } else {
150
+ debug('no serial could be determined; omitting device_serial');
151
+ }
152
+ return serial || null;
153
+ } catch (err) {
154
+ debug(`getDeviceSerial failed: ${err && err.message}`);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ module.exports = { getDeviceSerial, isValidSerial };
@@ -0,0 +1,37 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const { isValidSerial, getDeviceSerial } = require('../src/device-serial');
4
+
5
+ test('isValidSerial: accepts real hardware serials', () => {
6
+ assert.equal(isValidSerial('M6KYTR4M2Q'), true);
7
+ assert.equal(isValidSerial('DP4Y2K37VV'), true);
8
+ // Parallels VM serial (spaces are part of the value, not a separator)
9
+ assert.equal(isValidSerial('Parallels-74 09 F2 8A 55 09 40 3D AA 13 19 BD CC 96 0F 0B'), true);
10
+ });
11
+
12
+ test('isValidSerial: rejects OEM/VM placeholder sentinels', () => {
13
+ for (const junk of [
14
+ '', '0', '00000000', 'To Be Filled By O.E.M.', 'System Serial Number',
15
+ 'Default String', 'None', 'N/A', 'unknown', 'invalid', '123456789',
16
+ ]) {
17
+ assert.equal(isValidSerial(junk), false, `expected placeholder rejected: ${JSON.stringify(junk)}`);
18
+ }
19
+ });
20
+
21
+ test('isValidSerial: placeholder match is case- and whitespace-insensitive', () => {
22
+ assert.equal(isValidSerial(' to be filled by o.e.m. '), false);
23
+ assert.equal(isValidSerial('SYSTEM SERIAL NUMBER'), false);
24
+ });
25
+
26
+ test('isValidSerial: rejects non-string input', () => {
27
+ assert.equal(isValidSerial(null), false);
28
+ assert.equal(isValidSerial(undefined), false);
29
+ assert.equal(isValidSerial(12345), false);
30
+ assert.equal(isValidSerial({}), false);
31
+ });
32
+
33
+ test('getDeviceSerial: is async and resolves to a string or null without throwing', async () => {
34
+ const result = await getDeviceSerial();
35
+ assert.ok(result === null || (typeof result === 'string' && result.length > 0),
36
+ `expected string|null, got ${typeof result}: ${JSON.stringify(result)}`);
37
+ });