Skip to content

Commit 272a0c4

Browse files
committed
feat(devices): discover wired static-IP hosts via router ARP table (#507)
1 parent 82f7b6e commit 272a0c4

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

‎server-go/internal/adapters/arp_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,61 @@ func TestBuildDevicesArpFallback(t *testing.T) {
2929
}
3030
}
3131

32+
// TestBuildDevicesArpDiscovery: una MAC visible SOLO por ARP (sin wireless,
33+
// sin FDB, sin lease, sin device_attrib) aparece como dispositivo cableado
34+
// online con la IP de la tabla ARP y el RouterID del router que la reportó
35+
// (#507).
36+
func TestBuildDevicesArpDiscovery(t *testing.T) {
37+
l := NewLive(nil, nil, nil, nil)
38+
polled := map[string]*routerPolled{
39+
"patio": {
40+
cfg: RouterConfig{ID: "patio", Name: "Patio", Host: "192.168.1.2"},
41+
arp: map[string]string{
42+
"AA:BB:CC:DD:EE:FF": "192.168.1.60",
43+
},
44+
},
45+
}
46+
devs := l.buildDevices(polled)
47+
if len(devs) != 1 {
48+
t.Fatalf("esperado 1 dispositivo, got %d: %+v", len(devs), devs)
49+
}
50+
d := devs[0]
51+
if d.MAC != "AA:BB:CC:DD:EE:FF" {
52+
t.Fatalf("MAC esperada AA:BB:CC:DD:EE:FF, got %q", d.MAC)
53+
}
54+
if d.IP != "192.168.1.60" {
55+
t.Fatalf("IP esperada 192.168.1.60, got %q", d.IP)
56+
}
57+
if d.Band != "cable" {
58+
t.Fatalf("Band esperada cable, got %q", d.Band)
59+
}
60+
if !d.Online {
61+
t.Fatalf("Online esperado true (presencia ARP = activo reciente)")
62+
}
63+
if d.RouterID != "patio" {
64+
t.Fatalf("RouterID esperado patio, got %q", d.RouterID)
65+
}
66+
}
67+
68+
// TestBuildDevicesArpRouterExcluded: una MAC de bridge/router presente en la
69+
// tabla ARP NO debe aparecer como dispositivo (#507).
70+
func TestBuildDevicesArpRouterExcluded(t *testing.T) {
71+
l := NewLive(nil, nil, nil, nil)
72+
polled := map[string]*routerPolled{
73+
"patio": {
74+
cfg: RouterConfig{ID: "patio", Name: "Patio", Host: "192.168.1.2"},
75+
brMac: "AA:BB:CC:DD:EE:FF",
76+
arp: map[string]string{
77+
"AA:BB:CC:DD:EE:FF": "192.168.1.60",
78+
},
79+
},
80+
}
81+
devs := l.buildDevices(polled)
82+
if len(devs) != 0 {
83+
t.Fatalf("esperado 0 dispositivos (bridge MAC excluida), got %d: %+v", len(devs), devs)
84+
}
85+
}
86+
3287
func TestPolledFromAgentArp(t *testing.T) {
3388
l := NewLive(nil, nil, nil, nil)
3489
pl := &probe.Payload{Router: "patio", Version: "0.1.0"}

‎server-go/internal/adapters/live.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,34 @@ func (l *Live) buildDevices(polled map[string]*routerPolled) []Device {
18351835
}
18361836
}
18371837
}
1838+
// (4) ARP (#507): hosts cableados con IP estática visibles SOLO en la
1839+
// tabla ARP del router (ni wireless, ni FDB, ni lease, ni device_attrib).
1840+
// Presencia en la tabla = activo recientemente → online este tick. No se
1841+
// persiste: cuando la entrada envejece, el host desaparece del snapshot.
1842+
// Se itera en orden de router ID para atribuir deterministamente el
1843+
// RouterID correcto cuando varios routers ven la misma MAC (misma LAN).
1844+
// La exclusión de MACs de router/bridge se aplica en el bucle de
1845+
// dispositivos (routerMacs), igual que para leases/known: aquí no se
1846+
// duplica ese filtro.
1847+
arpRouterIDs := make([]string, 0, len(polled))
1848+
for routerID := range polled {
1849+
arpRouterIDs = append(arpRouterIDs, routerID)
1850+
}
1851+
sort.Strings(arpRouterIDs)
1852+
for _, routerID := range arpRouterIDs {
1853+
for mac := range polled[routerID].arp {
1854+
if _, ok := seen[mac]; ok {
1855+
continue
1856+
}
1857+
if _, ok := leasesByMac[mac]; ok {
1858+
continue
1859+
}
1860+
if _, ok := known[mac]; ok {
1861+
continue
1862+
}
1863+
seen[mac] = seenInfo{routerID, "cable", nil}
1864+
}
1865+
}
18381866

18391867
allMacs := map[string]bool{}
18401868
for mac := range leasesByMac {

0 commit comments

Comments
 (0)