station: Identify devices by MAC OUI vendor lookup

Embed the IEEE OUI registry (~1MB pre-processed text file) and resolve
the vendor for every station MAC. Locally administered MACs (U/L bit
set, used by iOS/Android private addresses and virtual interfaces) are
skipped so we don't return spurious matches against randomized prefixes.

The vendor name shows up in the device card as a secondary line, and
falls back to the title position when no DHCP hostname is available —
"Apple" with the IP and MAC is far more useful than "Sans nom".

The lookup table loads lazily (sync.Once) on the first call so the
~40k-entry parse only runs when the station discovery code is exercised.
This commit is contained in:
nemunaire 2026-05-01 22:32:57 +08:00
commit 70140bc289
10 changed files with 39518 additions and 3 deletions

View file

@ -403,14 +403,16 @@ function displayDevices(devices) {
const displayName = device.name && device.name.trim() !== ''
? device.name
: 'Sans nom';
: (device.vendor ? device.vendor : 'Sans nom');
const showVendor = device.vendor && device.vendor !== displayName;
const metrics = buildDeviceMetrics(device);
deviceCard.innerHTML = `
${getDeviceIcon(device.type)}
<div class="device-name">${escapeHtml(displayName)}</div>
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
${showVendor ? `<div class="device-vendor">${escapeHtml(device.vendor)}</div>` : ''}
<div class="device-info">
<div>${escapeHtml(device.ip || '—')}</div>
<div class="device-mac">${escapeHtml(device.mac)}</div>

View file

@ -553,7 +553,16 @@ body {
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.125rem;
}
.device-vendor {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.375rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-info {

View file

@ -59,11 +59,13 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
for _, entry := range arpEntries {
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
if entry.Flags == 2 || entry.Flags == 6 {
mac := entry.HWAddress.String()
st := backend.Station{
MAC: entry.HWAddress.String(),
MAC: mac,
IP: entry.IP.String(),
Hostname: "", // No hostname available from ARP
Type: backend.GuessDeviceType("", entry.HWAddress.String()),
Type: backend.GuessDeviceType("", mac),
Vendor: backend.LookupVendor(mac),
Signal: 0, // Not available from ARP
RxBytes: 0, // Not available from ARP
TxBytes: 0, // Not available from ARP

View file

@ -0,0 +1,98 @@
package backend
import (
_ "embed"
"strings"
"sync"
)
// IEEE OUI registry as a compact text file: each line is 6 hex characters
// (uppercase, no separators) followed immediately by the organization name.
// Sourced from https://standards-oui.ieee.org/oui/oui.csv and pre-processed
// to strip the address column and common corporate suffixes (Inc, Ltd, ...).
//
//go:embed oui_data.txt
var ouiData string
var (
ouiOnce sync.Once
ouiTable map[string]string
)
func loadOUITable() {
ouiTable = make(map[string]string, 40000)
for line := range strings.SplitSeq(ouiData, "\n") {
if len(line) < 7 {
continue
}
ouiTable[line[:6]] = line[6:]
}
}
// LookupVendor returns the organization assigned to the OUI prefix of mac,
// or an empty string when the prefix is unknown or mac is malformed. Locally
// administered MACs (the U/L bit is set) are explicitly skipped — they are
// randomized by the device, not assigned to a vendor, so any match would be
// a false positive.
func LookupVendor(mac string) string {
ouiOnce.Do(loadOUITable)
prefix := normalizeOUI(mac)
if prefix == "" {
return ""
}
return ouiTable[prefix]
}
// normalizeOUI extracts the 6-hex-char OUI from a MAC address (any of
// "aa:bb:cc:...", "aa-bb-cc-...", "aabb.ccdd.eeff", "aabbcc...") and returns
// it uppercased. Returns "" for randomized MACs (locally administered bit
// set) or anything that doesn't parse.
func normalizeOUI(mac string) string {
var hex strings.Builder
hex.Grow(12)
for i := 0; i < len(mac) && hex.Len() < 6; i++ {
c := mac[i]
switch {
case c >= '0' && c <= '9', c >= 'A' && c <= 'F':
hex.WriteByte(c)
case c >= 'a' && c <= 'f':
hex.WriteByte(c - 32)
}
}
if hex.Len() < 6 {
return ""
}
out := hex.String()
// Skip locally administered MACs. The U/L bit is bit 1 of the first
// octet — when set, the MAC was generated by the device (private
// address randomization on iOS/Android, virtual interfaces, ...), not
// assigned by IEEE, so OUI lookup is meaningless.
first, ok := hexByte(out[0], out[1])
if !ok || first&0x02 != 0 {
return ""
}
return out
}
func hexByte(hi, lo byte) (byte, bool) {
h, ok1 := hexNibble(hi)
l, ok2 := hexNibble(lo)
if !ok1 || !ok2 {
return 0, false
}
return h<<4 | l, true
}
func hexNibble(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'A' && c <= 'F':
return c - 'A' + 10, true
case c >= 'a' && c <= 'f':
return c - 'a' + 10, true
}
return 0, false
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
package backend
import "testing"
func TestLookupVendor(t *testing.T) {
cases := []struct {
mac string
want string
exact bool // when true, want must match exactly; otherwise non-empty is enough
comment string
}{
{"00:1c:b3:11:22:33", "Apple", true, "Apple OUI"},
{"f4:0f:24:11:22:33", "Apple", true, "Apple OUI uppercase"},
{"B8:27:EB:AA:BB:CC", "", false, "Raspberry Pi Foundation"},
{"dc:a6:32:11:22:33", "", false, "Raspberry Pi Trading"},
{"02:11:22:33:44:55", "", true, "locally administered (U/L bit set)"},
{"aa:bb:cc:dd:ee:ff", "", true, "locally administered randomized"},
{"", "", true, "empty mac"},
{"not a mac", "", true, "garbage"},
}
for _, tc := range cases {
got := LookupVendor(tc.mac)
if tc.exact {
if got != tc.want {
t.Errorf("%s: LookupVendor(%q) = %q, want %q", tc.comment, tc.mac, got, tc.want)
}
continue
}
if got == "" {
t.Errorf("%s: LookupVendor(%q) returned empty, expected non-empty", tc.comment, tc.mac)
}
}
}
func TestNormalizeOUI(t *testing.T) {
cases := []struct {
in string
want string
}{
{"00:1c:b3:11:22:33", "001CB3"},
{"00-1c-b3-11-22-33", "001CB3"},
{"001c.b311.2233", "001CB3"},
{"001cb3112233", "001CB3"},
{"02:11:22:33:44:55", ""}, // U/L bit set
{"03:11:22:33:44:55", ""}, // U/L bit set
{"06:11:22:33:44:55", ""}, // U/L bit set
{"01:00:5e:00:00:00", "01005E"}, // bit 0 (multicast) is fine, only bit 1 matters
{"abcd", ""}, // too short
}
for _, tc := range cases {
if got := normalizeOUI(tc.in); got != tc.want {
t.Errorf("normalizeOUI(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View file

@ -52,6 +52,7 @@ type Station struct {
IP string // IP address (may be empty for some backends initially)
Hostname string // Device hostname (may be empty)
Type string // Device type: "mobile", "laptop", "tablet", "unknown"
Vendor string // Vendor inferred from MAC OUI (empty when unknown or randomized)
Signal int32 // Signal strength in dBm (0 if not available)
RxBytes uint64 // Received bytes (0 if not available)
TxBytes uint64 // Transmitted bytes (0 if not available)

View file

@ -72,6 +72,7 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
IP: lease.IP,
Hostname: lease.Hostname,
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
Vendor: backend.LookupVendor(lease.MAC),
Signal: 0, // Not available from DHCP
RxBytes: 0, // Not available from DHCP
TxBytes: 0, // Not available from DHCP

View file

@ -328,6 +328,7 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
IP: ip,
Hostname: hostname,
Type: backend.GuessDeviceType(hostname, mac),
Vendor: backend.LookupVendor(mac),
Signal: hs.Signal,
RxBytes: hs.RxBytes,
TxBytes: hs.TxBytes,

View file

@ -68,6 +68,7 @@ func toConnectedDevice(s backend.Station) models.ConnectedDevice {
Type: s.Type,
MAC: s.MAC,
IP: s.IP,
Vendor: s.Vendor,
SignalDbm: s.Signal,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,