spoof-d 0.1.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.
Files changed (5) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +250 -0
  3. package/bin/cmd.js +313 -0
  4. package/index.js +1115 -0
  5. package/package.json +57 -0
package/index.js ADDED
@@ -0,0 +1,1115 @@
1
+ /*! spoof. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
2
+ module.exports = {
3
+ findInterface,
4
+ findInterfaces,
5
+ normalize,
6
+ randomize,
7
+ setInterfaceMAC,
8
+ getInterfaceMAC,
9
+ };
10
+
11
+ const cp = require("child_process");
12
+ const quote = require("shell-quote").quote;
13
+ const zeroFill = require("zero-fill");
14
+
15
+ /**
16
+ * Custom error classes for better error handling
17
+ */
18
+ class SpoofyError extends Error {
19
+ constructor(message, code, suggestions = []) {
20
+ super(message);
21
+ this.name = "SpoofyError";
22
+ this.code = code;
23
+ this.suggestions = suggestions;
24
+ Error.captureStackTrace(this, this.constructor);
25
+ }
26
+ }
27
+
28
+ class ValidationError extends SpoofyError {
29
+ constructor(message, suggestions = []) {
30
+ super(message, "VALIDATION_ERROR", suggestions);
31
+ this.name = "ValidationError";
32
+ }
33
+ }
34
+
35
+ class PermissionError extends SpoofyError {
36
+ constructor(message, suggestions = []) {
37
+ super(message, "PERMISSION_ERROR", suggestions);
38
+ this.name = "PermissionError";
39
+ }
40
+ }
41
+
42
+ class NetworkError extends SpoofyError {
43
+ constructor(message, suggestions = []) {
44
+ super(message, "NETWORK_ERROR", suggestions);
45
+ this.name = "NetworkError";
46
+ }
47
+ }
48
+
49
+ class PlatformError extends SpoofyError {
50
+ constructor(message, suggestions = []) {
51
+ super(message, "PLATFORM_ERROR", suggestions);
52
+ this.name = "PlatformError";
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Escapes a string for use in PowerShell commands
58
+ * @param {string} str
59
+ * @return {string}
60
+ */
61
+ function escapePowerShell(str) {
62
+ return str.replace(/'/g, "''").replace(/"/g, '`"');
63
+ }
64
+
65
+ /**
66
+ * Executes a command with timeout and better error handling
67
+ * @param {string} command
68
+ * @param {Object} options
69
+ * @param {number} timeout
70
+ * @return {string}
71
+ */
72
+ function execWithTimeout(command, options = {}, timeout = 30000) {
73
+ try {
74
+ return cp.execSync(command, {
75
+ ...options,
76
+ timeout: timeout,
77
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
78
+ }).toString();
79
+ } catch (err) {
80
+ if (err.signal === "SIGTERM") {
81
+ throw new NetworkError(
82
+ `Command timed out after ${timeout}ms: ${command.substring(0, 50)}...`,
83
+ ["Try again with a slower network connection", "Check if the interface is busy"]
84
+ );
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Retries a function with exponential backoff
92
+ * @param {Function} fn
93
+ * @param {number} maxRetries
94
+ * @param {number} delay
95
+ * @return {Promise}
96
+ */
97
+ function retry(fn, maxRetries = 3, delay = 500) {
98
+ let lastError;
99
+ for (let i = 0; i < maxRetries; i++) {
100
+ try {
101
+ return fn();
102
+ } catch (err) {
103
+ lastError = err;
104
+ if (i < maxRetries - 1) {
105
+ const waitTime = delay * Math.pow(2, i);
106
+ // Simple sleep for sync operations
107
+ const start = Date.now();
108
+ while (Date.now() - start < waitTime) {
109
+ // Busy wait
110
+ }
111
+ }
112
+ }
113
+ }
114
+ throw lastError;
115
+ }
116
+
117
+ /**
118
+ * Parses PowerShell error output for better error messages
119
+ * @param {string} errorOutput
120
+ * @return {string}
121
+ */
122
+ function parsePowerShellError(errorOutput) {
123
+ if (!errorOutput) return "Unknown PowerShell error";
124
+
125
+ // Try to extract meaningful error messages
126
+ const errorMatch = errorOutput.match(/Error:\s*(.+?)(?:\r?\n|$)/i);
127
+ if (errorMatch) {
128
+ return errorMatch[1].trim();
129
+ }
130
+
131
+ // Try to find exception messages
132
+ const exceptionMatch = errorOutput.match(/Exception:\s*(.+?)(?:\r?\n|$)/i);
133
+ if (exceptionMatch) {
134
+ return exceptionMatch[1].trim();
135
+ }
136
+
137
+ // Return first non-empty line
138
+ const lines = errorOutput.split(/\r?\n/).filter(line => line.trim());
139
+ if (lines.length > 0) {
140
+ return lines[0].trim();
141
+ }
142
+
143
+ return errorOutput.trim();
144
+ }
145
+
146
+ // Regex to validate a MAC address
147
+ // Example: 00-00-00-00-00-00 or 00:00:00:00:00:00 or 000000000000
148
+ const MAC_ADDRESS_RE =
149
+ /([0-9A-F]{1,2})[:-]?([0-9A-F]{1,2})[:-]?([0-9A-F]{1,2})[:-]?([0-9A-F]{1,2})[:-]?([0-9A-F]{1,2})[:-]?([0-9A-F]{1,2})/i;
150
+
151
+ // Regex to validate a MAC address in cisco-style
152
+ // Example: 0123.4567.89ab
153
+ const CISCO_MAC_ADDRESS_RE =
154
+ /([0-9A-F]{0,4})\.([0-9A-F]{0,4})\.([0-9A-F]{0,4})/i;
155
+
156
+ /**
157
+ * Returns the list of interfaces found on this machine as reported by the
158
+ * `networksetup` command.
159
+ * @param {Array.<string>|null} targets
160
+ * @return {Array.<Object>}
161
+ */
162
+ function findInterfaces(targets) {
163
+ if (!targets) targets = [];
164
+
165
+ targets = targets.map((target) => target.toLowerCase());
166
+
167
+ try {
168
+ if (process.platform === "darwin") {
169
+ return findInterfacesDarwin(targets);
170
+ } else if (process.platform === "linux") {
171
+ return findInterfacesLinux(targets);
172
+ } else if (process.platform === "win32") {
173
+ return findInterfacesWin32(targets);
174
+ } else {
175
+ throw new Error(
176
+ `Unsupported platform: ${process.platform}. ` +
177
+ "Supported platforms: darwin (macOS), linux, win32 (Windows)"
178
+ );
179
+ }
180
+ } catch (err) {
181
+ // Provide better error messages
182
+ if (err.message.includes("spawn") || err.message.includes("ENOENT")) {
183
+ const suggestions = [];
184
+ if (process.platform === "linux") {
185
+ suggestions.push("Install iproute2: sudo apt-get install iproute2 (Debian/Ubuntu) or sudo yum install iproute (RHEL/CentOS)");
186
+ } else if (process.platform === "win32") {
187
+ suggestions.push("Ensure PowerShell is installed and available in PATH");
188
+ }
189
+ throw new PlatformError(
190
+ `Failed to execute system command. ` +
191
+ `Platform: ${process.platform}. ` +
192
+ `Error: ${err.message}`,
193
+ suggestions
194
+ );
195
+ }
196
+ throw err;
197
+ }
198
+ }
199
+
200
+ function findInterfacesDarwin(targets) {
201
+ // Parse the output of `networksetup -listallhardwareports` which gives
202
+ // us 3 fields per port:
203
+ // - the port name,
204
+ // - the device associated with this port, if any,
205
+ // - the MAC address, if any, otherwise 'N/A'
206
+
207
+ let output = cp.execSync("networksetup -listallhardwareports").toString();
208
+
209
+ const details = [];
210
+ while (true) {
211
+ const result = /(?:Hardware Port|Device|Ethernet Address): (.+)/.exec(
212
+ output
213
+ );
214
+ if (!result || !result[1]) {
215
+ break;
216
+ }
217
+ details.push(result[1]);
218
+ output = output.slice(result.index + result[1].length);
219
+ }
220
+
221
+ const interfaces = []; // to return
222
+
223
+ // Split the results into chunks of 3 (for our three fields) and yield
224
+ // those that match `targets`.
225
+ for (let i = 0; i < details.length; i += 3) {
226
+ const port = details[i];
227
+ const device = details[i + 1];
228
+ let address = details[i + 2];
229
+
230
+ address = MAC_ADDRESS_RE.exec(address.toUpperCase());
231
+ if (address) {
232
+ address = normalize(address[0]);
233
+ }
234
+
235
+ const it = {
236
+ address: address,
237
+ currentAddress: getInterfaceMAC(device),
238
+ device: device,
239
+ port: port,
240
+ };
241
+
242
+ if (targets.length === 0) {
243
+ // Not trying to match anything in particular, return everything.
244
+ interfaces.push(it);
245
+ continue;
246
+ }
247
+
248
+ for (let j = 0; j < targets.length; j++) {
249
+ const target = targets[j];
250
+ if (target === port.toLowerCase() || target === device.toLowerCase()) {
251
+ interfaces.push(it);
252
+ break;
253
+ }
254
+ }
255
+ }
256
+
257
+ return interfaces;
258
+ }
259
+
260
+ function findInterfacesLinux(targets) {
261
+ // Use modern `ip link` command instead of deprecated `ifconfig`
262
+ let output;
263
+ try {
264
+ output = cp.execSync("ip -o link show", { stdio: "pipe" }).toString();
265
+ } catch (err) {
266
+ // Fallback to ifconfig if ip command is not available
267
+ try {
268
+ output = cp.execSync("ifconfig", { stdio: "pipe" }).toString();
269
+ return findInterfacesLinuxLegacy(output, targets);
270
+ } catch (err2) {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ const interfaces = [];
276
+ const lines = output.split("\n");
277
+
278
+ for (let i = 0; i < lines.length; i++) {
279
+ const line = lines[i];
280
+ // Parse: <index>: <name>: <flags> ... link/ether <mac> ...
281
+ const match = /^\d+:\s+([^:]+):\s+.*?\s+link\/ether\s+([0-9a-f:]+)/i.exec(line);
282
+ if (!match) continue;
283
+
284
+ const device = match[1].trim();
285
+ let address = match[2] ? normalize(match[2]) : null;
286
+
287
+ const it = {
288
+ address: address,
289
+ currentAddress: getInterfaceMAC(device),
290
+ device: device,
291
+ port: device, // Linux doesn't have port names like macOS
292
+ };
293
+
294
+ if (targets.length === 0) {
295
+ interfaces.push(it);
296
+ continue;
297
+ }
298
+
299
+ for (let j = 0; j < targets.length; j++) {
300
+ const target = targets[j];
301
+ if (target === device.toLowerCase()) {
302
+ interfaces.push(it);
303
+ break;
304
+ }
305
+ }
306
+ }
307
+
308
+ return interfaces;
309
+ }
310
+
311
+ function findInterfacesLinuxLegacy(output, targets) {
312
+ // Legacy ifconfig parsing (fallback)
313
+ const details = [];
314
+ while (true) {
315
+ const result = /(.*?)HWaddr(.*)/im.exec(output);
316
+ if (!result || !result[1] || !result[2]) {
317
+ break;
318
+ }
319
+ details.push(result[1], result[2]);
320
+ output = output.slice(result.index + result[0].length);
321
+ }
322
+
323
+ const interfaces = [];
324
+
325
+ for (let i = 0; i < details.length; i += 2) {
326
+ const s = details[i].split(":");
327
+
328
+ let device, port;
329
+ if (s.length >= 2) {
330
+ device = s[0].split(" ")[0];
331
+ port = s[1].trim();
332
+ }
333
+
334
+ let address = details[i + 1].trim();
335
+ if (address) {
336
+ address = normalize(address);
337
+ }
338
+
339
+ const it = {
340
+ address: address,
341
+ currentAddress: getInterfaceMAC(device),
342
+ device: device,
343
+ port: port || device,
344
+ };
345
+
346
+ if (targets.length === 0) {
347
+ interfaces.push(it);
348
+ continue;
349
+ }
350
+
351
+ for (let j = 0; j < targets.length; j++) {
352
+ const target = targets[j];
353
+ if (target === (port || device).toLowerCase() || target === device.toLowerCase()) {
354
+ interfaces.push(it);
355
+ break;
356
+ }
357
+ }
358
+ }
359
+
360
+ return interfaces;
361
+ }
362
+
363
+ function findInterfacesWin32(targets) {
364
+ // Use PowerShell Get-NetAdapter for better reliability
365
+ let interfaces = [];
366
+
367
+ try {
368
+ const psCommand = `Get-NetAdapter | Select-Object Name, InterfaceDescription, MacAddress, Status | ConvertTo-Json -Compress`;
369
+ const output = cp
370
+ .execSync(
371
+ `powershell -Command "${psCommand}"`,
372
+ { stdio: "pipe", shell: true }
373
+ )
374
+ .toString()
375
+ .trim();
376
+
377
+ // Parse JSON output
378
+ const adapters = JSON.parse(output);
379
+ const adapterArray = Array.isArray(adapters) ? adapters : [adapters];
380
+
381
+ for (const adapter of adapterArray) {
382
+ if (!adapter || !adapter.Name) continue;
383
+
384
+ const it = {
385
+ address: adapter.MacAddress ? normalize(adapter.MacAddress) : null,
386
+ currentAddress: adapter.MacAddress ? normalize(adapter.MacAddress) : null,
387
+ device: adapter.Name,
388
+ port: adapter.InterfaceDescription || adapter.Name,
389
+ description: adapter.InterfaceDescription,
390
+ status: adapter.Status,
391
+ };
392
+
393
+ if (targets.length === 0) {
394
+ interfaces.push(it);
395
+ continue;
396
+ }
397
+
398
+ for (let j = 0; j < targets.length; j++) {
399
+ const target = targets[j];
400
+ if (
401
+ target === it.port.toLowerCase() ||
402
+ target === it.device.toLowerCase() ||
403
+ (it.description && target === it.description.toLowerCase())
404
+ ) {
405
+ interfaces.push(it);
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ } catch (err) {
411
+ // Fallback to ipconfig method if PowerShell fails
412
+ const output = cp.execSync("ipconfig /all", { stdio: "pipe" }).toString();
413
+ const lines = output.split("\n");
414
+ let it = false;
415
+
416
+ for (let i = 0; i < lines.length; i++) {
417
+ // Check if new device
418
+ let result;
419
+ if (lines[i].substr(0, 1).match(/[A-Z]/)) {
420
+ if (it) {
421
+ if (targets.length === 0) {
422
+ interfaces.push(it);
423
+ } else {
424
+ for (let j = 0; j < targets.length; j++) {
425
+ const target = targets[j];
426
+ if (
427
+ target === it.port.toLowerCase() ||
428
+ target === it.device.toLowerCase()
429
+ ) {
430
+ interfaces.push(it);
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ it = {
438
+ port: "",
439
+ device: "",
440
+ };
441
+
442
+ result = /adapter (.+?):/.exec(lines[i]);
443
+ if (!result) {
444
+ continue;
445
+ }
446
+
447
+ it.device = result[1];
448
+ }
449
+
450
+ if (!it) {
451
+ continue;
452
+ }
453
+
454
+ // Try to find address
455
+ result = /Physical Address.+?:(.*)/im.exec(lines[i]);
456
+ if (result) {
457
+ it.address = normalize(result[1].trim());
458
+ it.currentAddress = it.address;
459
+ continue;
460
+ }
461
+
462
+ // Try to find description
463
+ result = /description.+?:(.*)/im.exec(lines[i]);
464
+ if (result) {
465
+ it.description = result[1].trim();
466
+ it.port = it.description || it.device;
467
+ continue;
468
+ }
469
+ }
470
+
471
+ // Add the last interface
472
+ if (it) {
473
+ if (targets.length === 0) {
474
+ interfaces.push(it);
475
+ } else {
476
+ for (let j = 0; j < targets.length; j++) {
477
+ const target = targets[j];
478
+ if (
479
+ target === it.port.toLowerCase() ||
480
+ target === it.device.toLowerCase()
481
+ ) {
482
+ interfaces.push(it);
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ }
488
+ }
489
+
490
+ return interfaces;
491
+ }
492
+
493
+ /**
494
+ * Returns the first interface which matches `target`
495
+ * @param {string} target
496
+ * @return {Object}
497
+ */
498
+ function findInterface(target) {
499
+ const interfaces = findInterfaces([target]);
500
+ return interfaces && interfaces[0];
501
+ }
502
+
503
+ /**
504
+ * Returns currently-set MAC address of given interface. This is distinct from the
505
+ * interface's hardware MAC address.
506
+ * @return {string}
507
+ */
508
+ function getInterfaceMAC(device) {
509
+ if (process.platform === "darwin" || process.platform === "linux") {
510
+ let output;
511
+ try {
512
+ output = cp
513
+ .execSync(quote(["ifconfig", device]), { stdio: "pipe" })
514
+ .toString();
515
+ } catch (err) {
516
+ return null;
517
+ }
518
+
519
+ const address = MAC_ADDRESS_RE.exec(output);
520
+ return address && normalize(address[0]);
521
+ } else if (process.platform === "win32") {
522
+ // Use PowerShell to get current MAC address
523
+ try {
524
+ const escapedDevice = escapePowerShell(device);
525
+ const psCommand = `Get-NetAdapter -Name '${escapedDevice}' | Select-Object -ExpandProperty MacAddress`;
526
+ const output = cp
527
+ .execSync(
528
+ `powershell -Command "${psCommand}"`,
529
+ { stdio: "pipe", shell: true }
530
+ )
531
+ .toString()
532
+ .trim();
533
+
534
+ if (output) {
535
+ return normalize(output);
536
+ }
537
+ } catch (err) {
538
+ // Fallback to ipconfig method
539
+ try {
540
+ const output = cp
541
+ .execSync(`ipconfig /all`, { stdio: "pipe" })
542
+ .toString();
543
+ const regex = new RegExp(
544
+ `adapter ${device.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:[\\s\\S]*?Physical Address[\\s\\S]*?:\\s*([0-9A-F-]+)`,
545
+ "i"
546
+ );
547
+ const match = regex.exec(output);
548
+ if (match && match[1]) {
549
+ return normalize(match[1]);
550
+ }
551
+ } catch (err2) {
552
+ return null;
553
+ }
554
+ }
555
+ return null;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Sets the mac address for given `device` to `mac`.
561
+ *
562
+ * Device varies by platform:
563
+ * OS X, Linux: this is the interface name in ifconfig
564
+ * Windows: this is the network adapter name in ipconfig
565
+ *
566
+ * @param {string} device
567
+ * @param {string} mac
568
+ * @param {string=} port
569
+ */
570
+ function setInterfaceMAC(device, mac, port) {
571
+ // Validate MAC address format
572
+ if (!mac || typeof mac !== "string") {
573
+ throw new ValidationError("MAC address must be a non-empty string");
574
+ }
575
+
576
+ const normalizedMac = normalize(mac);
577
+ if (!normalizedMac) {
578
+ throw new ValidationError(
579
+ `"${mac}" is not a valid MAC address. ` +
580
+ "Expected format: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX",
581
+ [
582
+ "Use colons (:) or dashes (-) as separators",
583
+ "Each byte must be a valid hexadecimal value (00-FF)",
584
+ "Example: 00:11:22:33:44:55"
585
+ ]
586
+ );
587
+ }
588
+
589
+ // Validate MAC address is not all zeros or broadcast
590
+ if (normalizedMac === "00:00:00:00:00:00" || normalizedMac === "FF:FF:FF:FF:FF:FF") {
591
+ throw new ValidationError(
592
+ `"${normalizedMac}" is not a valid MAC address (cannot be all zeros or broadcast address)`,
593
+ ["Generate a random MAC address using: spoofy randomize"]
594
+ );
595
+ }
596
+
597
+ // Validate device name
598
+ if (!device || typeof device !== "string" || device.trim().length === 0) {
599
+ throw new ValidationError(
600
+ "Device name must be a non-empty string",
601
+ ["List available devices using: spoofy list"]
602
+ );
603
+ }
604
+
605
+ // Use normalized MAC address
606
+ mac = normalizedMac;
607
+
608
+ const isWirelessPort = port && port.toLowerCase() === "wi-fi";
609
+
610
+ if (process.platform === "darwin") {
611
+ let macChangeError = null;
612
+
613
+ if (isWirelessPort) {
614
+ // On modern macOS (Sequoia 15.4+, Tahoe 26+), WiFi MAC can only be changed
615
+ // in the brief window after WiFi is powered on but before it connects to a network.
616
+ // We must NOT use ifconfig down as it causes "Network is down" errors.
617
+ try {
618
+ cp.execSync(quote(["networksetup", "-setairportpower", device, "off"]));
619
+ cp.execSync(quote(["networksetup", "-setairportpower", device, "on"]));
620
+ // Change MAC immediately in the window before auto-join
621
+ cp.execFileSync("ifconfig", [device, "ether", mac]);
622
+ } catch (err) {
623
+ macChangeError = err;
624
+ }
625
+
626
+ try {
627
+ cp.execSync(quote(["networksetup", "-detectnewhardware"]));
628
+ } catch (err) {
629
+ // Ignore
630
+ }
631
+ } else {
632
+ // Non-WiFi interfaces: standard down/change/up sequence
633
+ try {
634
+ cp.execFileSync("ifconfig", [device, "down"]);
635
+ } catch (err) {
636
+ macChangeError = new Error(
637
+ "Unable to bring interface down: " + err.message
638
+ );
639
+ }
640
+
641
+ if (!macChangeError) {
642
+ try {
643
+ cp.execFileSync("ifconfig", [device, "ether", mac]);
644
+ } catch (err) {
645
+ macChangeError = err;
646
+ }
647
+ }
648
+
649
+ try {
650
+ cp.execFileSync("ifconfig", [device, "up"]);
651
+ } catch (err) {
652
+ if (!macChangeError) {
653
+ macChangeError = new Error(
654
+ "Unable to bring interface up: " + err.message
655
+ );
656
+ }
657
+ }
658
+ }
659
+
660
+ if (macChangeError) {
661
+ // Verify if the change actually took effect
662
+ const newMac = getInterfaceMAC(device);
663
+ if (newMac && newMac.toLowerCase() === mac.toLowerCase()) {
664
+ // Change succeeded despite error
665
+ return;
666
+ }
667
+
668
+ throw new NetworkError(
669
+ `Unable to change MAC address on ${device}: ${macChangeError.message}`,
670
+ [
671
+ "Ensure you have root privileges (use sudo)",
672
+ "On macOS, you may need to disconnect from WiFi networks first",
673
+ "Try disabling and re-enabling the interface manually",
674
+ "Some network adapters may not support MAC address changes (hardware limitation)"
675
+ ]
676
+ );
677
+ }
678
+
679
+ // Verify the change took effect
680
+ const newMac = getInterfaceMAC(device);
681
+ if (newMac && newMac.toLowerCase() !== mac.toLowerCase()) {
682
+ throw new NetworkError(
683
+ `MAC address change verification failed. Expected ${mac}, but got ${newMac}`,
684
+ [
685
+ "The change may not have taken effect",
686
+ "Try running the command again",
687
+ "On macOS, you may need to reconnect to WiFi after the change"
688
+ ]
689
+ );
690
+ }
691
+ } else if (process.platform === "linux") {
692
+ // Modern Linux support using ip link commands
693
+ let macChangeError = null;
694
+
695
+ try {
696
+ // Bring interface down
697
+ cp.execFileSync("ip", ["link", "set", device, "down"]);
698
+ } catch (err) {
699
+ macChangeError = new Error(
700
+ "Unable to bring interface down: " + err.message
701
+ );
702
+ }
703
+
704
+ if (!macChangeError) {
705
+ try {
706
+ // Set MAC address using ip link
707
+ cp.execFileSync("ip", ["link", "set", device, "address", mac]);
708
+ } catch (err) {
709
+ macChangeError = err;
710
+ }
711
+ }
712
+
713
+ try {
714
+ // Bring interface back up
715
+ cp.execFileSync("ip", ["link", "set", device, "up"]);
716
+ } catch (err) {
717
+ if (!macChangeError) {
718
+ macChangeError = new Error(
719
+ "Unable to bring interface up: " + err.message
720
+ );
721
+ }
722
+ }
723
+
724
+ if (macChangeError) {
725
+ // Verify if the change actually took effect
726
+ const newMac = getInterfaceMAC(device);
727
+ if (newMac && newMac.toLowerCase() === mac.toLowerCase()) {
728
+ // Change succeeded despite error
729
+ return;
730
+ }
731
+
732
+ throw new NetworkError(
733
+ `Unable to change MAC address on ${device}: ${macChangeError.message}`,
734
+ [
735
+ "Ensure you have root privileges (use sudo)",
736
+ "Check if the interface is currently in use",
737
+ "Some network adapters may not support MAC address changes"
738
+ ]
739
+ );
740
+ }
741
+
742
+ // Verify the change took effect
743
+ const newMac = getInterfaceMAC(device);
744
+ if (newMac && newMac.toLowerCase() !== mac.toLowerCase()) {
745
+ throw new NetworkError(
746
+ `MAC address change verification failed. Expected ${mac}, but got ${newMac}`,
747
+ [
748
+ "The change may not have taken effect",
749
+ "Try running the command again",
750
+ "Some adapters require a restart to apply MAC changes"
751
+ ]
752
+ );
753
+ }
754
+ } else if (process.platform === "win32") {
755
+ // Windows support using PowerShell and registry
756
+ let macChangeError = null;
757
+
758
+ // Convert MAC address to Windows format (no colons, no dashes)
759
+ const macNoSeparators = mac.replace(/[:-]/g, "");
760
+
761
+ try {
762
+ // Method 1: Try using PowerShell Set-NetAdapter (Windows 8+)
763
+ const escapedDevice = escapePowerShell(device);
764
+ const escapedMac = escapePowerShell(mac);
765
+ const psCommand = `$ErrorActionPreference = 'Stop'; try { $adapter = Get-NetAdapter -Name '${escapedDevice}' -ErrorAction Stop; if ($adapter) { $adapter | Set-NetAdapter -MacAddress '${escapedMac}' -ErrorAction Stop; Write-Host 'Success' } else { throw 'Adapter not found: ${escapedDevice}' } } catch { Write-Error $_.Exception.Message; exit 1 }`;
766
+ try {
767
+ execWithTimeout(
768
+ `powershell -Command "${psCommand}"`,
769
+ { shell: true },
770
+ 30000
771
+ );
772
+ } catch (err) {
773
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
774
+ // Method 2: Fallback to registry method
775
+ // Get adapter registry path
776
+ const getGuidCommand = `$ErrorActionPreference = 'Stop'; try { Get-NetAdapter -Name '${escapedDevice}' -ErrorAction Stop | Select-Object -ExpandProperty InterfaceGuid } catch { Write-Error $_.Exception.Message; exit 1 }`;
777
+ let guidOutput;
778
+ try {
779
+ guidOutput = execWithTimeout(
780
+ `powershell -Command "${getGuidCommand}"`,
781
+ { shell: true },
782
+ 30000
783
+ ).toString().trim();
784
+ } catch (err) {
785
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
786
+ throw new NetworkError(
787
+ `Could not find adapter "${device}": ${errorMsg}`,
788
+ [
789
+ "List available adapters using: spoofy list",
790
+ "Ensure the adapter name is correct (case-sensitive)",
791
+ "Check if the adapter is enabled"
792
+ ]
793
+ );
794
+ }
795
+
796
+ if (!guidOutput || guidOutput.toLowerCase().includes("error")) {
797
+ throw new NetworkError(
798
+ `Could not find adapter GUID for "${device}"`,
799
+ [
800
+ "The adapter may not exist or may be disabled",
801
+ "List available adapters using: spoofy list"
802
+ ]
803
+ );
804
+ }
805
+
806
+ // Disable adapter
807
+ const disableCommand = `$ErrorActionPreference = 'Stop'; try { Disable-NetAdapter -Name '${escapedDevice}' -Confirm:$false -ErrorAction Stop } catch { Write-Error $_.Exception.Message; exit 1 }`;
808
+ try {
809
+ execWithTimeout(
810
+ `powershell -Command "${disableCommand}"`,
811
+ { shell: true },
812
+ 30000
813
+ );
814
+ } catch (err) {
815
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
816
+ throw new NetworkError(
817
+ `Could not disable adapter "${device}": ${errorMsg}`,
818
+ [
819
+ "Ensure you have Administrator privileges",
820
+ "The adapter may be in use by another application"
821
+ ]
822
+ );
823
+ }
824
+
825
+ // Set MAC address in registry
826
+ const registryPath = `HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e972-e325-11ce-bfc1-08002be10318}`;
827
+ const escapedGuid = escapePowerShell(guidOutput);
828
+ const findGuidCommand = `$ErrorActionPreference = 'Stop'; try { $path = '${registryPath}'; Get-ChildItem -Path $path -ErrorAction Stop | Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).NetCfgInstanceId -eq '${escapedGuid}' } | Select-Object -ExpandProperty PSPath } catch { Write-Error $_.Exception.Message; exit 1 }`;
829
+ let adapterPath;
830
+ try {
831
+ adapterPath = execWithTimeout(
832
+ `powershell -Command "${findGuidCommand}"`,
833
+ { shell: true },
834
+ 30000
835
+ ).toString().trim();
836
+ } catch (err) {
837
+ // Re-enable adapter before throwing error
838
+ try {
839
+ execWithTimeout(
840
+ `powershell -Command "Enable-NetAdapter -Name '${escapedDevice}' -Confirm:$false"`,
841
+ { shell: true },
842
+ 10000
843
+ );
844
+ } catch (e) {
845
+ // Ignore re-enable errors
846
+ }
847
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
848
+ throw new NetworkError(
849
+ `Could not find adapter registry path: ${errorMsg}`,
850
+ [
851
+ "The adapter may not support MAC address changes",
852
+ "Try using the Set-NetAdapter method instead"
853
+ ]
854
+ );
855
+ }
856
+
857
+ if (!adapterPath || adapterPath.toLowerCase().includes("error")) {
858
+ // Re-enable adapter before throwing error
859
+ try {
860
+ execWithTimeout(
861
+ `powershell -Command "Enable-NetAdapter -Name '${escapedDevice}' -Confirm:$false"`,
862
+ { shell: true },
863
+ 10000
864
+ );
865
+ } catch (e) {
866
+ // Ignore re-enable errors
867
+ }
868
+ throw new NetworkError(
869
+ `Could not find adapter registry path for "${device}"`,
870
+ [
871
+ "The adapter may not support MAC address changes via registry",
872
+ "Try using a different method or adapter"
873
+ ]
874
+ );
875
+ }
876
+
877
+ // Set NetworkAddress registry value
878
+ const escapedPath = escapePowerShell(adapterPath);
879
+ const setMacCommand = `$ErrorActionPreference = 'Stop'; try { Set-ItemProperty -Path '${escapedPath}' -Name 'NetworkAddress' -Value '${macNoSeparators}' -ErrorAction Stop } catch { Write-Error $_.Exception.Message; exit 1 }`;
880
+ try {
881
+ execWithTimeout(
882
+ `powershell -Command "${setMacCommand}"`,
883
+ { shell: true },
884
+ 30000
885
+ );
886
+ } catch (err) {
887
+ // Re-enable adapter before throwing error
888
+ try {
889
+ execWithTimeout(
890
+ `powershell -Command "Enable-NetAdapter -Name '${escapedDevice}' -Confirm:$false"`,
891
+ { shell: true },
892
+ 10000
893
+ );
894
+ } catch (e) {
895
+ // Ignore re-enable errors
896
+ }
897
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
898
+ throw new NetworkError(
899
+ `Could not set MAC address in registry: ${errorMsg}`,
900
+ [
901
+ "Ensure you have Administrator privileges",
902
+ "The adapter may not support MAC address changes"
903
+ ]
904
+ );
905
+ }
906
+
907
+ // Enable adapter
908
+ const enableCommand = `$ErrorActionPreference = 'Stop'; try { Enable-NetAdapter -Name '${escapedDevice}' -Confirm:$false -ErrorAction Stop } catch { Write-Error $_.Exception.Message; exit 1 }`;
909
+ try {
910
+ execWithTimeout(
911
+ `powershell -Command "${enableCommand}"`,
912
+ { shell: true },
913
+ 30000
914
+ );
915
+ } catch (err) {
916
+ const errorMsg = parsePowerShellError(err.stderr ? err.stderr.toString() : err.message);
917
+ throw new NetworkError(
918
+ `Could not re-enable adapter "${device}": ${errorMsg}. ` +
919
+ "The adapter may need to be enabled manually.",
920
+ [
921
+ "Try enabling the adapter manually from Network Settings",
922
+ "The MAC address may have been changed but adapter is disabled"
923
+ ]
924
+ );
925
+ }
926
+ }
927
+ } catch (err) {
928
+ macChangeError = err;
929
+ }
930
+
931
+ if (macChangeError) {
932
+ // Verify if the change actually took effect
933
+ const newMac = getInterfaceMAC(device);
934
+ if (newMac && newMac.toLowerCase() === mac.toLowerCase()) {
935
+ // Change succeeded despite error
936
+ return;
937
+ }
938
+
939
+ const suggestions = [
940
+ "Ensure you are running as Administrator",
941
+ "Some network adapters may not support MAC address changes (hardware limitation)",
942
+ "Try disabling and re-enabling the adapter manually",
943
+ "Check if the adapter is in use by another application"
944
+ ];
945
+
946
+ if (macChangeError.message && macChangeError.message.includes("Permission")) {
947
+ suggestions.unshift("Right-click PowerShell/CMD and select 'Run as Administrator'");
948
+ }
949
+
950
+ throw new NetworkError(
951
+ `Unable to change MAC address on "${device}": ${macChangeError.message}`,
952
+ suggestions
953
+ );
954
+ }
955
+
956
+ // Verify the change took effect (with retry for Windows)
957
+ let newMac;
958
+ try {
959
+ newMac = retry(() => getInterfaceMAC(device), 3, 1000);
960
+ } catch (err) {
961
+ // If we can't verify, assume it worked (better than failing)
962
+ return;
963
+ }
964
+
965
+ if (newMac && newMac.toLowerCase() !== mac.toLowerCase()) {
966
+ throw new NetworkError(
967
+ `MAC address change verification failed. Expected ${mac}, but got ${newMac}`,
968
+ [
969
+ "The change may not have taken effect",
970
+ "Try running the command again",
971
+ "Some adapters require a restart to apply MAC changes",
972
+ "The adapter may not support MAC address changes"
973
+ ]
974
+ );
975
+ }
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Generates and returns a random MAC address.
981
+ * @param {boolean} localAdmin locally administered address
982
+ * @return {string}
983
+ */
984
+ function randomize(localAdmin) {
985
+ // Randomly assign a VM vendor's MAC address prefix, which should
986
+ // decrease chance of colliding with existing device's addresses.
987
+
988
+ const vendors = [
989
+ [0x00, 0x05, 0x69], // VMware
990
+ [0x00, 0x50, 0x56], // VMware
991
+ [0x00, 0x0c, 0x29], // VMware
992
+ [0x00, 0x16, 0x3e], // Xen
993
+ [0x00, 0x03, 0xff], // Microsoft Hyper-V, Virtual Server, Virtual PC
994
+ [0x00, 0x1c, 0x42], // Parallels
995
+ [0x00, 0x0f, 0x4b], // Virtual Iron 4
996
+ [0x08, 0x00, 0x27], // Sun Virtual Box
997
+ ];
998
+
999
+ // Windows needs specific prefixes sometimes
1000
+ // http://www.wikihow.com/Change-a-Computer's-Mac-Address-in-Windows
1001
+ const windowsPrefixes = ["D2", "D6", "DA", "DE"];
1002
+
1003
+ const vendor = vendors[random(0, vendors.length - 1)];
1004
+
1005
+ if (process.platform === "win32") {
1006
+ // Parse hex string to number (fix for Windows randomize bug)
1007
+ vendor[0] = parseInt(windowsPrefixes[random(0, 3)], 16);
1008
+ }
1009
+
1010
+ const mac = [
1011
+ vendor[0],
1012
+ vendor[1],
1013
+ vendor[2],
1014
+ random(0x00, 0x7f),
1015
+ random(0x00, 0xff),
1016
+ random(0x00, 0xff),
1017
+ ];
1018
+
1019
+ if (localAdmin) {
1020
+ // Universally administered and locally administered addresses are
1021
+ // distinguished by setting the second least significant bit of the
1022
+ // most significant byte of the address. If the bit is 0, the address
1023
+ // is universally administered. If it is 1, the address is locally
1024
+ // administered. In the example address 02-00-00-00-00-01 the most
1025
+ // significant byte is 02h. The binary is 00000010 and the second
1026
+ // least significant bit is 1. Therefore, it is a locally administered
1027
+ // address.[3] The bit is 0 in all OUIs.
1028
+ mac[0] |= 2;
1029
+ }
1030
+
1031
+ return mac
1032
+ .map((byte) => zeroFill(2, byte.toString(16)))
1033
+ .join(":")
1034
+ .toUpperCase();
1035
+ }
1036
+
1037
+ /**
1038
+ * Takes a MAC address in various formats:
1039
+ *
1040
+ * - 00:00:00:00:00:00,
1041
+ * - 00-00-00-00-00-00,
1042
+ * - 0000.0000.0000
1043
+ *
1044
+ * ... and returns it in the format 00:00:00:00:00:00.
1045
+ *
1046
+ * @param {string} mac
1047
+ * @return {string}
1048
+ */
1049
+ function normalize(mac) {
1050
+ if (!mac || typeof mac !== "string") {
1051
+ return null;
1052
+ }
1053
+
1054
+ // Remove whitespace
1055
+ mac = mac.trim();
1056
+
1057
+ if (mac.length === 0) {
1058
+ return null;
1059
+ }
1060
+
1061
+ // Try Cisco format first (e.g., 0123.4567.89ab)
1062
+ let m = CISCO_MAC_ADDRESS_RE.exec(mac);
1063
+ if (m) {
1064
+ const halfwords = m.slice(1);
1065
+ // Validate all halfwords are present
1066
+ if (halfwords.length === 3 && halfwords.every(hw => hw && hw.length > 0)) {
1067
+ mac = halfwords
1068
+ .map((halfword) => {
1069
+ return zeroFill(4, halfword);
1070
+ })
1071
+ .join("");
1072
+ if (mac.length === 12) {
1073
+ return chunk(mac, 2).join(":").toUpperCase();
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ // Try standard MAC format (e.g., 00:11:22:33:44:55 or 00-11-22-33-44-55)
1079
+ m = MAC_ADDRESS_RE.exec(mac);
1080
+ if (m) {
1081
+ const bytes = m.slice(1);
1082
+ // Validate we have exactly 6 bytes
1083
+ if (bytes.length === 6 && bytes.every(byte => byte && byte.length > 0)) {
1084
+ const normalized = bytes
1085
+ .map((byte) => zeroFill(2, byte))
1086
+ .join(":")
1087
+ .toUpperCase();
1088
+
1089
+ // Final validation: should be exactly 17 characters (6 bytes + 5 colons)
1090
+ if (normalized.length === 17) {
1091
+ return normalized;
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ return null;
1097
+ }
1098
+
1099
+ function chunk(str, n) {
1100
+ const arr = [];
1101
+ for (let i = 0; i < str.length; i += n) {
1102
+ arr.push(str.slice(i, i + n));
1103
+ }
1104
+ return arr;
1105
+ }
1106
+
1107
+ /**
1108
+ * Return a random integer between min and max (inclusive).
1109
+ * @param {number} min
1110
+ * @param {number} max
1111
+ * @return {number}
1112
+ */
1113
+ function random(min, max) {
1114
+ return min + Math.floor(Math.random() * (max - min + 1));
1115
+ }