Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
166 changes: 125 additions & 41 deletions linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package machineid

import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/<name>/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

Expand All @@ -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)
}
}

Expand Down
Loading
Loading