diff --git a/backend/internal/routines/scan-routine.go b/backend/internal/routines/scan-routine.go index 2470a01..b0f6947 100644 --- a/backend/internal/routines/scan-routine.go +++ b/backend/internal/routines/scan-routine.go @@ -1,6 +1,8 @@ package routines import ( + "sort" + "strings" "time" "github.com/aceberg/WatchYourLAN/internal/arp" @@ -28,14 +30,9 @@ func startScan(quit chan bool) { if nowDate.After(plusDate) { foundHosts = arp.Scan(conf.AppConfig.Ifaces, conf.AppConfig.ArpArgs, conf.AppConfig.ArpStrs) + foundHosts = check.EnrichHosts(foundHosts) - // Make map of found hosts - foundHostsMap := make(map[string]models.Host) - for _, fHost := range foundHosts { - foundHostsMap[fHost.Mac] = fHost - } - - compareHosts(foundHostsMap) + compareHosts(newFoundHostIndex(foundHosts)) lastDate = time.Now() } @@ -45,24 +42,42 @@ func startScan(quit chan bool) { } } -func compareHosts(foundHostsMap map[string]models.Host) { +func compareHosts(foundHosts *foundHostIndex) { allHosts, ok := gdb.Select("now") if !ok { return } + existingMACCounts := make(map[string]int) + for _, aHost := range allHosts { + mac := normalizeMAC(aHost.Mac) + if mac != "" { + existingMACCounts[mac]++ + } + } + for _, aHost := range allHosts { - fHost, exists := foundHostsMap[aHost.Mac] + mac := normalizeMAC(aHost.Mac) + fHost, exists := foundHosts.match(aHost, existingMACCounts[mac] == 1) if exists { aHost.Iface = fHost.Iface aHost.IP = fHost.IP + if aHost.Name == "" && fHost.Name != "" { + aHost.Name = fHost.Name + } + if aHost.DNS == "" && fHost.DNS != "" { + aHost.DNS = fHost.DNS + } + if isUnknownHardware(aHost.Hw) && fHost.Hw != "" { + aHost.Hw = fHost.Hw + } aHost.Date = fHost.Date aHost.Now = 1 - delete(foundHostsMap, aHost.Mac) + foundHosts.delete(fHost) } else { aHost.Now = 0 @@ -81,11 +96,147 @@ func compareHosts(foundHostsMap map[string]models.Host) { } } - for _, fHost := range foundHostsMap { + for _, fHost := range foundHosts.remaining() { - fHost.Name, fHost.DNS = check.DNS(fHost) + if fHost.Name == "" || fHost.DNS == "" { + fHost.Name, fHost.DNS = check.DNS(fHost) + } notify.Unknown(fHost) // Log and Shoutrrr gdb.Update("now", fHost) } } + +type foundHostIndex struct { + hosts map[string]models.Host + keysByIP map[string][]string + keysByMAC map[string][]string +} + +func newFoundHostIndex(hosts []models.Host) *foundHostIndex { + index := &foundHostIndex{ + hosts: make(map[string]models.Host), + keysByIP: make(map[string][]string), + keysByMAC: make(map[string][]string), + } + + for _, host := range hosts { + key := hostIdentityKey(host) + if key == "" { + continue + } + + index.hosts[key] = host + + if host.IP != "" { + index.keysByIP[host.IP] = appendUnique(index.keysByIP[host.IP], key) + } + + mac := normalizeMAC(host.Mac) + if mac != "" { + index.keysByMAC[mac] = appendUnique(index.keysByMAC[mac], key) + } + } + + return index +} + +func (index *foundHostIndex) match(existing models.Host, allowMACFallback bool) (models.Host, bool) { + key := hostIdentityKey(existing) + if host, ok := index.hosts[key]; ok { + return host, true + } + + if existing.IP != "" { + if host, ok := index.only(index.keysByIP[existing.IP]); ok { + return host, true + } + } + + mac := normalizeMAC(existing.Mac) + if allowMACFallback && mac != "" { + if host, ok := index.only(index.keysByMAC[mac]); ok { + return host, true + } + } + + return models.Host{}, false +} + +func (index *foundHostIndex) only(keys []string) (models.Host, bool) { + active := make([]string, 0, len(keys)) + for _, key := range keys { + if _, ok := index.hosts[key]; ok { + active = append(active, key) + } + } + + if len(active) != 1 { + return models.Host{}, false + } + + host, ok := index.hosts[active[0]] + return host, ok +} + +func (index *foundHostIndex) delete(host models.Host) { + key := hostIdentityKey(host) + delete(index.hosts, key) +} + +func (index *foundHostIndex) remaining() []models.Host { + keys := make([]string, 0, len(index.hosts)) + for key := range index.hosts { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return index.hosts[keys[i]].IP < index.hosts[keys[j]].IP + }) + + hosts := make([]models.Host, 0, len(keys)) + for _, key := range keys { + hosts = append(hosts, index.hosts[key]) + } + + return hosts +} + +func hostIdentityKey(host models.Host) string { + ip := strings.TrimSpace(host.IP) + mac := normalizeMAC(host.Mac) + + if mac != "" && ip != "" { + return mac + "|" + ip + } + if mac != "" { + return mac + } + return ip +} + +func normalizeMAC(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func appendUnique(items []string, more ...string) []string { + seen := make(map[string]struct{}, len(items)+len(more)) + out := make([]string, 0, len(items)+len(more)) + + for _, item := range append(items, more...) { + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + + return out +} + +func isUnknownHardware(value string) bool { + value = strings.TrimSpace(value) + return value == "" || strings.HasPrefix(value, "(Unknown") +} diff --git a/backend/internal/routines/scan-routine_test.go b/backend/internal/routines/scan-routine_test.go new file mode 100644 index 0000000..68c9c42 --- /dev/null +++ b/backend/internal/routines/scan-routine_test.go @@ -0,0 +1,57 @@ +package routines + +import ( + "testing" + + "github.com/aceberg/WatchYourLAN/internal/models" +) + +func TestFoundHostIndexKeepsSameMACDifferentIPs(t *testing.T) { + index := newFoundHostIndex([]models.Host{ + {IP: "192.168.57.146", Mac: "48:b0:2d:eb:f5:15"}, + {IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, + }) + + host, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, false) + if !ok { + t.Fatal("expected exact MAC+IP match") + } + if host.IP != "192.168.57.209" { + t.Fatalf("matched wrong IP: %s", host.IP) + } + + index.delete(host) + + remaining := index.remaining() + if len(remaining) != 1 { + t.Fatalf("expected one remaining host, got %d", len(remaining)) + } + if remaining[0].IP != "192.168.57.146" { + t.Fatalf("expected 192.168.57.146 to remain, got %s", remaining[0].IP) + } +} + +func TestFoundHostIndexUsesMACForSingleAddressChange(t *testing.T) { + index := newFoundHostIndex([]models.Host{ + {IP: "192.168.57.210", Mac: "48:b0:2d:eb:f5:15"}, + }) + + host, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, true) + if !ok { + t.Fatal("expected single MAC match for address change") + } + if host.IP != "192.168.57.210" { + t.Fatalf("matched wrong IP: %s", host.IP) + } +} + +func TestFoundHostIndexDoesNotUseMACFallbackForKnownMultiIPHost(t *testing.T) { + index := newFoundHostIndex([]models.Host{ + {IP: "192.168.57.146", Mac: "48:b0:2d:eb:f5:15"}, + }) + + _, ok := index.match(models.Host{IP: "192.168.57.209", Mac: "48:b0:2d:eb:f5:15"}, false) + if ok { + t.Fatal("did not expect MAC fallback when same MAC is already known on multiple IPs") + } +}