Track hosts by MAC and IP during scans

This commit is contained in:
Corsair-cxs 2026-06-20 16:45:20 +08:00
parent ddb9da18ac
commit 85a158d712
2 changed files with 220 additions and 12 deletions

View file

@ -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")
}

View file

@ -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")
}
}