From 3f235632fbdaf93a85171e4bef2b5e84b84c31d3 Mon Sep 17 00:00:00 2001 From: gnacho Date: Wed, 14 Oct 2026 00:18:17 +0200 Subject: [PATCH] feat(alerts): alert when a new client is seen for the first time (#1323) The only client-join alert was 'unknown device', which fires only for nameless clients after a few poll ticks (#234). A device that resolves a hostname on the first tick (DHCP reservation, static name) was never unknown, so a named-but-new client joined silently - no log, no push. - noteDevicesSeen now returns the first-timers (MACs inserted into device_seen for the first time) that already carry a name; nameless ones stay with unknown-device so a new client never alerts twice. - Startup anti-avalanche: when the table was empty on entry (first boot or fresh DB), the whole online set is seeded silently; later additions alert normally. - trackNewClients emits a clients-category info alert, urgent so it also pushes, one-shot per MAC persisted in kv (new_client_alerted:) with the same semantics as unknown_alerted (#248). A MAC that already alerted as unknown does not re-alert here when it later gains a name, and tombstoned clients (#1278/#1290) never re-alert. - Carries name/mac/ip/router/attachment; i18n es/en. Tests: named first-timer alerts once with full vars, nameless stays with unknown-device, empty-table seeding is silent but later joins alert, unknown-alerted guard. Closes #1323 --- app/public/locales/en/translation.json | 5 + app/public/locales/es/translation.json | 5 + server-go/internal/adapters/device_seen.go | 107 +++++++++++++++- server-go/internal/adapters/live.go | 5 +- server-go/internal/adapters/newclient_test.go | 114 ++++++++++++++++++ server-go/internal/alerts/alerts.go | 2 + 6 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 server-go/internal/adapters/newclient_test.go diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index b2ff2f81..35c5e045 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -91,6 +91,7 @@ "high-temperature": "Improve ventilation and keep the router away from heat sources.", "port-flapping": "Check the cable or switch port: an unstable link is usually the culprit.", "unknown-device": "If you do not recognize it, block it or mark it as trusted in Settings.", + "new-client": "A device just joined the network for the first time. If you recognize it, name it or mark it as trusted in Settings; if not, check what it is.", "wifi-ssid-impostor": "An unknown radio is broadcasting your network name. Check the area around the hearing AP; if you do not recognize the BSSID, treat it as a rogue AP.", "new-neighbor-ssid": "A new network name has been broadcasting nearby for hours. If it is not yours, find out what it is (a new router, an IoT device in pairing mode, a hotspot); if it is a permanent neighbor you do not care about, dismiss the alert - it will not repeat.", "uplink-switched": "Check the line that dropped: the router goes back to it on its own once it recovers.", @@ -223,6 +224,10 @@ "description": "{{mac}} connected to {{router}}", "descriptionWhere": "{{mac}} · {{ip}} connected to {{where}}" }, + "new-client": { + "title": "New client: {{name}}", + "description": "{{name}} ({{mac}}) connected for the first time to {{where}}" + }, "wifi-ssid-impostor": { "title": "SSID impostor detected", "description": "{{bssid}} advertises {{ssid}} (heard by {{router}}, {{signal}} dBm)" diff --git a/app/public/locales/es/translation.json b/app/public/locales/es/translation.json index b23aa2d3..55266ad4 100644 --- a/app/public/locales/es/translation.json +++ b/app/public/locales/es/translation.json @@ -91,6 +91,7 @@ "high-temperature": "Mejora la ventilación y aleja el router de fuentes de calor.", "port-flapping": "Revisa el cable o el puerto del switch: un enlace inestable suele ser el culpable.", "unknown-device": "Si no lo reconoces, bloquéalo o márcalo como de confianza en Ajustes.", + "new-client": "Un dispositivo se ha conectado a la red por primera vez. Si lo reconoces, ponle nombre o márcalo como de confianza en Ajustes; si no, averigua qué es.", "wifi-ssid-impostor": "Una radio desconocida está emitiendo el nombre de tu red. Mira qué hay cerca del AP que la oyó; si no reconoces el BSSID, trátala como un AP malicioso.", "new-neighbor-ssid": "Un nombre de red nuevo lleva horas emitiendo cerca. Si no es tuyo, averigua qué es (un router nuevo, un IoT en modo pairing, un hotspot); si es un vecino fijo que no te importa, descarta la alerta - no se repetirá.", "uplink-switched": "Revisa la línea que se ha caído: el router volverá a ella solo cuando se recupere.", @@ -223,6 +224,10 @@ "description": "{{mac}} se ha conectado a {{router}}", "descriptionWhere": "{{mac}} · {{ip}} se ha conectado a {{where}}" }, + "new-client": { + "title": "Cliente nuevo: {{name}}", + "description": "{{name}} ({{mac}}) se ha conectado por primera vez a {{where}}" + }, "wifi-ssid-impostor": { "title": "SSID impostor detectado", "description": "{{bssid}} anuncia {{ssid}} (oído por {{router}}, {{signal}} dBm)" diff --git a/server-go/internal/adapters/device_seen.go b/server-go/internal/adapters/device_seen.go index 4c0a9db1..87883fd0 100644 --- a/server-go/internal/adapters/device_seen.go +++ b/server-go/internal/adapters/device_seen.go @@ -1,8 +1,11 @@ package adapters import ( + "fmt" "net" "strings" + + "github.com/gnacho/netpulse/server-go/internal/alerts" ) // device_seen.go — seguimiento first/last seen de clientes (#954). @@ -158,18 +161,39 @@ func (l *Live) purgeFleetSeen() { } } -func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) { +// noteDevicesSeen actualiza el registro (#954) y devuelve los clientes que +// entran en él por PRIMERA vez y YA tienen nombre (#1323): son el hueco que +// "unknown-device" no cubre (un cliente nuevo con hostname/reserva DHCP +// resuelve nombre en el primer tick y nunca es "desconocido"). +// +// Anti-avalancha: si la tabla estaba VACÍA al entrar (primer arranque del +// server o BD nueva), todo lo online es "la red existente" - se siembra el +// registro en silencio y no se devuelve nada. Con tabla poblada (reinicio +// normal), las MACs nuevas sí se devuelven para alertar. +func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) []Device { if l.db == nil { - return + return nil } l.purgeFleetSeen() tomb, err := l.db.DeletedClients() if err != nil { tomb = map[string]bool{} } + // MACs ya registradas: la diferencia serán las verdaderamente nuevas. + existing := map[string]bool{} + if rows, err := l.db.Query("SELECT mac FROM device_seen"); err == nil { + for rows.Next() { + var m string + if rows.Scan(&m) == nil { + existing[m] = true + } + } + rows.Close() + } + tableWasEmpty := len(existing) == 0 tx, err := l.db.DB.Begin() if err != nil { - return + return nil } defer tx.Rollback() //nolint:errcheck // no-op tras Commit stmt, err := tx.Prepare(`INSERT INTO device_seen (mac, first_seen, last_seen, name, ip, device_type) VALUES (?, ?, ?, ?, ?, ?) @@ -178,9 +202,10 @@ func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) { ip = CASE WHEN excluded.ip != '' THEN excluded.ip ELSE device_seen.ip END, device_type = CASE WHEN excluded.device_type != '' THEN excluded.device_type ELSE device_seen.device_type END`) if err != nil { - return + return nil } defer stmt.Close() + var firstTimers []Device for _, d := range devices { if !d.Online { continue @@ -190,7 +215,7 @@ func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) { continue } if tomb[mac] { - continue // #1278: borrado a mano, no re-alta + continue // #1278: borrado a mano, no re-alta (ni re-aviso, #1290) } name := d.Hostname if isMACLike(name) { @@ -200,10 +225,80 @@ func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) { name = d.Name } if _, err := stmt.Exec(mac, nowMs, nowMs, name, saneIP(d.IP), inferredDeviceType(d)); err != nil { - return + return nil + } + // #1323: primera vez CON nombre (los sin nombre los cubre + // unknown-device con su gracia; alertar ambos sería duplicar ruido). + if !existing[mac] && name != "" { + firstTimers = append(firstTimers, d) } } _ = tx.Commit() + if tableWasEmpty { + return nil // siembra silenciosa del primer arranque + } + return firstTimers +} + +// trackNewClients emite "cliente nuevo visto por primera vez" (#1323) para +// las MACs que acaban de entrar en el registro CON nombre. One-shot por MAC +// (kv new_client_alerted:, misma persistencia que unknown_alerted de #248); +// una MAC que ya alertó como desconocida no re-alertar aquí si luego +// resuelve nombre. Toma l.mu internamente. +func (l *Live) trackNewClients(firstTimers []Device, devices []Device, dists []DistributionNode) { + if len(firstTimers) == 0 || l.db == nil || l.engine == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + alerted := map[string]bool{} + if rows, err := l.db.Query(`SELECT key FROM kv WHERE key LIKE 'new_client_alerted:%' OR key LIKE 'unknown_alerted:%'`); err == nil { + for rows.Next() { + var k string + if rows.Scan(&k) == nil { + alerted[k] = true + } + } + rows.Close() + } + for _, d := range firstTimers { + mac := normSeenMAC(d.MAC) + if alerted["new_client_alerted:"+mac] || alerted["unknown_alerted:"+mac] { + continue + } + name := d.Name + if d.Hostname != "" { + name = d.Hostname + } + where, port := l.deviceLocation(d, devices, dists) + vars := map[string]string{ + "mac": mac, "name": name, + "router": l.routerDisplayName(d.RouterID), + "where": where, + } + if d.IP != "" { + vars["ip"] = d.IP + } + if port != "" { + vars["port"] = port + } + desc := fmt.Sprintf("%s (%s) connected for the first time to %s", name, mac, where) + if d.IP != "" { + desc = fmt.Sprintf("%s (%s · %s) connected for the first time to %s", name, mac, d.IP, where) + } + l.engine.Emit(AlertEvent{ + ID: "alert-new-client-" + mac, + Category: alerts.CatClients, Urgent: true, + Severity: "info", + Title: "New client: " + name, + Description: desc, + Hint: alerts.HintFor(alerts.HintNewClient), + Type: alerts.HintNewClient, + Vars: vars, + Time: "just now", RouterID: d.RouterID, + }) + _, _ = l.db.Exec("INSERT INTO kv (key, value) VALUES (?, '1') ON CONFLICT(key) DO NOTHING", "new_client_alerted:"+mac) + } } // applyDeviceSeen rellena FirstSeenMs/LastSeenMs de cada dispositivo desde diff --git a/server-go/internal/adapters/live.go b/server-go/internal/adapters/live.go index c6b558c4..76f64656 100644 --- a/server-go/internal/adapters/live.go +++ b/server-go/internal/adapters/live.go @@ -3360,7 +3360,10 @@ func (l *Live) buildOverview(ctx context.Context) (*Overview, error) { l.trackOfflineDevices(devices) l.trackDevicePresence(devices, time.Now().UnixMilli()) // #954: first/last seen persistente de TODOS los clientes online. - l.noteDevicesSeen(devices, time.Now().UnixMilli()) + // #1323: los que entran al registro por primera vez CON nombre alertan + // ("new client"); los sin nombre los cubre unknown-device (#234). + firstTimers := l.noteDevicesSeen(devices, time.Now().UnixMilli()) + l.trackNewClients(firstTimers, devices, distNodes) // #1095: el overview (snapshot SSE del que vive la tabla de clientes) // escribía las marcas pero NUNCA las aplicaba: la UI en vivo mostraba // "—" aunque la API REST las tuviera (sólo los detalles las aplicaban). diff --git a/server-go/internal/adapters/newclient_test.go b/server-go/internal/adapters/newclient_test.go new file mode 100644 index 00000000..9e86fa3c --- /dev/null +++ b/server-go/internal/adapters/newclient_test.go @@ -0,0 +1,114 @@ +package adapters + +// newclient_test.go — #1323: alerta "new client" cuando una MAC entra en +// device_seen por primera vez CON nombre (el hueco de unknown-device, que +// solo cubre sin-nombre con gracia). + +import ( + "testing" + + "github.com/gnacho/netpulse/server-go/internal/alerts" +) + +func newClientLive(t *testing.T) *Live { + t.Helper() + d := openLiveTestDB(t) + l := NewLive(nil, d, []RouterConfig{{ID: "gw", Name: "Gateway"}}, nil) + return l +} + +func newClientAlerts(l *Live) []AlertEvent { + var out []AlertEvent + for _, a := range l.engine.List() { + if a.Type == alerts.HintNewClient { + out = append(out, a) + } + } + return out +} + +// El caso del issue: cliente nuevo CON nombre (reserva DHCP) alerta una vez. +func TestNewClientNamedAlertsOnce(t *testing.T) { + l := newClientLive(t) + // Registro pre-poblado (no es el primer arranque): siembra manual. + l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000) + + devs := []Device{ + {MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}, + {MAC: "5A:3B:41:63:08:FE", Online: true, Hostname: "portatil-nuevo", IP: "192.168.1.150", RouterID: "gw"}, + } + first := l.noteDevicesSeen(devs, 2000) + if len(first) != 1 || first[0].MAC != "5A:3B:41:63:08:FE" { + t.Fatalf("firstTimers: %+v", first) + } + l.trackNewClients(first, devs, nil) + got := newClientAlerts(l) + if len(got) != 1 { + t.Fatalf("una alerta new-client; got %d", len(got)) + } + a := got[0] + if !a.Urgent || a.Severity != "info" || a.Category != alerts.CatClients { + t.Fatalf("urgent+info+clients: %+v", a) + } + if a.Vars["name"] != "portatil-nuevo" || a.Vars["ip"] != "192.168.1.150" || a.Vars["router"] != "Gateway" { + t.Fatalf("vars: %+v", a.Vars) + } + // One-shot: el mismo MAC en el siguiente ciclo no vuelve a alertar. + first2 := l.noteDevicesSeen(devs, 3000) + if len(first2) != 0 { + t.Fatalf("ya registrado no es first-timer: %+v", first2) + } +} + +// Sin nombre: lo cubre unknown-device (#234), no new-client (sin duplicar). +func TestNewClientNamelessDoesNotAlert(t *testing.T) { + l := newClientLive(t) + l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000) + devs := []Device{{MAC: "bb:bb:bb:00:00:02", Online: true, RouterID: "gw"}} // Name=MAC efectivo + first := l.noteDevicesSeen(devs, 2000) + if len(first) != 0 { + t.Fatalf("sin nombre no es new-client (lo cubre unknown): %+v", first) + } + l.trackNewClients(first, devs, nil) + if got := newClientAlerts(l); len(got) != 0 { + t.Fatalf("sin nombre no alerta aquí: %+v", got) + } +} + +// Anti-avalancha: tabla vacía al entrar (primer arranque / BD nueva) = +// siembra silenciosa; los presentes no son "nuevos". +func TestNewClientStartupSeedingIsSilent(t *testing.T) { + l := newClientLive(t) + devs := []Device{ + {MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "uno", RouterID: "gw"}, + {MAC: "aa:aa:aa:00:00:02", Online: true, Hostname: "dos", RouterID: "gw"}, + } + first := l.noteDevicesSeen(devs, 1000) + if len(first) != 0 { + t.Fatalf("primer arranque no alerta: %+v", first) + } + l.trackNewClients(first, devs, nil) + if got := newClientAlerts(l); len(got) != 0 { + t.Fatalf("siembra silenciosa: %+v", got) + } + // Pero un alta POSTERIOR a la siembra sí alerta. + devs2 := append(devs, Device{MAC: "aa:aa:aa:00:00:03", Online: true, Hostname: "tres", RouterID: "gw"}) + first2 := l.noteDevicesSeen(devs2, 2000) + if len(first2) != 1 { + t.Fatalf("tras la siembra, el nuevo sí se detecta: %+v", first2) + } +} + +// Una MAC que ya alertó como desconocida no re-alert como new-client si +// luego resuelve nombre; y la tombstone (#1278/#1290) nunca re-alert. +func TestNewClientUnknownGuard(t *testing.T) { + l := newClientLive(t) + l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000) + _, _ = l.db.Exec("INSERT INTO kv (key, value) VALUES ('unknown_alerted:CC:CC:CC:00:00:04', '1')") + devs := []Device{{MAC: "cc:cc:cc:00:00:04", Online: true, Hostname: "antes-desconocido", RouterID: "gw"}} + first := l.noteDevicesSeen(devs, 2000) + l.trackNewClients(first, devs, nil) + if got := newClientAlerts(l); len(got) != 0 { + t.Fatalf("ya alertó como desconocido: %+v", got) + } +} diff --git a/server-go/internal/alerts/alerts.go b/server-go/internal/alerts/alerts.go index 7bfebc79..ae019394 100644 --- a/server-go/internal/alerts/alerts.go +++ b/server-go/internal/alerts/alerts.go @@ -106,6 +106,7 @@ const ( HintUnknownDevice = "unknown-device" HintSsidImpostor = "wifi-ssid-impostor" HintNewNeighborSsid = "new-neighbor-ssid" + HintNewClient = "new-client" HintFirmware = "firmware-outdated" HintWanDown = "wan-down" HintWifiWeak = "wifi-weak" @@ -163,6 +164,7 @@ var Hints = map[string]string{ HintUnknownDevice: "If you do not recognize it, block it or mark it as trusted in Settings.", HintSsidImpostor: "An unknown radio is broadcasting your network name. Check the area around the hearing AP; if you do not recognize the BSSID, treat it as a rogue AP.", HintNewNeighborSsid: "A new network name has been broadcasting nearby for hours. If it is not yours, find out what it is (a new router, an IoT device in pairing mode, a hotspot); if it is a permanent neighbor you do not care about, dismiss the alert - it will not repeat.", + HintNewClient: "A device just joined the network for the first time. If you recognize it, name it or mark it as trusted in Settings; if not, check what it is.", HintFirmware: "Your router has newer firmware available; check Firmware upgrades (Labs) or LuCI.", HintWanDown: "Check the modem/ONT and contact your ISP if the outage persists.", HintWifiWeak: "Move the device closer to an access point or relocate the AP.",