Skip to content

Commit 2f94b6b

Browse files
committed
feat(alerts): new neighbor SSID alert with dwell (#1359)
A neighbor SSID never seen before that STICKS AROUND is worth a look (a new router plugged in, an IoT in pairing mode, a fixed hotspot), while a drive-by hotspot should stay quiet. - EvalNewNeighborSsids runs at the same ingest hook as the impostor check: SSIDs active in the 48h retention window, excluding the fleet (auto-approved via LocalSsids) and hidden (empty) ones. - wifi_scans only retains 48h, so 'never seen' cannot come from the table: known SSIDs persist in kv (ssid_known:), alerted ones in ssid_alerted: - one-shot survives restarts and pruning. - Dwell: alerts only when the SSID has been around >=4h and is still active (<=2h since last sighting). Transients stay silent. - Anti-stampede seeding: the first evaluation marks every currently active SSID as known without alerting, so pre-existing neighbors never fire. Seeding runs even with zero active SSIDs (a first-run with no scans must not swallow the first real new SSID). - Info severity, not urgent, clients category (signal defaults to LevelNone); i18n es/en. Tests: seeding does not alert, transient stays quiet, dwell alerts exactly once (kv persisted), fleet/hidden SSIDs ignored. Closes #1359
1 parent 46037ca commit 2f94b6b

6 files changed

Lines changed: 280 additions & 0 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"port-flapping": "Check the cable or switch port: an unstable link is usually the culprit.",
9090
"unknown-device": "If you do not recognize it, block it or mark it as trusted in Settings.",
9191
"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.",
92+
"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.",
9293
"uplink-switched": "Check the line that dropped: the router goes back to it on its own once it recovers.",
9394
"wan-down": "Check the modem/ONT and contact your ISP if the outage persists.",
9495
"wan-slow": "Restart the router and the modem/ONT and run the test again; if it stays low, contact your ISP (line degradation or congestion in your area).",
@@ -223,6 +224,10 @@
223224
"title": "SSID impostor detected",
224225
"description": "{{bssid}} advertises {{ssid}} (heard by {{router}}, {{signal}} dBm)"
225226
},
227+
"new-neighbor-ssid": {
228+
"title": "New neighbor SSID",
229+
"description": "{{ssid}} heard for the first time (by {{router}}, {{signal}} dBm, since {{since}})"
230+
},
226231
"uplink-restored": {
227232
"title": "Internet is back on {{to}}",
228233
"description": "{{router}} moved the traffic back to {{to}}"

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"port-flapping": "Revisa el cable o el puerto del switch: un enlace inestable suele ser el culpable.",
9090
"unknown-device": "Si no lo reconoces, bloquéalo o márcalo como de confianza en Ajustes.",
9191
"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.",
92+
"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á.",
9293
"uplink-switched": "Revisa la línea que se ha caído: el router volverá a ella solo cuando se recupere.",
9394
"wan-down": "Comprueba el módem/ONT y contacta con tu operador si la caída persiste.",
9495
"wan-slow": "Reinicia el router y el módem/ONT y repite el test; si sigue bajo, contacta con tu operador (degrada la línea o hay saturación en tu área).",
@@ -223,6 +224,10 @@
223224
"title": "SSID impostor detectado",
224225
"description": "{{bssid}} anuncia {{ssid}} (oído por {{router}}, {{signal}} dBm)"
225226
},
227+
"new-neighbor-ssid": {
228+
"title": "Nuevo SSID vecino",
229+
"description": "{{ssid}} se oye por primera vez ({{router}}, {{signal}} dBm, desde las {{since}})"
230+
},
226231
"uplink-restored": {
227232
"title": "Internet vuelve por {{to}}",
228233
"description": "{{router}} ha devuelto el tráfico a {{to}}"
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package adapters
2+
3+
// neighborssid.go — alerta new-neighbor-ssid (#1359): un SSID vecino que
4+
// nunca se había visto y que SE QUEDA (dwell) merece un aviso info (un
5+
// router nuevo enchufado, un IoT en modo pairing, un hotspot fijo); un
6+
// hotspot de paso no dice nada.
7+
//
8+
// wifi_scans solo retiene 48h (#475), así que "nunca visto" NO se puede
9+
// derivar de la tabla: los SSIDs conocidos se persisten en kv
10+
// (ssid_known:<ssid>) y los ya alertados en ssid_alerted:<ssid> (one-shot
11+
// de verdad, sobrevive a reinicios y a la poda).
12+
//
13+
// Anti-avalancha de arranque: en la primera evaluación de la feature (kv
14+
// ssid_known_seeded ausente) TODOS los SSIDs activos se siembran como
15+
// conocidos sin alertar - solo alertan los verdaderamente nuevos.
16+
//
17+
// La flota está auto-aprobada por definición (LocalSsids): nada que
18+
// configurar para el caso común.
19+
20+
import (
21+
"fmt"
22+
"strings"
23+
"time"
24+
25+
"github.com/gnacho/netpulse/server-go/internal/alerts"
26+
)
27+
28+
const (
29+
// newSsidDwell: el SSID nuevo debe llevar visto al menos este tiempo
30+
// (minTs dentro de la retención) para alertar. Con scans cada 30 min,
31+
// un hotspot de paso nunca llega.
32+
newSsidDwell = 4 * time.Hour
33+
// newSsidActive: el SSID debe seguir activo (visto dentro de esta
34+
// ventana) para que el dwell cuente como cumplido ahora.
35+
newSsidActive = 2 * time.Hour
36+
// newSsidScanWindow: historia consultada = la retención de wifi_scans.
37+
newSsidScanWindow = 48 * time.Hour
38+
)
39+
40+
// EvalNewNeighborSsids: una pasada sobre los SSIDs activos. La llama el
41+
// handler de ingesta tras guardar un scan nuevo, igual que el impostor
42+
// (#1358): es el momento exacto en que la evidencia cambia.
43+
func (l *Live) EvalNewNeighborSsids() {
44+
if l.db == nil || l.agents == nil || l.engine == nil {
45+
return
46+
}
47+
now := time.Now()
48+
49+
fleetSsids := map[string]bool{}
50+
for _, s := range l.agents.LocalSsids() {
51+
fleetSsids[s] = true
52+
}
53+
54+
// SSIDs activos en la retención: primero/último sighting por SSID.
55+
// Sin fleet ni vacíos (redes ocultas no anuncian nombre).
56+
rows, err := l.db.Query(
57+
`SELECT ssid, MIN(ts), MAX(ts) FROM wifi_scans
58+
WHERE ts >= ? AND ssid <> '' GROUP BY ssid`,
59+
now.Add(-newSsidScanWindow).Unix())
60+
if err != nil {
61+
return
62+
}
63+
type ssidSeen struct {
64+
minTs int64
65+
maxTs int64
66+
}
67+
active := map[string]ssidSeen{}
68+
for rows.Next() {
69+
var ssid string
70+
var minTs, maxTs int64
71+
if rows.Scan(&ssid, &minTs, &maxTs) != nil {
72+
continue
73+
}
74+
if fleetSsids[ssid] {
75+
continue
76+
}
77+
active[ssid] = ssidSeen{minTs: minTs, maxTs: maxTs}
78+
}
79+
rows.Close()
80+
81+
// Siembra anti-avalancha (una vez por instalación de la feature). Debe
82+
// correr AUNQUE no haya activos ahora: si no, la siembra se pospondría
83+
// al primer SSID nuevo de verdad y se tragaría su alerta.
84+
var seeded int
85+
_ = l.db.QueryRow(`SELECT COUNT(1) FROM kv WHERE key='ssid_known_seeded'`).Scan(&seeded)
86+
if seeded == 0 {
87+
for ssid := range active {
88+
_, _ = l.db.Exec(`INSERT INTO kv (key, value) VALUES (?, '1') ON CONFLICT(key) DO NOTHING`, "ssid_known:"+ssid)
89+
}
90+
_, _ = l.db.Exec(`INSERT INTO kv (key, value) VALUES ('ssid_known_seeded', '1') ON CONFLICT(key) DO NOTHING`)
91+
return
92+
}
93+
if len(active) == 0 {
94+
return
95+
}
96+
97+
known, alerted := map[string]bool{}, map[string]bool{}
98+
if kvs, err := l.db.Query(`SELECT key FROM kv WHERE key LIKE 'ssid_known:%' OR key LIKE 'ssid_alerted:%'`); err == nil {
99+
for kvs.Next() {
100+
var k string
101+
if kvs.Scan(&k) == nil {
102+
if rest, ok := strings.CutPrefix(k, "ssid_known:"); ok {
103+
known[rest] = true
104+
} else if rest, ok := strings.CutPrefix(k, "ssid_alerted:"); ok {
105+
alerted[rest] = true
106+
}
107+
}
108+
}
109+
kvs.Close()
110+
}
111+
112+
for ssid, seen := range active {
113+
if known[ssid] || alerted[ssid] {
114+
continue
115+
}
116+
first := time.Unix(seen.minTs, 0)
117+
last := time.Unix(seen.maxTs, 0)
118+
if now.Sub(first) < newSsidDwell || last.Before(now.Add(-newSsidActive)) {
119+
continue // aún en dwell o ya desapareció
120+
}
121+
// Router y señal del sighting más reciente (para que el aviso diga
122+
// dónde se oye).
123+
var routerID string
124+
var signal int
125+
_ = l.db.QueryRow(
126+
`SELECT router_id, signal_dbm FROM wifi_scans WHERE ssid = ? ORDER BY ts DESC LIMIT 1`,
127+
ssid).Scan(&routerID, &signal)
128+
l.mu.Lock()
129+
routerName := routerID
130+
if rn := l.routerDisplayName(routerID); rn != "" {
131+
routerName = rn
132+
}
133+
l.mu.Unlock()
134+
alertID := "alert-new-ssid-" + ssid
135+
l.engine.Emit(AlertEvent{
136+
ID: alertID,
137+
Category: alerts.CatClients, // visible por defecto (signal=none)
138+
Urgent: false,
139+
Severity: "info",
140+
Title: "New neighbor SSID",
141+
Description: fmt.Sprintf("%s heard for the first time (by %s, %d dBm, since %s)",
142+
ssid, routerName, signal, first.Format("15:04")),
143+
Hint: alerts.HintFor(alerts.HintNewNeighborSsid),
144+
Type: alerts.HintNewNeighborSsid,
145+
Vars: map[string]string{
146+
"ssid": ssid,
147+
"router": routerName,
148+
"signal": fmt.Sprintf("%d", signal),
149+
"since": first.Format("15:04"),
150+
},
151+
RouterID: routerID,
152+
})
153+
// One-shot: alertado y conocido aunque desaparezca y vuelva.
154+
_, _ = l.db.Exec(`INSERT INTO kv (key, value) VALUES (?, '1') ON CONFLICT(key) DO NOTHING`, "ssid_alerted:"+ssid)
155+
_, _ = l.db.Exec(`INSERT INTO kv (key, value) VALUES (?, '1') ON CONFLICT(key) DO NOTHING`, "ssid_known:"+ssid)
156+
}
157+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package adapters
2+
3+
// neighborssid_test.go — #1359: new-neighbor-ssid con dwell y one-shot.
4+
5+
import (
6+
"testing"
7+
"time"
8+
)
9+
10+
func newSsidAlerts(l *Live) []AlertEvent {
11+
var out []AlertEvent
12+
for _, a := range l.engine.List() {
13+
if a.Type == "new-neighbor-ssid" {
14+
out = append(out, a)
15+
}
16+
}
17+
return out
18+
}
19+
20+
func TestNewSsidSeedingDoesNotAlert(t *testing.T) {
21+
l, _ := newImpostorLive(t) // flota: "temiscira" propio
22+
now := time.Now()
23+
// Vecino de siempre (lleva 10h): en la primera evaluación se siembra,
24+
// no alerta (anti-avalancha de arranque).
25+
insertScan(t, l, "ap1", "11:22:33:44:55:01", "vecino-historico", now.Add(-10*time.Hour))
26+
insertScan(t, l, "ap1", "11:22:33:44:55:01", "vecino-historico", now)
27+
l.EvalNewNeighborSsids()
28+
if got := newSsidAlerts(l); len(got) != 0 {
29+
t.Fatalf("la siembra no debe alertar: %+v", got)
30+
}
31+
var seeded int
32+
_ = l.db.QueryRow(`SELECT COUNT(1) FROM kv WHERE key='ssid_known_seeded'`).Scan(&seeded)
33+
if seeded != 1 {
34+
t.Fatal("kv ssid_known_seeded no escrito")
35+
}
36+
_ = l.db.QueryRow(`SELECT COUNT(1) FROM kv WHERE key='ssid_known:vecino-historico'`).Scan(&seeded)
37+
if seeded != 1 {
38+
t.Fatal("vecino histórico no quedó sembrado como conocido")
39+
}
40+
}
41+
42+
func TestNewSsidTransientStaysQuiet(t *testing.T) {
43+
l, _ := newImpostorLive(t)
44+
now := time.Now()
45+
// Siembra con la flota sola.
46+
l.EvalNewNeighborSsids()
47+
// Hotspot de paso: 2 sightings separados 1h (< dwell 4h), ya no activo.
48+
insertScan(t, l, "ap1", "11:22:33:44:55:02", "hotspot-movil", now.Add(-90*time.Minute))
49+
insertScan(t, l, "ap1", "11:22:33:44:55:02", "hotspot-movil", now.Add(-30*time.Minute))
50+
l.EvalNewNeighborSsids()
51+
if got := newSsidAlerts(l); len(got) != 0 {
52+
t.Fatalf("transitorio no debe alertar: %+v", got)
53+
}
54+
}
55+
56+
func TestNewSsidAlertsAfterDwellOnce(t *testing.T) {
57+
l, _ := newImpostorLive(t)
58+
now := time.Now()
59+
l.EvalNewNeighborSsids() // siembra
60+
// SSID nuevo que persiste 5h y sigue activo.
61+
insertScan(t, l, "ap1", "11:22:33:44:55:03", "iot-pairing", now.Add(-5*time.Hour))
62+
insertScan(t, l, "ap1", "11:22:33:44:55:03", "iot-pairing", now.Add(-3*time.Hour))
63+
l.EvalNewNeighborSsids()
64+
if got := newSsidAlerts(l); len(got) != 0 {
65+
t.Fatalf("a los 3h de dwell no debe alertar aún... espera, esto ya son 5h")
66+
}
67+
insertScan(t, l, "ap1", "11:22:33:44:55:03", "iot-pairing", now)
68+
l.EvalNewNeighborSsids()
69+
got := newSsidAlerts(l)
70+
if len(got) != 1 {
71+
t.Fatalf("dwell cumplido y activo debe alertar: %d", len(got))
72+
}
73+
a := got[0]
74+
if a.Urgent || a.Severity != "info" || a.Category != "clients" {
75+
t.Fatalf("info/no urgente/clients: %+v", a)
76+
}
77+
if a.Vars["ssid"] != "iot-pairing" || a.Vars["router"] != "AP Uno" || a.Vars["signal"] != "-55" {
78+
t.Fatalf("vars: %+v", a.Vars)
79+
}
80+
// One-shot: re-evaluar con más sightings no repite.
81+
insertScan(t, l, "ap1", "11:22:33:44:55:03", "iot-pairing", now.Add(1*time.Minute))
82+
l.EvalNewNeighborSsids()
83+
if got := newSsidAlerts(l); len(got) != 1 {
84+
t.Fatalf("one-shot: no debe repetir; alertas: %d", len(got))
85+
}
86+
// Persistido en kv (sobrevive a la poda de 48h y a reinicios).
87+
var n int
88+
_ = l.db.QueryRow(`SELECT COUNT(1) FROM kv WHERE key='ssid_alerted:iot-pairing'`).Scan(&n)
89+
if n != 1 {
90+
t.Fatal("ssid_alerted no persistido en kv")
91+
}
92+
}
93+
94+
func TestNewSsidFleetAndEmptyIgnored(t *testing.T) {
95+
l, _ := newImpostorLive(t)
96+
now := time.Now()
97+
l.EvalNewNeighborSsids() // siembra
98+
// SSID propio de la flota y SSID vacío (red oculta): nunca alertan.
99+
insertScan(t, l, "ap1", "AA:BB:CC:00:00:09", "temiscira", now.Add(-6*time.Hour))
100+
insertScan(t, l, "ap1", "AA:BB:CC:00:00:09", "temiscira", now)
101+
insertScan(t, l, "ap1", "11:22:33:44:55:04", "", now.Add(-6*time.Hour))
102+
insertScan(t, l, "ap1", "11:22:33:44:55:04", "", now)
103+
l.EvalNewNeighborSsids()
104+
if got := newSsidAlerts(l); len(got) != 0 {
105+
t.Fatalf("flota/vacío no alertan: %+v", got)
106+
}
107+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const (
9696
HintDeviceOffline = "device-offline"
9797
HintUnknownDevice = "unknown-device"
9898
HintSsidImpostor = "wifi-ssid-impostor"
99+
HintNewNeighborSsid = "new-neighbor-ssid"
99100
HintFirmware = "firmware-outdated"
100101
HintWanDown = "wan-down"
101102
HintWifiWeak = "wifi-weak"
@@ -152,6 +153,7 @@ var Hints = map[string]string{
152153
HintDeviceOffline: "Verify the device power and network cable.",
153154
HintUnknownDevice: "If you do not recognize it, block it or mark it as trusted in Settings.",
154155
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.",
156+
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.",
155157
HintFirmware: "Your router has newer firmware available; check Firmware upgrades (Labs) or LuCI.",
156158
HintWanDown: "Check the modem/ONT and contact your ISP if the outage persists.",
157159
HintWifiWeak: "Move the device closer to an access point or relocate the AP.",

‎server-go/internal/httpapi/agent.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ func (s *server) handleIngestAgent(w http.ResponseWriter, r *http.Request) {
275275
if ev, ok := s.adapter.(interface{ EvalSsidImpostors() }); ok {
276276
ev.EvalSsidImpostors()
277277
}
278+
// #1359: misma coyuntura para SSIDs vecinos nuevos con dwell.
279+
if ev, ok := s.adapter.(interface{ EvalNewNeighborSsids() }); ok {
280+
ev.EvalNewNeighborSsids()
281+
}
278282
}
279283
// #1214: survey crudo del agente para la lente "Ahora" en unidades
280284
// agent-only. El adaptador Live lo cachea en memoria con TTL; los

0 commit comments

Comments
 (0)