From a0fd219bfee5a7c47cdad7480b6587ec8af7a1d9 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 6 Oct 2026 18:18:12 +0200 Subject: [PATCH] fix(clients): tombstones filter the final device list, not just the registry #1292: the reporter was right that the delete pipeline was broken beyond the registry - the two stale iPhone entries had NO device_seen row at all (no first_seen/last_seen), so they were being re-emitted fresh every cycle by a live source (a stale wireless station entry on the AP), never "re-admitted" through the registry. filterTombstoned now runs on both final device lists (overview and router detail), dropping tombstoned MACs no matter which source emits them. --- server-go/internal/adapters/device_seen.go | 22 +++++++++++++++++++ .../internal/adapters/device_seen_test.go | 17 ++++++++++++++ server-go/internal/adapters/live.go | 6 +++++ 3 files changed, 45 insertions(+) diff --git a/server-go/internal/adapters/device_seen.go b/server-go/internal/adapters/device_seen.go index 5de130a5..e778aa19 100644 --- a/server-go/internal/adapters/device_seen.go +++ b/server-go/internal/adapters/device_seen.go @@ -36,6 +36,28 @@ func saneIP(ip string) string { return ip } +// filterTombstoned (#1292): quita de la LISTA FINAL las MACs enterradas. +// Una fuente viva (una estación stale del AP) puede seguir emitiéndolas en +// cada ciclo sin que pasen por device_seen (ni first_seen ni last_seen): +// el filtro del registro no basta, la lista misma se filtra. +func (l *Live) filterTombstoned(devices []Device) []Device { + if l.db == nil || len(devices) == 0 { + return devices + } + tomb, err := l.db.DeletedClients() + if err != nil || len(tomb) == 0 { + return devices + } + out := devices[:0] + for _, d := range devices { + if tomb[normSeenMAC(d.MAC)] { + continue + } + out = append(out, d) + } + return out +} + // purgeFleetSeen (#1278): borra del registro la MAC base de las unidades de // flota. Las bocas/gestiones de los propios equipos se aprendían como // "clientes" vía FDB de los switches y quedaban como fantasmas offline para diff --git a/server-go/internal/adapters/device_seen_test.go b/server-go/internal/adapters/device_seen_test.go index 9e60bee6..b198574f 100644 --- a/server-go/internal/adapters/device_seen_test.go +++ b/server-go/internal/adapters/device_seen_test.go @@ -161,3 +161,20 @@ func TestDeletedClientTombstone(t *testing.T) { t.Errorf("tras quitar la lápida la MAC se da de alta (%d filas)", n) } } + +// #1292: la lápida filtra la LISTA FINAL - una estación stale del AP puede +// re-emitir la MAC sin que pase por device_seen. +func TestFilterTombstonedFinalList(t *testing.T) { + d := openLiveTestDB(t) + l := &Live{db: d} + if err := d.AddDeletedClient("12:77:BC:5A:5A:FA"); err != nil { + t.Fatal(err) + } + devs := l.filterTombstoned([]Device{ + {MAC: "12:77:BC:5A:5A:FA", Online: true}, + {MAC: "AA:BB:CC:DD:EE:FF", Online: true}, + }) + if len(devs) != 1 || devs[0].MAC != "AA:BB:CC:DD:EE:FF" { + t.Errorf("filterTombstoned = %+v", devs) + } +} diff --git a/server-go/internal/adapters/live.go b/server-go/internal/adapters/live.go index eadc8d5f..55972b57 100644 --- a/server-go/internal/adapters/live.go +++ b/server-go/internal/adapters/live.go @@ -3178,6 +3178,9 @@ func (l *Live) buildOverview(ctx context.Context) (*Overview, error) { l.mu.Unlock() } } + // #1292: lápidas filtradas de la lista final (una estación stale del AP + // re-emite la MAC en cada ciclo sin pasar por device_seen). + devices = l.filterTombstoned(devices) l.trackUnknownDevices(devices, distNodes) l.trackDevicePresence(devices, time.Now().UnixMilli()) // #954: first/last seen persistente de TODOS los clientes online. @@ -3707,6 +3710,9 @@ func (l *Live) attributedDevices() []Device { overlayStickyFdb(polled, detailMemo, detailNowMs), l.buildDevices(polled), ) + // #1292: lápidas fuera de la lista del detalle (la fuente viva puede + // seguir emitiendo la MAC borrada). + devices = l.filterTombstoned(devices) // #561: sellado de infraestructura con el inventario PVE (si configurado). // El detalle no consume distnodes, pero el sellado de devices sí corre. l.sealProxmoxInfra(devices, nil)