From 48f7519932f81de392eeeb43818b8c69d2322406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 19 Sep 2026 20:32:32 +0200 Subject: [PATCH] feat!: stop routine events from rotating machine IDs Four inputs that changed on ordinary maintenance are now excluded from the hash. This changes IDs on affected machines; see the README section "ID compatibility across versions" for the migration note. Linux CPU (WithCPU) - The identifier is now "vendor:model[:hardware]" from the first core. The kernel's "flags" line gained entries after kernel and microcode updates and the processor index changed when a VM was resized; both rotated the ID on routine maintenance. - aarch64 kernels have no vendor_id or model name; "CPU implementer", "CPU part", "CPU variant", "CPU revision" and "Hardware" are used instead of the previous empty fields. Network (WithMAC, physical filter) - Hyper-V and WSL switches ("vEthernet (...)"), Bluetooth PAN, Wi-Fi Direct, VirtualBox and VMware host adapters, Npcap and TAP-Windows on Windows; awdl, llw, ap and anpi on macOS; tailscale, zerotier, cni, flannel, calico, dummy, vxlan and other overlay names on Linux are now classified as virtual. - Windows friendly names (with spaces) use their own rules so the kernel prefix "lo" cannot misclassify "Local Area Connection". Loopback is matched exactly (lo, lo0, ...). Disks (WithDisk) - Linux: lsblk is queried with -P -o NAME,TYPE,RM,SERIAL and only TYPE=disk RM=0 devices count; /sys/block skips removable=1 and the loop/ram/zram/dm-/md/sr/fd/nbd/mtd families. - Windows: USB interfaces and Removable/External media are excluded in both the wmic and the PowerShell path. - Plugging in a USB drive, SD card or DVD no longer changes the ID. README: stability table rewritten, MAC filter table updated, migration paragraph added. Table tests cover every new classification and the "flags added / index changed => same identifier" property. Co-Authored-By: Claude Fable 5.1 --- README.md | 24 ++++--- doc.go | 2 +- linux.go | 166 ++++++++++++++++++++++++++++++++++++------------ linux_test.go | 152 ++++++++++++++++++++++++++++++++++++++------ machineid.go | 5 +- network.go | 76 ++++++++++++++++------ network_test.go | 35 ++++++++++ strings.go | 14 ++++ windows.go | 71 +++++++++++++++++++-- windows_test.go | 49 +++++++++++++- 10 files changed, 495 insertions(+), 99 deletions(-) create mode 100644 strings.go diff --git a/README.md b/README.md index a8081e6..fc76f82 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ With no component flags the default is `-cpu -motherboard -uuid`. ```go provider := machineid.New(). - WithCPU(). // processor identifier and feature flags + WithCPU(). // processor identifier (vendor and model) WithMotherboard(). // motherboard / baseboard serial number WithSystemUUID(). // BIOS / UEFI system UUID WithMAC(). // physical network interface MAC addresses @@ -313,9 +313,9 @@ machineid.New().WithCPU().WithMAC(machineid.MACFilterVirtual) | Filter | Interfaces included | Best for | |--------|---------------------|----------| -| `MACFilterPhysical` | `en0`, `eth0`, `wlan0`, … (default) | Bare-metal stability | -| `MACFilterAll` | Physical + virtual (`docker0`, `utun`, `bridge`, …) | Maximum uniqueness | -| `MACFilterVirtual` | `docker0`, `utun`, `bridge0`, `veth`, `vmnet`, … | Container fingerprinting | +| `MACFilterPhysical` | `en0`, `eth0`, `wlan0`, `Ethernet`, `Wi-Fi`, … (default) | Bare-metal stability | +| `MACFilterAll` | Physical + virtual | Maximum uniqueness | +| `MACFilterVirtual` | VPN and tunnels (`utun`, `wg`, `tailscale`), containers (`docker0`, `veth`, `cni`, `flannel`, `cali`), hypervisors (`vmnet`, `vboxnet`, `vEthernet (WSL)`), Bluetooth PAN, Wi-Fi Direct, `awdl`, `llw`, `bridge`, … | Container fingerprinting | Loopback interfaces and interfaces that are down are always excluded. @@ -490,13 +490,17 @@ Be deliberate about which of these your users are likely to do. | Event | `cpu` | `uuid` | `motherboard` | `mac` | `disk` | |-------|:-----:|:------:|:-------------:|:-----:|:------:| | Reboot, OS reinstall | – | – | – | – | – | +| Kernel, microcode or driver update | – | – | – | – | – | +| Plug in or remove a USB drive, SD card or DVD | – | – | – | – | – | +| Connect a VPN, start Docker, enable Wi-Fi Direct or Bluetooth PAN | – | – | – | – (physical filter) | – | | Replace the motherboard | ✅ | ✅ | ✅ | – | – | -| Replace or add a NIC | – | – | – | ✅ | – | -| Replace, add or remove a disk | – | – | – | – | ✅ | -| Kernel or microcode update (Linux) | ⚠️ | – | – | – | – | -| VM migration or resize | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ | +| Replace or add a physical NIC | – | – | – | ✅ | – | +| Replace, add or remove an internal disk | – | – | – | – | ✅ | +| VM migration or resize | – | ⚠️ | ⚠️ | ✅ | ✅ | -⚠️ On Linux the CPU identifier includes the kernel's CPU `flags` line, which can gain entries after a kernel or microcode update. On virtual machines the hypervisor decides how stable the UUID and motherboard serial are. If either matters to you, prefer `VMFriendly()` plus a salt, or drop `WithCPU()` on Linux. +⚠️ On virtual machines the hypervisor decides how stable the UUID and motherboard serial are. If that matters to you, prefer `VMFriendly()` plus a salt. + +**ID compatibility across versions.** IDs generated by v0.1.x differ from v0.2.0 and later in three cases, all of them made deliberately so routine events stop rotating IDs: Linux `WithCPU()` (the kernel's `flags` line and the processor index are no longer hashed), `WithMAC()` on machines with Hyper-V, WSL, Bluetooth PAN, Wi-Fi Direct, Apple `awdl`/`llw` or container overlay interfaces (now classified as virtual), and `WithDisk()` on machines with a USB or removable drive attached (no longer hashed). Everything else is unchanged. If you store IDs for licensing, re-enrol affected machines once when upgrading, or validate against both the old and new value during a transition window. --- @@ -554,7 +558,7 @@ Runnable examples are listed with `go doc -ex github.com/slashdevops/machineid`. **`ErrNoIdentifiers` on a VM or in a container.** The hypervisor or runtime is hiding the hardware. Run `machineid -all -diagnostics -debug` to see which sources fail, then use `VMFriendly()` or a subset that works in that environment. -**The ID changed after a Linux kernel update.** See [What changes an ID](#what-changes-an-id). The CPU flags line moved. Drop `WithCPU()` on Linux or switch to `VMFriendly()`. +**The ID changed after upgrading from v0.1.x.** See [ID compatibility](#what-changes-an-id). Three inputs were made stable on purpose; the old IDs for affected machines cannot be reproduced by the new version. **Windows is slow.** PowerShell start-up dominates. Make sure you are on a build where `wmic` is either present or cleanly absent; the library handles both. Use `-debug` to see per-command timing. diff --git a/doc.go b/doc.go index c984767..aef3732 100644 --- a/doc.go +++ b/doc.go @@ -26,7 +26,7 @@ // // Enable individual hardware components via the With* methods: // -// - [Provider.WithCPU] — processor identifier and feature flags +// - [Provider.WithCPU] — processor identifier (vendor and model; no volatile feature flags) // - [Provider.WithMotherboard] — motherboard / baseboard serial number // - [Provider.WithSystemUUID] — BIOS / UEFI system UUID // - [Provider.WithMAC] — MAC addresses of network interfaces (filterable) diff --git a/linux.go b/linux.go index bd63ca4..1536f82 100644 --- a/linux.go +++ b/linux.go @@ -4,7 +4,6 @@ package machineid import ( "context" - "fmt" "log/slog" "os" "path/filepath" @@ -84,39 +83,66 @@ func linuxCPUID(logger *slog.Logger) (string, error) { return parseCPUInfo(string(data)) } -// parseCPUInfo extracts CPU information from /proc/cpuinfo content. +// parseCPUInfo extracts a stable CPU identifier from /proc/cpuinfo content. +// +// The identifier is "vendor:model[:hardware]", built from the first processor +// entry. It deliberately excludes the "flags" line, which gains entries after +// kernel and microcode updates, and the processor index, which changes when a +// VM is resized; both used to rotate the machine ID on routine maintenance. +// +// x86 kernels provide "vendor_id" and "model name". ARM kernels provide +// "CPU implementer", "CPU part", "CPU variant" and "CPU revision" instead, +// often with a "Hardware" line naming the board; those are used when the x86 +// fields are absent. +// // Returns ErrNotFound when none of the expected fields are present, so an // empty or malformed /proc/cpuinfo does not silently contribute a fixed -// all-colons string to the machine ID. +// string to the machine ID. func parseCPUInfo(content string) (string, error) { - lines := strings.Split(content, "\n") - var processor, vendorID, modelName, flags string + fields := map[string]string{} - for _, line := range lines { - line = strings.TrimSpace(line) - _, value, found := strings.Cut(line, ":") + for line := range strings.SplitSeq(content, "\n") { + key, value, found := strings.Cut(line, ":") if !found { continue } + key = strings.ToLower(strings.TrimSpace(key)) value = strings.TrimSpace(value) + if value == "" { + continue + } + // First occurrence wins: every core repeats the same values. + if _, seen := fields[key]; !seen { + fields[key] = value + } + } - switch { - case strings.HasPrefix(line, "processor"): - processor = value - case strings.HasPrefix(line, "vendor_id"): - vendorID = value - case strings.HasPrefix(line, "model name"): - modelName = value - case strings.HasPrefix(line, "flags"): - flags = value + vendor := fields["vendor_id"] + if vendor == "" { + vendor = fields["cpu implementer"] + } + + model := fields["model name"] + if model == "" { + var parts []string + for _, key := range []string{"cpu part", "cpu variant", "cpu revision"} { + if v := fields[key]; v != "" { + parts = append(parts, v) + } } + model = strings.Join(parts, "/") } - if processor == "" && vendorID == "" && modelName == "" && flags == "" { + if vendor == "" && model == "" { return "", &ParseError{Source: "/proc/cpuinfo", Err: ErrNotFound} } - return fmt.Sprintf("%s:%s:%s:%s", processor, vendorID, modelName, flags), nil + id := vendor + ":" + model + if hw := fields["hardware"]; hw != "" { + id += ":" + hw + } + + return id, nil } // linuxSystemUUID retrieves system UUID from DMI. @@ -249,29 +275,74 @@ func linuxDiskSerials(ctx context.Context, executor CommandExecutor, logger *slo return serials, nil } -// linuxDiskSerialsLSBLK retrieves disk serials using lsblk command. -// OEM placeholder strings are filtered out. +// linuxDiskSerialsLSBLK retrieves the serials of fixed disks using lsblk. +// +// Removable devices (USB sticks, SD cards) and non-disk devices (optical +// drives, loop devices) are excluded: plugging one in must not change the +// machine ID. OEM placeholder strings are filtered out. func linuxDiskSerialsLSBLK(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { - output, err := executeCommand(ctx, executor, logger, "lsblk", "-d", "-n", "-o", "SERIAL") + output, err := executeCommand(ctx, executor, logger, "lsblk", "-d", "-n", "-P", "-o", "NAME,TYPE,RM,SERIAL") if err != nil { return nil, err } var serials []string - lines := strings.SplitSeq(output, "\n") - for line := range lines { - serial := strings.TrimSpace(line) - if !isValidSerial(serial) { + for line := range strings.SplitSeq(output, "\n") { + dev := parseKeyValueLine(line) + if len(dev) == 0 { continue } - serials = append(serials, serial) + + if dev["TYPE"] != "disk" || dev["RM"] == "1" { + if logger != nil { + logger.Debug("skipping block device", "name", dev["NAME"], "type", dev["TYPE"], "removable", dev["RM"]) + } + + continue + } + + if serial := dev["SERIAL"]; isValidSerial(serial) { + serials = append(serials, serial) + } } return serials, nil } -// linuxDiskSerialsSys retrieves disk serials from /sys/block. -// OEM placeholder strings are filtered out. +// parseKeyValueLine parses lsblk -P output: KEY="value" pairs separated by spaces. +func parseKeyValueLine(line string) map[string]string { + fields := map[string]string{} + rest := strings.TrimSpace(line) + + for rest != "" { + key, after, ok := strings.Cut(rest, "=\"") + if !ok { + break + } + value, remainder, ok := strings.Cut(after, "\"") + if !ok { + break + } + fields[strings.TrimSpace(key)] = value + rest = strings.TrimSpace(remainder) + } + + return fields +} + +// virtualBlockPrefixes name block devices that are never fixed disks. +var virtualBlockPrefixes = []string{"loop", "ram", "zram", "dm-", "md", "sr", "fd", "nbd", "mtd"} + +// isRemovableBlockDevice reports whether /sys/block//removable is set. +func isRemovableBlockDevice(name string) bool { + data, err := os.ReadFile(filepath.Join(sysBlockDir, name, "removable")) + + return err == nil && strings.TrimSpace(string(data)) == "1" +} + +// linuxDiskSerialsSys retrieves the serials of fixed disks from /sys/block. +// Virtual and removable block devices are skipped; OEM placeholder strings +// are filtered out. func linuxDiskSerialsSys(logger *slog.Logger) ([]string, error) { var serials []string @@ -281,19 +352,32 @@ func linuxDiskSerialsSys(logger *slog.Logger) ([]string, error) { } for _, entry := range entries { - if entry.IsDir() && !strings.HasPrefix(entry.Name(), "loop") { - serialFile := filepath.Join(sysBlockDir, entry.Name(), "device", "serial") - if data, err := os.ReadFile(serialFile); err == nil { - serial := strings.TrimSpace(string(data)) - if !isValidSerial(serial) { - continue - } - serials = append(serials, serial) - - if logger != nil { - logger.Debug("read disk serial from sysfs", "disk", entry.Name(), "path", serialFile) - } + if !entry.IsDir() { + continue + } + name := entry.Name() + if hasAnyPrefix(name, virtualBlockPrefixes) || isRemovableBlockDevice(name) { + if logger != nil { + logger.Debug("skipping block device", "disk", name) } + + continue + } + + serialFile := filepath.Join(sysBlockDir, name, "device", "serial") + data, err := os.ReadFile(serialFile) + if err != nil { + continue + } + + serial := strings.TrimSpace(string(data)) + if !isValidSerial(serial) { + continue + } + serials = append(serials, serial) + + if logger != nil { + logger.Debug("read disk serial from sysfs", "disk", name, "path", serialFile) } } diff --git a/linux_test.go b/linux_test.go index c17a714..4ad26b9 100644 --- a/linux_test.go +++ b/linux_test.go @@ -29,7 +29,56 @@ flags : fpu vme de pse tsc msr pae mce if err != nil { t.Fatalf("Unexpected error: %v", err) } - expected := "0:GenuineIntel:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz:fpu vme de pse tsc msr pae mce" + expected := "GenuineIntel:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +// TestParseCPUInfoIgnoresVolatileFields verifies that the flags line and the +// processor index do not contribute: a kernel or microcode update that adds +// a mitigation flag, or a VM resize, must not change the identifier. +func TestParseCPUInfoIgnoresVolatileFields(t *testing.T) { + before := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel Core i7\nflags\t\t: fpu vme\n" + after := "processor\t: 7\nvendor_id\t: GenuineIntel\nmodel name\t: Intel Core i7\nflags\t\t: fpu vme md_clear flush_l1d\n" + + a, err := parseCPUInfo(before) + if err != nil { + t.Fatal(err) + } + b, err := parseCPUInfo(after) + if err != nil { + t.Fatal(err) + } + if a != b { + t.Errorf("identifier changed with flags/processor index: %q vs %q", a, b) + } +} + +// TestParseCPUInfoARM covers aarch64 kernels, which have no vendor_id or +// model name and describe the CPU with implementer/part/variant/revision. +func TestParseCPUInfoARM(t *testing.T) { + content := `processor : 0 +BogoMIPS : 108.00 +Features : fp asimd evtstrm crc32 cpuid +CPU implementer : 0x41 +CPU architecture: 8 +CPU variant : 0x0 +CPU part : 0xd08 +CPU revision : 3 + +processor : 1 +BogoMIPS : 108.00 +CPU implementer : 0x41 +CPU part : 0xd08 +CPU revision : 3 +Hardware : BCM2835 +` + result, err := parseCPUInfo(content) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + expected := "0x41:0xd08/0x0/3:BCM2835" if result != expected { t.Errorf("Expected %q, got %q", expected, result) } @@ -57,7 +106,7 @@ model name : AMD Ryzen 9 5950X if err != nil { t.Fatalf("Unexpected error: %v", err) } - expected := "3:AuthenticAMD:AMD Ryzen 9 5950X:" + expected := "AuthenticAMD:AMD Ryzen 9 5950X" if result != expected { t.Errorf("Expected %q, got %q", expected, result) } @@ -87,7 +136,7 @@ func TestParseCPUInfoUnknownFieldsOnly(t *testing.T) { } func TestParseCPUInfoMultipleProcessors(t *testing.T) { - // parseCPUInfo keeps overwriting, so the last processor block wins + // Every core repeats the same vendor and model; the first block is used. content := `processor : 0 vendor_id : GenuineIntel model name : Intel Core i7 @@ -102,8 +151,7 @@ flags : fpu vme avx if err != nil { t.Fatalf("Unexpected error: %v", err) } - // Last processor's values should win - expected := "1:GenuineIntel:Intel Core i7:fpu vme avx" + expected := "GenuineIntel:Intel Core i7" if result != expected { t.Errorf("Expected %q, got %q", expected, result) } @@ -147,7 +195,7 @@ func TestIsNonEmpty(t *testing.T) { func TestLinuxDiskSerialsLSBLKSuccess(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "WD-12345\nSAMSUNG-67890\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "WD-12345")+lsblkLine("nvme0n1", "disk", "0", "SAMSUNG-67890")) serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) if err != nil { @@ -186,7 +234,7 @@ func TestLinuxDiskSerialsLSBLKError(t *testing.T) { func TestLinuxDiskSerialsLSBLKSkipsEmpty(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "WD-12345\n\n\nSAMSUNG-67890\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "WD-12345")+"\n\n"+lsblkLine("sdb", "disk", "0", "SAMSUNG-67890")) serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) if err != nil { @@ -199,7 +247,7 @@ func TestLinuxDiskSerialsLSBLKSkipsEmpty(t *testing.T) { func TestLinuxDiskSerialsLSBLKFiltersOEM(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "WD-12345\nTo be filled by O.E.M.\nSAMSUNG-67890\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "WD-12345")+lsblkLine("sdb", "disk", "0", "To be filled by O.E.M.")+lsblkLine("sdc", "disk", "0", "SAMSUNG-67890")) serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) if err != nil { @@ -215,6 +263,42 @@ func TestLinuxDiskSerialsLSBLKFiltersOEM(t *testing.T) { } } +// TestLinuxDiskSerialsLSBLKSkipsRemovableAndNonDisk verifies that USB sticks, +// optical drives and loop devices never contribute: plugging one in must not +// change the machine ID. +func TestLinuxDiskSerialsLSBLKSkipsRemovableAndNonDisk(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("lsblk", + lsblkLine("sda", "disk", "0", "FIXED-1")+ + lsblkLine("sdb", "disk", "1", "USB-STICK")+ + lsblkLine("sr0", "rom", "1", "DVD-DRIVE")+ + lsblkLine("loop0", "loop", "0", "")+ + lsblkLine("nvme0n1", "disk", "0", "FIXED-2")) + + serials, err := linuxDiskSerialsLSBLK(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(serials) != 2 || serials[0] != "FIXED-1" || serials[1] != "FIXED-2" { + t.Errorf("Expected [FIXED-1 FIXED-2], got %v", serials) + } +} + +func TestParseKeyValueLine(t *testing.T) { + got := parseKeyValueLine(`NAME="sda" TYPE="disk" RM="0" SERIAL="S/N with spaces"`) + if got["NAME"] != "sda" || got["TYPE"] != "disk" || got["RM"] != "0" || got["SERIAL"] != "S/N with spaces" { + t.Errorf("parseKeyValueLine = %v", got) + } + if len(parseKeyValueLine("")) != 0 || len(parseKeyValueLine("garbage")) != 0 { + t.Error("non key=value input should yield no fields") + } +} + +// lsblkLine renders one line of `lsblk -P -o NAME,TYPE,RM,SERIAL` output. +func lsblkLine(name, typ, rm, serial string) string { + return fmt.Sprintf("NAME=%q TYPE=%q RM=%q SERIAL=%q\n", name, typ, rm, serial) +} + // --- linuxDiskSerialsSys tests (with injected sysBlockDir) --- func withSysBlockDir(t *testing.T, dir string) { @@ -267,6 +351,36 @@ func TestLinuxDiskSerialsSysSkipsLoop(t *testing.T) { } } +func TestLinuxDiskSerialsSysSkipsVirtualAndRemovable(t *testing.T) { + tmp := t.TempDir() + withSysBlockDir(t, tmp) + + writeFakeDisk(t, tmp, "sda", "FIXED\n") + for _, name := range []string{"ram0", "zram0", "dm-0", "md127", "sr0", "fd0", "nbd0", "mtdblock0"} { + writeFakeDisk(t, tmp, name, "VIRTUAL-"+name+"\n") + } + writeFakeDisk(t, tmp, "sdb", "USB-STICK\n") + writeFakeRemovable(t, tmp, "sdb", "1\n") + writeFakeDisk(t, tmp, "sdc", "FIXED-EXPLICIT\n") + writeFakeRemovable(t, tmp, "sdc", "0\n") + + serials, err := linuxDiskSerialsSys(nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(serials) != 2 || serials[0] != "FIXED" || serials[1] != "FIXED-EXPLICIT" { + t.Errorf("Expected [FIXED FIXED-EXPLICIT], got %v", serials) + } +} + +// writeFakeRemovable writes /sys/block//removable. +func writeFakeRemovable(t *testing.T, root, name, value string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name, "removable"), []byte(value), 0o644); err != nil { + t.Fatalf("write removable %q: %v", name, err) + } +} + func TestLinuxDiskSerialsSysFiltersOEMAndEmpty(t *testing.T) { tmp := t.TempDir() withSysBlockDir(t, tmp) @@ -314,7 +428,7 @@ func TestLinuxDiskSerialsPartialSuccess(t *testing.T) { // lsblk succeeds, /sys/block missing → no error, lsblk results returned. withSysBlockDir(t, filepath.Join(t.TempDir(), "nope")) mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL-X\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL-X")) serials, err := linuxDiskSerials(context.Background(), mock, nil) if err != nil { @@ -332,7 +446,7 @@ func TestLinuxDiskSerialsDeduplicatesAcrossBackends(t *testing.T) { writeFakeDisk(t, tmp, "sdb", "ONLY-SYS\n") mock := newMockExecutor() - mock.setOutput("lsblk", "SHARED\nONLY-LSBLK\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SHARED")+lsblkLine("sdb", "disk", "0", "ONLY-LSBLK")) serials, err := linuxDiskSerials(context.Background(), mock, nil) if err != nil { @@ -359,7 +473,7 @@ func TestLinuxDiskSerialsFiltersOEMAcrossBackends(t *testing.T) { writeFakeDisk(t, tmp, "sda", "GOOD-SYS\n") mock := newMockExecutor() - mock.setOutput("lsblk", "GOOD-LSBLK\nTo be filled by O.E.M.\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "GOOD-LSBLK")+lsblkLine("sdb", "disk", "0", "To be filled by O.E.M.")) serials, err := linuxDiskSerials(context.Background(), mock, nil) if err != nil { @@ -422,7 +536,7 @@ func TestReadFirstValidFromLocationsSkipsInvalid(t *testing.T) { func TestLinuxDiskSerialsDeduplicated(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL-A\nSERIAL-B\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL-A")+lsblkLine("sdb", "disk", "0", "SERIAL-B")) serials, err := linuxDiskSerials(context.Background(), mock, nil) if err != nil { @@ -437,7 +551,7 @@ func TestLinuxDiskSerialsWithLogger(t *testing.T) { logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL-LOG\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL-LOG")) _, err := linuxDiskSerials(context.Background(), mock, logger) if err != nil { @@ -452,7 +566,7 @@ func TestLinuxDiskSerialsWithLogger(t *testing.T) { func TestProviderWithMockExecutor(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL-A\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL-A")) p := New().WithExecutor(mock).WithDisk() @@ -479,7 +593,7 @@ func TestProviderErrorHandlingLinux(t *testing.T) { func TestProviderDiagnosticsLinux(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL")) p := New().WithExecutor(mock).WithDisk() @@ -503,7 +617,7 @@ func TestProviderWithLoggerLinux(t *testing.T) { logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL")) p := New().WithExecutor(mock).WithLogger(logger).WithDisk() @@ -519,7 +633,7 @@ func TestProviderWithLoggerLinux(t *testing.T) { func TestProviderValidateLinux(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL")) p := New().WithExecutor(mock).WithDisk() @@ -592,7 +706,7 @@ func TestValidateErrorLinux(t *testing.T) { func TestProviderCachedIDLinux(t *testing.T) { mock := newMockExecutor() - mock.setOutput("lsblk", "SERIAL1\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL1")) p := New().WithExecutor(mock).WithDisk() @@ -601,7 +715,7 @@ func TestProviderCachedIDLinux(t *testing.T) { t.Fatalf("First ID() error: %v", err) } - mock.setOutput("lsblk", "SERIAL2\n") + mock.setOutput("lsblk", lsblkLine("sda", "disk", "0", "SERIAL2")) id2, err := p.ID(context.Background()) if err != nil { diff --git a/machineid.go b/machineid.go index 10d41e5..90620f0 100644 --- a/machineid.go +++ b/machineid.go @@ -127,7 +127,10 @@ func (p *Provider) WithFormat(mode FormatMode) *Provider { return p } -// WithCPU includes the CPU identifier in the generation. +// WithCPU includes the CPU identifier in the generation: the processor ID on +// Windows, the brand string on macOS, and the vendor and model on Linux. +// Volatile details such as the Linux feature-flags line are deliberately +// excluded so routine kernel and microcode updates do not change the ID. func (p *Provider) WithCPU() *Provider { p.includeCPU = true diff --git a/network.go b/network.go index 18947f0..d0438bf 100644 --- a/network.go +++ b/network.go @@ -6,26 +6,35 @@ import ( "strings" ) -// virtualInterfacePrefixes lists interface name prefixes that represent -// virtual, VPN, bridge, or ephemeral interfaces. These are excluded because -// they change when software is installed/removed or connections are started/stopped. +// virtualInterfacePrefixes lists kernel-style interface name prefixes (no +// spaces, as on Linux and macOS) that represent virtual, VPN, bridge, +// container or ephemeral interfaces. These are excluded because they change +// when software is installed/removed or connections are started/stopped. +// Names are compared in lower case. var virtualInterfacePrefixes = []string{ // VPN and tunnel interfaces - "utun", "tun", "tap", "ipsec", "ppp", - // Docker and container bridges - "docker", "br-", "veth", - // Virtual bridges and switches - "virbr", "vnet", "vmnet", - // Thunderbolt bridge (changes with docking state) - "bridge", - // Loopback variants - "lo", - // WireGuard - "wg", + "utun", "tun", "tap", "ipsec", "ppp", "wg", "tailscale", "zt", "nebula", "gre", "sit", "ip6tnl", "erspan", "vxlan", "geneve", + // Docker, Podman, Kubernetes and container networking + "docker", "br-", "veth", "cni", "flannel", "cali", "kube", "podman", "lxc", "lxd", + // Virtual bridges, switches and dummies + "virbr", "vnet", "vmnet", "bridge", "dummy", "ifb", "nlmon", "teql", "bond", "macvtap", // Parallels / VirtualBox / VMware "vnic", "vboxnet", + // macOS: Apple Wireless Direct Link, low-latency WLAN, access point and Apple silicon debug interfaces + "awdl", "llw", "ap", "anpi", } +// windowsVirtualPrefixes lists prefixes of Windows friendly adapter names +// that are virtual: Hyper-V and WSL switches, Bluetooth PAN, Npcap, and +// hypervisor host adapters. +var windowsVirtualPrefixes = []string{"vethernet", "bluetooth", "npcap", "vmware", "virtualbox", "hyper-v", "tap-windows"} + +// windowsVirtualSubstrings lists fragments that mark a Windows friendly name +// as virtual regardless of position, e.g. "Microsoft Wi-Fi Direct Virtual +// Adapter" or "Local Area Connection* 2" (Windows only uses the asterisk for +// virtual adapters). +var windowsVirtualSubstrings = []string{"virtual", "wi-fi direct", "loopback", "*"} + // collectMACAddresses retrieves MAC addresses from network interfaces filtered // by the given [MACFilter]. Loopback and down interfaces are always excluded. func collectMACAddresses(filter MACFilter, logger *slog.Logger) ([]string, error) { @@ -84,15 +93,44 @@ func collectMACAddresses(filter MACFilter, logger *slog.Logger) ([]string, error return macs, nil } -// isVirtualInterface reports whether the interface name matches a known -// virtual, VPN, or bridge prefix. +// isVirtualInterface reports whether the interface name denotes a virtual, +// VPN, bridge or container interface. +// +// Windows friendly names contain spaces or parentheses ("vEthernet (WSL)", +// "Local Area Connection") and are matched against the Windows rules only, so +// short kernel prefixes such as "lo" cannot misclassify "Local Area +// Connection". Kernel-style names are matched against the prefix list and an +// exact loopback pattern (lo, lo0, lo1, …). func isVirtualInterface(name string) bool { lower := strings.ToLower(name) - for _, prefix := range virtualInterfacePrefixes { - if strings.HasPrefix(lower, prefix) { + + if strings.ContainsAny(lower, " (") { + if hasAnyPrefix(lower, windowsVirtualPrefixes) { return true } + for _, fragment := range windowsVirtualSubstrings { + if strings.Contains(lower, fragment) { + return true + } + } + + return false + } + + return isLoopbackName(lower) || hasAnyPrefix(lower, virtualInterfacePrefixes) || hasAnyPrefix(lower, windowsVirtualPrefixes) +} + +// isLoopbackName reports whether name is "lo" followed only by digits. +func isLoopbackName(name string) bool { + rest, ok := strings.CutPrefix(name, "lo") + if !ok { + return false + } + for _, r := range rest { + if r < '0' || r > '9' { + return false + } } - return false + return true } diff --git a/network_test.go b/network_test.go index 5e57d78..8bd3bc1 100644 --- a/network_test.go +++ b/network_test.go @@ -91,11 +91,46 @@ func TestIsVirtualInterface(t *testing.T) { {"lo0", true}, {"wg0", true}, {"vnic0", true}, + // Added for stability: Hyper-V / WSL, Bluetooth PAN, Wi-Fi Direct, VirtualBox, + // VMware, Npcap on Windows; awdl / llw / ap / anpi on macOS; container and + // overlay networks on Linux. + {"vEthernet (WSL)", true}, + {"vEthernet (Default Switch)", true}, + {"Bluetooth Network Connection", true}, + {"Local Area Connection* 2", true}, + {"Microsoft Wi-Fi Direct Virtual Adapter #2", true}, + {"VirtualBox Host-Only Network", true}, + {"VMware Network Adapter VMnet8", true}, + {"Npcap Loopback Adapter", true}, + {"TAP-Windows Adapter V9", true}, + {"awdl0", true}, + {"llw0", true}, + {"ap1", true}, + {"anpi0", true}, + {"tailscale0", true}, + {"zt7nnzqoby", true}, + {"cni0", true}, + {"flannel.1", true}, + {"cali1a2b3c", true}, + {"dummy0", true}, + {"vxlan.calico", true}, + // Physical names stay physical. {"en0", false}, {"en1", false}, {"eth0", false}, + {"enp3s0", false}, + {"eno1", false}, {"wlan0", false}, + {"wlp2s0", false}, {"Wi-Fi", false}, + {"Ethernet", false}, + {"Ethernet 2", false}, + {"Local Area Connection", false}, + {"lo", true}, + {"lo0", true}, + {"lo1", true}, + {"lom1", false}, + {"logical0", false}, } for _, tt := range tests { diff --git a/strings.go b/strings.go new file mode 100644 index 0000000..661509e --- /dev/null +++ b/strings.go @@ -0,0 +1,14 @@ +package machineid + +import "strings" + +// hasAnyPrefix reports whether s starts with any of the prefixes. +func hasAnyPrefix(s string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(s, prefix) { + return true + } + } + + return false +} diff --git a/windows.go b/windows.go index 4f915a6..62ee221 100644 --- a/windows.go +++ b/windows.go @@ -125,6 +125,61 @@ func parseWmicMultipleValues(output, prefix string) []string { return values } +// parseWmicBlocks splits wmic /value output into one key=value map per +// instance. Instances are separated by blank lines. +func parseWmicBlocks(output string) []map[string]string { + var blocks []map[string]string + current := map[string]string{} + + flush := func() { + if len(current) > 0 { + blocks = append(blocks, current) + current = map[string]string{} + } + } + + for line := range strings.SplitSeq(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + flush() + + continue + } + if key, value, ok := strings.Cut(line, "="); ok { + current[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + flush() + + return blocks +} + +// parseWmicFixedDiskSerials extracts the serials of fixed, non-USB disks from +// `wmic diskdrive get InterfaceType,MediaType,SerialNumber /value` output. +// Instances without the type fields (older wmic output) are kept. +func parseWmicFixedDiskSerials(output string, logger *slog.Logger) []string { + var serials []string + + for _, disk := range parseWmicBlocks(output) { + media := strings.ToLower(disk["MediaType"]) + if strings.EqualFold(disk["InterfaceType"], "USB") || strings.Contains(media, "removable") || strings.Contains(media, "external") { + if logger != nil { + logger.Debug("skipping removable disk", "interface", disk["InterfaceType"], "media", disk["MediaType"]) + } + + continue + } + + serial := disk["SerialNumber"] + if serial == "" || serial == biosFirmwareMessage { + continue + } + serials = append(serials, serial) + } + + return serials +} + // parsePowerShellValue extracts a trimmed, non-empty value from PowerShell output. // OEM placeholder strings (see biosFirmwareMessage) are rejected with ErrOEMPlaceholder. func parsePowerShellValue(output string) (string, error) { @@ -274,12 +329,19 @@ func windowsSystemUUIDViaPowerShell(ctx context.Context, executor CommandExecuto return value, nil } -// windowsDiskSerials retrieves disk serial numbers using wmic, with PowerShell fallback. +// fixedDiskPowerShell lists the serials of fixed, non-USB disks. +const fixedDiskPowerShell = "Get-CimInstance -ClassName Win32_DiskDrive | " + + "Where-Object { $_.InterfaceType -ne 'USB' -and $_.MediaType -notlike '*emovable*' -and $_.MediaType -notlike '*xternal*' } | " + + "Select-Object -ExpandProperty SerialNumber" + +// windowsDiskSerials retrieves the serials of fixed disks using wmic, with +// PowerShell fallback. USB and removable media are excluded so plugging in a +// drive does not change the machine ID. func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { if wmicAvailable(logger) { - output, err := executeCommand(ctx, executor, logger, "wmic", "diskdrive", "get", "SerialNumber", "/value") + output, err := executeCommand(ctx, executor, logger, "wmic", "diskdrive", "get", "InterfaceType,MediaType,SerialNumber", "/value") if err == nil { - if values := parseWmicMultipleValues(output, "SerialNumber="); len(values) > 0 { + if values := parseWmicFixedDiskSerials(output, logger); len(values) > 0 { return values, nil } @@ -294,8 +356,7 @@ func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *s logger.Info("falling back to PowerShell for disk serials") } - psOutput, psErr := runPowerShell(ctx, executor, logger, - "Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -ExpandProperty SerialNumber") + psOutput, psErr := runPowerShell(ctx, executor, logger, fixedDiskPowerShell) if psErr != nil { if logger != nil { logger.Warn("all disk serial methods failed") diff --git a/windows_test.go b/windows_test.go index 3aa1d35..4220a51 100644 --- a/windows_test.go +++ b/windows_test.go @@ -501,9 +501,14 @@ func TestWindowsSystemUUIDViaPowerShellOEMPlaceholder(t *testing.T) { // --- windowsDiskSerials tests --- +// wmicDisk renders one instance of `wmic diskdrive get InterfaceType,MediaType,SerialNumber /value`. +func wmicDisk(iface, media, serial string) string { + return "\r\n\r\nInterfaceType=" + iface + "\r\nMediaType=" + media + "\r\nSerialNumber=" + serial + "\r\n" +} + func TestWindowsDiskSerialsWmicSuccess(t *testing.T) { mock := newMockExecutor() - mock.setOutput("wmic", "SerialNumber=WD-12345\r\nSerialNumber=WD-67890\r\n") + mock.setOutput("wmic", wmicDisk("SCSI", "Fixed hard disk media", "WD-12345")+wmicDisk("SCSI", "Fixed hard disk media", "WD-67890")) result, err := windowsDiskSerials(context.Background(), mock, nil) if err != nil { @@ -517,6 +522,44 @@ func TestWindowsDiskSerialsWmicSuccess(t *testing.T) { } } +// TestWindowsDiskSerialsWmicSkipsRemovable verifies that USB and removable +// media never contribute: plugging in a drive must not change the machine ID. +func TestWindowsDiskSerialsWmicSkipsRemovable(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", + wmicDisk("SCSI", "Fixed hard disk media", "FIXED-1")+ + wmicDisk("USB", "Removable Media", "USB-STICK")+ + wmicDisk("SCSI", "External hard disk media", "EXTERNAL")+ + wmicDisk("USB", "Fixed hard disk media", "USB-HDD")+ + wmicDisk("IDE", "Fixed hard disk media", "FIXED-2")) + + result, err := windowsDiskSerials(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result) != 2 || result[0] != "FIXED-1" || result[1] != "FIXED-2" { + t.Errorf("Expected [FIXED-1 FIXED-2], got %v", result) + } +} + +func TestParseWmicFixedDiskSerialsLegacyOutput(t *testing.T) { + // Instances without the type fields are kept. + got := parseWmicFixedDiskSerials("SerialNumber=A\r\n\r\nSerialNumber=B\r\n", nil) + if len(got) != 2 || got[0] != "A" || got[1] != "B" { + t.Errorf("got %v", got) + } +} + +func TestParseWmicBlocks(t *testing.T) { + blocks := parseWmicBlocks("\r\nA=1\r\nB=2\r\n\r\n\r\nA=3\r\n") + if len(blocks) != 2 || blocks[0]["A"] != "1" || blocks[0]["B"] != "2" || blocks[1]["A"] != "3" { + t.Errorf("parseWmicBlocks = %v", blocks) + } + if len(parseWmicBlocks("")) != 0 { + t.Error("empty output should yield no blocks") + } +} + func TestWindowsDiskSerialsFallbackToPowerShell(t *testing.T) { mock := newMockExecutor() mock.setError("wmic", fmt.Errorf("wmic not found")) @@ -594,7 +637,7 @@ func TestWindowsDiskSerialsPowerShellAllOEM(t *testing.T) { func TestWindowsDiskSerialsWmicEmptyFallback(t *testing.T) { mock := newMockExecutor() - mock.setOutput("wmic", "SerialNumber=\r\n") // wmic returns empty serials + mock.setOutput("wmic", wmicDisk("SCSI", "Fixed hard disk media", "")) // wmic returns empty serials mock.setOutput("powershell", "WD-FALLBACK") result, err := windowsDiskSerials(context.Background(), mock, nil) @@ -611,7 +654,7 @@ func TestWindowsDiskSerialsWithLogger(t *testing.T) { logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) mock := newMockExecutor() - mock.setOutput("wmic", "SerialNumber=\r\n") + mock.setOutput("wmic", wmicDisk("SCSI", "Fixed hard disk media", "")) mock.setOutput("powershell", "WD-LOG") _, err := windowsDiskSerials(context.Background(), mock, logger)