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:
parent
950f73371c
commit
70140bc289
10 changed files with 39518 additions and 3 deletions
|
|
@ -403,14 +403,16 @@ function displayDevices(devices) {
|
||||||
|
|
||||||
const displayName = device.name && device.name.trim() !== ''
|
const displayName = device.name && device.name.trim() !== ''
|
||||||
? device.name
|
? device.name
|
||||||
: 'Sans nom';
|
: (device.vendor ? device.vendor : 'Sans nom');
|
||||||
|
|
||||||
|
const showVendor = device.vendor && device.vendor !== displayName;
|
||||||
const metrics = buildDeviceMetrics(device);
|
const metrics = buildDeviceMetrics(device);
|
||||||
|
|
||||||
deviceCard.innerHTML = `
|
deviceCard.innerHTML = `
|
||||||
${getDeviceIcon(device.type)}
|
${getDeviceIcon(device.type)}
|
||||||
<div class="device-name">${escapeHtml(displayName)}</div>
|
<div class="device-name">${escapeHtml(displayName)}</div>
|
||||||
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
|
||||||
|
${showVendor ? `<div class="device-vendor">${escapeHtml(device.vendor)}</div>` : ''}
|
||||||
<div class="device-info">
|
<div class="device-info">
|
||||||
<div>${escapeHtml(device.ip || '—')}</div>
|
<div>${escapeHtml(device.ip || '—')}</div>
|
||||||
<div class="device-mac">${escapeHtml(device.mac)}</div>
|
<div class="device-mac">${escapeHtml(device.mac)}</div>
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,16 @@ body {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-vendor {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-info {
|
.device-info {
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,13 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
|
||||||
for _, entry := range arpEntries {
|
for _, entry := range arpEntries {
|
||||||
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
|
// Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
|
||||||
if entry.Flags == 2 || entry.Flags == 6 {
|
if entry.Flags == 2 || entry.Flags == 6 {
|
||||||
|
mac := entry.HWAddress.String()
|
||||||
st := backend.Station{
|
st := backend.Station{
|
||||||
MAC: entry.HWAddress.String(),
|
MAC: mac,
|
||||||
IP: entry.IP.String(),
|
IP: entry.IP.String(),
|
||||||
Hostname: "", // No hostname available from ARP
|
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
|
Signal: 0, // Not available from ARP
|
||||||
RxBytes: 0, // Not available from ARP
|
RxBytes: 0, // Not available from ARP
|
||||||
TxBytes: 0, // Not available from ARP
|
TxBytes: 0, // Not available from ARP
|
||||||
|
|
|
||||||
98
internal/station/backend/oui.go
Normal file
98
internal/station/backend/oui.go
Normal 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
|
||||||
|
}
|
||||||
39343
internal/station/backend/oui_data.txt
Normal file
39343
internal/station/backend/oui_data.txt
Normal file
File diff suppressed because it is too large
Load diff
57
internal/station/backend/oui_test.go
Normal file
57
internal/station/backend/oui_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,7 @@ type Station struct {
|
||||||
IP string // IP address (may be empty for some backends initially)
|
IP string // IP address (may be empty for some backends initially)
|
||||||
Hostname string // Device hostname (may be empty)
|
Hostname string // Device hostname (may be empty)
|
||||||
Type string // Device type: "mobile", "laptop", "tablet", "unknown"
|
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)
|
Signal int32 // Signal strength in dBm (0 if not available)
|
||||||
RxBytes uint64 // Received bytes (0 if not available)
|
RxBytes uint64 // Received bytes (0 if not available)
|
||||||
TxBytes uint64 // Transmitted bytes (0 if not available)
|
TxBytes uint64 // Transmitted bytes (0 if not available)
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ func (b *Backend) GetStations() ([]backend.Station, error) {
|
||||||
IP: lease.IP,
|
IP: lease.IP,
|
||||||
Hostname: lease.Hostname,
|
Hostname: lease.Hostname,
|
||||||
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
|
Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
|
||||||
|
Vendor: backend.LookupVendor(lease.MAC),
|
||||||
Signal: 0, // Not available from DHCP
|
Signal: 0, // Not available from DHCP
|
||||||
RxBytes: 0, // Not available from DHCP
|
RxBytes: 0, // Not available from DHCP
|
||||||
TxBytes: 0, // Not available from DHCP
|
TxBytes: 0, // Not available from DHCP
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
|
||||||
IP: ip,
|
IP: ip,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Type: backend.GuessDeviceType(hostname, mac),
|
Type: backend.GuessDeviceType(hostname, mac),
|
||||||
|
Vendor: backend.LookupVendor(mac),
|
||||||
Signal: hs.Signal,
|
Signal: hs.Signal,
|
||||||
RxBytes: hs.RxBytes,
|
RxBytes: hs.RxBytes,
|
||||||
TxBytes: hs.TxBytes,
|
TxBytes: hs.TxBytes,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ func toConnectedDevice(s backend.Station) models.ConnectedDevice {
|
||||||
Type: s.Type,
|
Type: s.Type,
|
||||||
MAC: s.MAC,
|
MAC: s.MAC,
|
||||||
IP: s.IP,
|
IP: s.IP,
|
||||||
|
Vendor: s.Vendor,
|
||||||
SignalDbm: s.Signal,
|
SignalDbm: s.Signal,
|
||||||
RxBytes: s.RxBytes,
|
RxBytes: s.RxBytes,
|
||||||
TxBytes: s.TxBytes,
|
TxBytes: s.TxBytes,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue