// Package macvendor implements the mac_vendor module. // It maps the OUI (first 3 bytes) of every known host's MAC address to a // human-readable vendor name and persists the result in the database. // The mapping is based on a built-in table; no external files are required. package macvendor import ( "context" "fmt" "log/slog" "strings" "time" "github.com/cereda-systems/nexus-scanner/internal/db" ) // ouiTable maps lowercase OUI prefixes (aa:bb:cc) to vendor names. // Broadcast / multicast entries are intentionally kept with an empty string // so they are recognised but not written to the database. var ouiTable = map[string]string{ "00:0c:29": "VMware", "00:50:56": "VMware", "bc:24:11": "Proxmox/Ceph", "90:1b:0e": "Supermicro", "90:09:d0": "Synology", "00:17:c8": "Hewlett-Packard", "00:be:43": "Ubiquiti", "e4:43:4b": "Ubiquiti", "24:6a:0e": "Ubiquiti", "40:86:cb": "Intel", "68:c6:ac": "Intel", "1c:af:4a": "Dell", "50:81:40": "Dell", "4c:5f:70": "Lenovo", "dc:58:bc": "Apple", "c8:4b:d6": "Kyocera", "38:d5:7a": "Samsung", "00:04:a5": "Barco", "7c:5a:1c": "LANCOM", "00:0a:b3": "Cisco", "00:e0:67": "Aten", "ff:ff:ff": "", // broadcast — leave vendor empty } // LookupVendor returns the vendor name for a MAC address. // The MAC must be in the format aa:bb:cc:dd:ee:ff (colon-separated). // An empty string is returned when no match is found. func LookupVendor(mac string) string { if len(mac) < 8 { return "" } oui := strings.ToLower(mac[:8]) return ouiTable[oui] } // Module enriches hosts in the database with OUI-based vendor names. type Module struct { site string store *db.Store log *slog.Logger } // New returns a new mac_vendor module. func New(site string, store *db.Store) *Module { return &Module{ site: site, store: store, log: slog.With("module", "mac_vendor"), } } func (m *Module) Name() string { return "mac_vendor" } // Interval returns 0, which causes the scheduler to run this module once at // startup and never repeat it automatically. func (m *Module) Interval() time.Duration { return 0 } // Run assigns vendor names to all hosts that have none recorded yet. func (m *Module) Run(ctx context.Context) error { scanID, err := m.store.BeginScan(m.Name()) if err != nil { return fmt.Errorf("begin scan: %w", err) } scanErr := m.scan(ctx) if endErr := m.store.EndScan(scanID, scanErr); endErr != nil { m.log.Error("record scan end", "err", endErr) } return scanErr } func (m *Module) scan(ctx context.Context) error { hosts, err := m.store.ListHosts("") if err != nil { return fmt.Errorf("list hosts: %w", err) } enriched := 0 for _, h := range hosts { // Skip hosts that already have a vendor assigned. if h.Vendor != "" { continue } select { case <-ctx.Done(): return ctx.Err() default: } vendor := LookupVendor(h.MAC) if vendor == "" { // No entry in our table — leave vendor empty. continue } h.Vendor = vendor if err := m.store.UpsertHost(h); err != nil { m.log.Error("upsert host", "ip", h.IP, "err", err) continue } m.log.Info("vendor resolved", "ip", h.IP, "mac", h.MAC, "vendor", vendor) enriched++ } m.log.Info("scan complete", "checked", len(hosts), "enriched", enriched) return nil }