unbound-cli 1.1.1 → 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 +1 -1
- package/src/auth.js +8 -1
- package/src/commands/login.js +5 -1
- package/src/commands/onboard.js +21 -7
- package/src/commands/setup.js +15 -9
- package/src/commands/status.js +5 -1
- package/src/commands/whoami.js +5 -1
- package/src/device-serial.js +159 -0
- package/test/device-serial.test.js +37 -0
package/package.json
CHANGED
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
|
-
|
|
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) {
|
package/src/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/commands/onboard.js
CHANGED
|
@@ -23,7 +23,7 @@ function register(program) {
|
|
|
23
23
|
'One-step user onboarding: install the default AI tools bundle and run device discovery. ' +
|
|
24
24
|
'Runs `setup --all` followed by `discover` in a single command.'
|
|
25
25
|
)
|
|
26
|
-
.option('--api-key <key>', 'User API key (or set UNBOUND_API_KEY env var)')
|
|
26
|
+
.option('--api-key <key>', 'User API key (or set UNBOUND_API_KEY env var, or reuse a stored `unbound login` key)')
|
|
27
27
|
.option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY env var)')
|
|
28
28
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
29
29
|
.option('--set-cron', 'Set up a daily background job to keep governance up to date')
|
|
@@ -33,7 +33,8 @@ function register(program) {
|
|
|
33
33
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
34
34
|
.addHelpText('after', `
|
|
35
35
|
Runs the full onboarding flow for an end user:
|
|
36
|
-
1. Logs in with --api-key and stores credentials
|
|
36
|
+
1. Logs in with --api-key and stores credentials (or reuses a stored
|
|
37
|
+
\`unbound login\` key when --api-key is omitted).
|
|
37
38
|
2. Installs the default tool bundle: ${ALL_TOOLS.join(', ')}.
|
|
38
39
|
3. Runs device discovery with --discovery-key. With --set-cron, sets up a
|
|
39
40
|
recurring daily scheduled scan (cross-platform) instead of a one-time scan.
|
|
@@ -56,8 +57,10 @@ Examples:
|
|
|
56
57
|
.action(async (opts) => {
|
|
57
58
|
const apiKeyOpt = opts.apiKey || process.env.UNBOUND_API_KEY;
|
|
58
59
|
const discoveryKeyOpt = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
// A stored `unbound login` key is enough — only demand --api-key when not
|
|
61
|
+
// already logged in. ensureLoggedIn() reuses the stored credential below.
|
|
62
|
+
if (!apiKeyOpt && !config.isLoggedIn()) {
|
|
63
|
+
output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
|
|
61
64
|
process.exitCode = 1;
|
|
62
65
|
return;
|
|
63
66
|
}
|
|
@@ -116,7 +119,7 @@ Examples:
|
|
|
116
119
|
const { setupScheduledRun } = require('../scheduled');
|
|
117
120
|
await setupScheduledRun({
|
|
118
121
|
command: 'onboard',
|
|
119
|
-
apiKey: apiKeyOpt,
|
|
122
|
+
apiKey: apiKeyOpt || apiKey,
|
|
120
123
|
discoveryKey: discoveryKeyOpt,
|
|
121
124
|
domain: discoveryDomain,
|
|
122
125
|
skipRunAtLoad: true,
|
|
@@ -182,7 +185,7 @@ Examples:
|
|
|
182
185
|
'One-step MDM onboarding: install the default MDM tool bundle and run device discovery. ' +
|
|
183
186
|
'Requires root. Used by organization admins to enroll devices via MDM.'
|
|
184
187
|
)
|
|
185
|
-
.
|
|
188
|
+
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key)')
|
|
186
189
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
187
190
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
188
191
|
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
@@ -196,11 +199,14 @@ Runs the full MDM onboarding flow for device enrollment:
|
|
|
196
199
|
|
|
197
200
|
Both steps require root. The admin API key and discovery API key are
|
|
198
201
|
separate keys obtained from different parts of the Unbound admin dashboard.
|
|
202
|
+
--admin-api-key may be omitted to reuse the key stored by a prior
|
|
203
|
+
\`unbound login\` (run sudo with HOME preserved so the stored key is found).
|
|
199
204
|
|
|
200
205
|
For end-user onboarding (non-MDM), use \`unbound onboard\` instead.
|
|
201
206
|
|
|
202
207
|
Examples:
|
|
203
208
|
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
209
|
+
$ sudo unbound onboard-mdm --discovery-key <DISCOVERY_KEY> Reuse the stored \`unbound login\` key
|
|
204
210
|
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
205
211
|
`)
|
|
206
212
|
.action(async (opts) => {
|
|
@@ -221,9 +227,17 @@ Examples:
|
|
|
221
227
|
|
|
222
228
|
checkRoot('onboard-mdm');
|
|
223
229
|
|
|
230
|
+
// Reuse the key stored by `unbound login` when --admin-api-key is omitted.
|
|
231
|
+
const adminApiKey = opts.adminApiKey || config.getApiKey();
|
|
232
|
+
if (!adminApiKey) {
|
|
233
|
+
output.error('--admin-api-key is required (or run `unbound login` first).');
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
224
238
|
console.log('');
|
|
225
239
|
output.info('Step 1/2: Installing MDM tool bundle');
|
|
226
|
-
const ok = await runMdmSetupAllBundle(
|
|
240
|
+
const ok = await runMdmSetupAllBundle(adminApiKey, {
|
|
227
241
|
backendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
228
242
|
});
|
|
229
243
|
if (!ok) return;
|
package/src/commands/setup.js
CHANGED
|
@@ -510,7 +510,7 @@ requires authentication.
|
|
|
510
510
|
// No tools specified → interactive multi-select (existing flow)
|
|
511
511
|
if (tools.length === 0) {
|
|
512
512
|
const selected = await output.multiSelect(
|
|
513
|
-
'Select tools to set up with Unbound:',
|
|
513
|
+
opts.clear ? 'Select tools to remove Unbound configuration for:' : 'Select tools to set up with Unbound:',
|
|
514
514
|
SETUP_TOOLS
|
|
515
515
|
);
|
|
516
516
|
|
|
@@ -530,14 +530,15 @@ requires authentication.
|
|
|
530
530
|
const ok = await runBatch(selectedTools, (tool) => {
|
|
531
531
|
const toolArgs = buildScriptArgs(apiKey, {
|
|
532
532
|
...urlOpts,
|
|
533
|
+
clear: opts.clear,
|
|
533
534
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
534
535
|
});
|
|
535
536
|
return runScriptPiped(tool.script, toolArgs);
|
|
536
|
-
});
|
|
537
|
+
}, { clear: opts.clear });
|
|
537
538
|
if (!ok) return;
|
|
538
539
|
|
|
539
540
|
console.log('');
|
|
540
|
-
output.success('All tools configured');
|
|
541
|
+
output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
|
|
541
542
|
return;
|
|
542
543
|
}
|
|
543
544
|
|
|
@@ -705,7 +706,7 @@ requires authentication.
|
|
|
705
706
|
'Used by organization admins to enroll devices via MDM.'
|
|
706
707
|
)
|
|
707
708
|
.argument('[tools...]', 'Tools to set up: ' + mdmToolNames)
|
|
708
|
-
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (not required with --clear)')
|
|
709
|
+
.option('--admin-api-key <key>', 'Admin API key for MDM enrollment (falls back to your stored `unbound login` key; not required with --clear)')
|
|
709
710
|
.option('--clear', 'Remove Unbound configuration for the specified tools (no API key required)')
|
|
710
711
|
.option('--all', 'Set up all available tools')
|
|
711
712
|
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
|
|
@@ -725,10 +726,12 @@ Note: claude-code-subscription and claude-code-gateway are mutually exclusive wh
|
|
|
725
726
|
setting up; same for codex. Bare claude-code/codex set up subscription mode.
|
|
726
727
|
When using --all, subscription mode is used by default for Claude Code and Codex.
|
|
727
728
|
|
|
728
|
-
Setup examples (
|
|
729
|
+
Setup examples (need an admin key — pass --admin-api-key, or omit it to reuse the
|
|
730
|
+
key stored by a prior \`unbound login\`):
|
|
729
731
|
$ sudo unbound setup mdm --admin-api-key KEY cursor
|
|
730
732
|
$ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
|
|
731
733
|
$ sudo unbound setup mdm --admin-api-key KEY --all
|
|
734
|
+
$ sudo unbound setup mdm --all Reuse the stored \`unbound login\` key
|
|
732
735
|
$ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
|
|
733
736
|
Install hooks AND backfill local history
|
|
734
737
|
$ sudo unbound setup mdm --admin-api-key KEY copilot --backfill
|
|
@@ -748,9 +751,12 @@ Clear examples (no API key required):
|
|
|
748
751
|
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
749
752
|
const globalOpts = command.optsWithGlobals();
|
|
750
753
|
// Clearing removes config without calling the API, so a key is only
|
|
751
|
-
// required when actually enrolling tools.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
+
// required when actually enrolling tools. Fall back to the API key
|
|
755
|
+
// stored by `unbound login` so admins who are already logged in don't
|
|
756
|
+
// have to pass --admin-api-key again.
|
|
757
|
+
const adminApiKey = opts.adminApiKey || config.getApiKey();
|
|
758
|
+
if (!globalOpts.clear && !adminApiKey) {
|
|
759
|
+
output.error('--admin-api-key is required to set up tools (or run `unbound login` first).');
|
|
754
760
|
process.exitCode = 1;
|
|
755
761
|
return;
|
|
756
762
|
}
|
|
@@ -829,7 +835,7 @@ Clear examples (no API key required):
|
|
|
829
835
|
const ok = await runBatch(
|
|
830
836
|
resolvedTools,
|
|
831
837
|
(tool) => {
|
|
832
|
-
const toolArgs = buildScriptArgs(
|
|
838
|
+
const toolArgs = buildScriptArgs(adminApiKey, {
|
|
833
839
|
backendUrl,
|
|
834
840
|
gatewayUrl,
|
|
835
841
|
clear: globalOpts.clear,
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
|
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';
|
package/src/commands/whoami.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|