Skip to content

Commit 3f23563

Browse files
committed
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
1 parent d30c682 commit 3f23563

6 files changed

Lines changed: 231 additions & 7 deletions

File tree

‎app/public/locales/en/translation.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"high-temperature": "Improve ventilation and keep the router away from heat sources.",
9292
"port-flapping": "Check the cable or switch port: an unstable link is usually the culprit.",
9393
"unknown-device": "If you do not recognize it, block it or mark it as trusted in Settings.",
94+
"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.",
9495
"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.",
9596
"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.",
9697
"uplink-switched": "Check the line that dropped: the router goes back to it on its own once it recovers.",
@@ -223,6 +224,10 @@
223224
"description": "{{mac}} connected to {{router}}",
224225
"descriptionWhere": "{{mac}} · {{ip}} connected to {{where}}"
225226
},
227+
"new-client": {
228+
"title": "New client: {{name}}",
229+
"description": "{{name}} ({{mac}}) connected for the first time to {{where}}"
230+
},
226231
"wifi-ssid-impostor": {
227232
"title": "SSID impostor detected",
228233
"description": "{{bssid}} advertises {{ssid}} (heard by {{router}}, {{signal}} dBm)"

‎app/public/locales/es/translation.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"high-temperature": "Mejora la ventilación y aleja el router de fuentes de calor.",
9292
"port-flapping": "Revisa el cable o el puerto del switch: un enlace inestable suele ser el culpable.",
9393
"unknown-device": "Si no lo reconoces, bloquéalo o márcalo como de confianza en Ajustes.",
94+
"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.",
9495
"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.",
9596
"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á.",
9697
"uplink-switched": "Revisa la línea que se ha caído: el router volverá a ella solo cuando se recupere.",
@@ -223,6 +224,10 @@
223224
"description": "{{mac}} se ha conectado a {{router}}",
224225
"descriptionWhere": "{{mac}} · {{ip}} se ha conectado a {{where}}"
225226
},
227+
"new-client": {
228+
"title": "Cliente nuevo: {{name}}",
229+
"description": "{{name}} ({{mac}}) se ha conectado por primera vez a {{where}}"
230+
},
226231
"wifi-ssid-impostor": {
227232
"title": "SSID impostor detectado",
228233
"description": "{{bssid}} anuncia {{ssid}} (oído por {{router}}, {{signal}} dBm)"

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

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package adapters
22

33
import (
4+
"fmt"
45
"net"
56
"strings"
7+
8+
"github.com/gnacho/netpulse/server-go/internal/alerts"
69
)
710

811
// device_seen.go — seguimiento first/last seen de clientes (#954).
@@ -158,18 +161,39 @@ func (l *Live) purgeFleetSeen() {
158161
}
159162
}
160163

161-
func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) {
164+
// noteDevicesSeen actualiza el registro (#954) y devuelve los clientes que
165+
// entran en él por PRIMERA vez y YA tienen nombre (#1323): son el hueco que
166+
// "unknown-device" no cubre (un cliente nuevo con hostname/reserva DHCP
167+
// resuelve nombre en el primer tick y nunca es "desconocido").
168+
//
169+
// Anti-avalancha: si la tabla estaba VACÍA al entrar (primer arranque del
170+
// server o BD nueva), todo lo online es "la red existente" - se siembra el
171+
// registro en silencio y no se devuelve nada. Con tabla poblada (reinicio
172+
// normal), las MACs nuevas sí se devuelven para alertar.
173+
func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) []Device {
162174
if l.db == nil {
163-
return
175+
return nil
164176
}
165177
l.purgeFleetSeen()
166178
tomb, err := l.db.DeletedClients()
167179
if err != nil {
168180
tomb = map[string]bool{}
169181
}
182+
// MACs ya registradas: la diferencia serán las verdaderamente nuevas.
183+
existing := map[string]bool{}
184+
if rows, err := l.db.Query("SELECT mac FROM device_seen"); err == nil {
185+
for rows.Next() {
186+
var m string
187+
if rows.Scan(&m) == nil {
188+
existing[m] = true
189+
}
190+
}
191+
rows.Close()
192+
}
193+
tableWasEmpty := len(existing) == 0
170194
tx, err := l.db.DB.Begin()
171195
if err != nil {
172-
return
196+
return nil
173197
}
174198
defer tx.Rollback() //nolint:errcheck // no-op tras Commit
175199
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) {
178202
ip = CASE WHEN excluded.ip != '' THEN excluded.ip ELSE device_seen.ip END,
179203
device_type = CASE WHEN excluded.device_type != '' THEN excluded.device_type ELSE device_seen.device_type END`)
180204
if err != nil {
181-
return
205+
return nil
182206
}
183207
defer stmt.Close()
208+
var firstTimers []Device
184209
for _, d := range devices {
185210
if !d.Online {
186211
continue
@@ -190,7 +215,7 @@ func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) {
190215
continue
191216
}
192217
if tomb[mac] {
193-
continue // #1278: borrado a mano, no re-alta
218+
continue // #1278: borrado a mano, no re-alta (ni re-aviso, #1290)
194219
}
195220
name := d.Hostname
196221
if isMACLike(name) {
@@ -200,10 +225,80 @@ func (l *Live) noteDevicesSeen(devices []Device, nowMs int64) {
200225
name = d.Name
201226
}
202227
if _, err := stmt.Exec(mac, nowMs, nowMs, name, saneIP(d.IP), inferredDeviceType(d)); err != nil {
203-
return
228+
return nil
229+
}
230+
// #1323: primera vez CON nombre (los sin nombre los cubre
231+
// unknown-device con su gracia; alertar ambos sería duplicar ruido).
232+
if !existing[mac] && name != "" {
233+
firstTimers = append(firstTimers, d)
204234
}
205235
}
206236
_ = tx.Commit()
237+
if tableWasEmpty {
238+
return nil // siembra silenciosa del primer arranque
239+
}
240+
return firstTimers
241+
}
242+
243+
// trackNewClients emite "cliente nuevo visto por primera vez" (#1323) para
244+
// las MACs que acaban de entrar en el registro CON nombre. One-shot por MAC
245+
// (kv new_client_alerted:, misma persistencia que unknown_alerted de #248);
246+
// una MAC que ya alertó como desconocida no re-alertar aquí si luego
247+
// resuelve nombre. Toma l.mu internamente.
248+
func (l *Live) trackNewClients(firstTimers []Device, devices []Device, dists []DistributionNode) {
249+
if len(firstTimers) == 0 || l.db == nil || l.engine == nil {
250+
return
251+
}
252+
l.mu.Lock()
253+
defer l.mu.Unlock()
254+
alerted := map[string]bool{}
255+
if rows, err := l.db.Query(`SELECT key FROM kv WHERE key LIKE 'new_client_alerted:%' OR key LIKE 'unknown_alerted:%'`); err == nil {
256+
for rows.Next() {
257+
var k string
258+
if rows.Scan(&k) == nil {
259+
alerted[k] = true
260+
}
261+
}
262+
rows.Close()
263+
}
264+
for _, d := range firstTimers {
265+
mac := normSeenMAC(d.MAC)
266+
if alerted["new_client_alerted:"+mac] || alerted["unknown_alerted:"+mac] {
267+
continue
268+
}
269+
name := d.Name
270+
if d.Hostname != "" {
271+
name = d.Hostname
272+
}
273+
where, port := l.deviceLocation(d, devices, dists)
274+
vars := map[string]string{
275+
"mac": mac, "name": name,
276+
"router": l.routerDisplayName(d.RouterID),
277+
"where": where,
278+
}
279+
if d.IP != "" {
280+
vars["ip"] = d.IP
281+
}
282+
if port != "" {
283+
vars["port"] = port
284+
}
285+
desc := fmt.Sprintf("%s (%s) connected for the first time to %s", name, mac, where)
286+
if d.IP != "" {
287+
desc = fmt.Sprintf("%s (%s · %s) connected for the first time to %s", name, mac, d.IP, where)
288+
}
289+
l.engine.Emit(AlertEvent{
290+
ID: "alert-new-client-" + mac,
291+
Category: alerts.CatClients, Urgent: true,
292+
Severity: "info",
293+
Title: "New client: " + name,
294+
Description: desc,
295+
Hint: alerts.HintFor(alerts.HintNewClient),
296+
Type: alerts.HintNewClient,
297+
Vars: vars,
298+
Time: "just now", RouterID: d.RouterID,
299+
})
300+
_, _ = l.db.Exec("INSERT INTO kv (key, value) VALUES (?, '1') ON CONFLICT(key) DO NOTHING", "new_client_alerted:"+mac)
301+
}
207302
}
208303

209304
// applyDeviceSeen rellena FirstSeenMs/LastSeenMs de cada dispositivo desde

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3360,7 +3360,10 @@ func (l *Live) buildOverview(ctx context.Context) (*Overview, error) {
33603360
l.trackOfflineDevices(devices)
33613361
l.trackDevicePresence(devices, time.Now().UnixMilli())
33623362
// #954: first/last seen persistente de TODOS los clientes online.
3363-
l.noteDevicesSeen(devices, time.Now().UnixMilli())
3363+
// #1323: los que entran al registro por primera vez CON nombre alertan
3364+
// ("new client"); los sin nombre los cubre unknown-device (#234).
3365+
firstTimers := l.noteDevicesSeen(devices, time.Now().UnixMilli())
3366+
l.trackNewClients(firstTimers, devices, distNodes)
33643367
// #1095: el overview (snapshot SSE del que vive la tabla de clientes)
33653368
// escribía las marcas pero NUNCA las aplicaba: la UI en vivo mostraba
33663369
// "—" aunque la API REST las tuviera (sólo los detalles las aplicaban).
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package adapters
2+
3+
// newclient_test.go — #1323: alerta "new client" cuando una MAC entra en
4+
// device_seen por primera vez CON nombre (el hueco de unknown-device, que
5+
// solo cubre sin-nombre con gracia).
6+
7+
import (
8+
"testing"
9+
10+
"github.com/gnacho/netpulse/server-go/internal/alerts"
11+
)
12+
13+
func newClientLive(t *testing.T) *Live {
14+
t.Helper()
15+
d := openLiveTestDB(t)
16+
l := NewLive(nil, d, []RouterConfig{{ID: "gw", Name: "Gateway"}}, nil)
17+
return l
18+
}
19+
20+
func newClientAlerts(l *Live) []AlertEvent {
21+
var out []AlertEvent
22+
for _, a := range l.engine.List() {
23+
if a.Type == alerts.HintNewClient {
24+
out = append(out, a)
25+
}
26+
}
27+
return out
28+
}
29+
30+
// El caso del issue: cliente nuevo CON nombre (reserva DHCP) alerta una vez.
31+
func TestNewClientNamedAlertsOnce(t *testing.T) {
32+
l := newClientLive(t)
33+
// Registro pre-poblado (no es el primer arranque): siembra manual.
34+
l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000)
35+
36+
devs := []Device{
37+
{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"},
38+
{MAC: "5A:3B:41:63:08:FE", Online: true, Hostname: "portatil-nuevo", IP: "192.168.1.150", RouterID: "gw"},
39+
}
40+
first := l.noteDevicesSeen(devs, 2000)
41+
if len(first) != 1 || first[0].MAC != "5A:3B:41:63:08:FE" {
42+
t.Fatalf("firstTimers: %+v", first)
43+
}
44+
l.trackNewClients(first, devs, nil)
45+
got := newClientAlerts(l)
46+
if len(got) != 1 {
47+
t.Fatalf("una alerta new-client; got %d", len(got))
48+
}
49+
a := got[0]
50+
if !a.Urgent || a.Severity != "info" || a.Category != alerts.CatClients {
51+
t.Fatalf("urgent+info+clients: %+v", a)
52+
}
53+
if a.Vars["name"] != "portatil-nuevo" || a.Vars["ip"] != "192.168.1.150" || a.Vars["router"] != "Gateway" {
54+
t.Fatalf("vars: %+v", a.Vars)
55+
}
56+
// One-shot: el mismo MAC en el siguiente ciclo no vuelve a alertar.
57+
first2 := l.noteDevicesSeen(devs, 3000)
58+
if len(first2) != 0 {
59+
t.Fatalf("ya registrado no es first-timer: %+v", first2)
60+
}
61+
}
62+
63+
// Sin nombre: lo cubre unknown-device (#234), no new-client (sin duplicar).
64+
func TestNewClientNamelessDoesNotAlert(t *testing.T) {
65+
l := newClientLive(t)
66+
l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000)
67+
devs := []Device{{MAC: "bb:bb:bb:00:00:02", Online: true, RouterID: "gw"}} // Name=MAC efectivo
68+
first := l.noteDevicesSeen(devs, 2000)
69+
if len(first) != 0 {
70+
t.Fatalf("sin nombre no es new-client (lo cubre unknown): %+v", first)
71+
}
72+
l.trackNewClients(first, devs, nil)
73+
if got := newClientAlerts(l); len(got) != 0 {
74+
t.Fatalf("sin nombre no alerta aquí: %+v", got)
75+
}
76+
}
77+
78+
// Anti-avalancha: tabla vacía al entrar (primer arranque / BD nueva) =
79+
// siembra silenciosa; los presentes no son "nuevos".
80+
func TestNewClientStartupSeedingIsSilent(t *testing.T) {
81+
l := newClientLive(t)
82+
devs := []Device{
83+
{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "uno", RouterID: "gw"},
84+
{MAC: "aa:aa:aa:00:00:02", Online: true, Hostname: "dos", RouterID: "gw"},
85+
}
86+
first := l.noteDevicesSeen(devs, 1000)
87+
if len(first) != 0 {
88+
t.Fatalf("primer arranque no alerta: %+v", first)
89+
}
90+
l.trackNewClients(first, devs, nil)
91+
if got := newClientAlerts(l); len(got) != 0 {
92+
t.Fatalf("siembra silenciosa: %+v", got)
93+
}
94+
// Pero un alta POSTERIOR a la siembra sí alerta.
95+
devs2 := append(devs, Device{MAC: "aa:aa:aa:00:00:03", Online: true, Hostname: "tres", RouterID: "gw"})
96+
first2 := l.noteDevicesSeen(devs2, 2000)
97+
if len(first2) != 1 {
98+
t.Fatalf("tras la siembra, el nuevo sí se detecta: %+v", first2)
99+
}
100+
}
101+
102+
// Una MAC que ya alertó como desconocida no re-alert como new-client si
103+
// luego resuelve nombre; y la tombstone (#1278/#1290) nunca re-alert.
104+
func TestNewClientUnknownGuard(t *testing.T) {
105+
l := newClientLive(t)
106+
l.noteDevicesSeen([]Device{{MAC: "aa:aa:aa:00:00:01", Online: true, Hostname: "viejo"}}, 1000)
107+
_, _ = l.db.Exec("INSERT INTO kv (key, value) VALUES ('unknown_alerted:CC:CC:CC:00:00:04', '1')")
108+
devs := []Device{{MAC: "cc:cc:cc:00:00:04", Online: true, Hostname: "antes-desconocido", RouterID: "gw"}}
109+
first := l.noteDevicesSeen(devs, 2000)
110+
l.trackNewClients(first, devs, nil)
111+
if got := newClientAlerts(l); len(got) != 0 {
112+
t.Fatalf("ya alertó como desconocido: %+v", got)
113+
}
114+
}

‎server-go/internal/alerts/alerts.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const (
106106
HintUnknownDevice = "unknown-device"
107107
HintSsidImpostor = "wifi-ssid-impostor"
108108
HintNewNeighborSsid = "new-neighbor-ssid"
109+
HintNewClient = "new-client"
109110
HintFirmware = "firmware-outdated"
110111
HintWanDown = "wan-down"
111112
HintWifiWeak = "wifi-weak"
@@ -163,6 +164,7 @@ var Hints = map[string]string{
163164
HintUnknownDevice: "If you do not recognize it, block it or mark it as trusted in Settings.",
164165
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.",
165166
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.",
167+
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.",
166168
HintFirmware: "Your router has newer firmware available; check Firmware upgrades (Labs) or LuCI.",
167169
HintWanDown: "Check the modem/ONT and contact your ISP if the outage persists.",
168170
HintWifiWeak: "Move the device closer to an access point or relocate the AP.",

0 commit comments

Comments
 (0)