From fc64481433ba4e1f860457aa2764a43b66008dc6 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 13 Oct 2026 21:18:42 +0200 Subject: [PATCH] feat(alerts): SSID impostor detection from existing neighbor scans (#1358) An unknown BSSID advertising a fleet SSID is the classic evil-twin signature. No new collection needed: passive neighbor scans already land in wifi_scans, and fleet BSSIDs/SSIDs already come from the agent registry (#1087) plus SSH-polled radios. - New AgentRegistry.LocalSsids() (OwnBssids + dawn/usteer local APs). - Live.EvalSsidImpostors(): one bounded query over the last 6h of wifi_scans. A candidate confirms only with >=2 real scans (distinct timestamps, so a frozen re-pushed payload does not count) separated by >=20 min dwell, and auto-resolves after 2h unseen. Alert id is stable per BSSID; tracking lives in memory on purpose - a restarted server re-alerts if the impostor is still around (security over re-notice). - Category clients, not signal: signal defaults to LevelNone and a security alert must not be invisible on stock config. - Wired after SaveScan in the agent ingest handler (the exact moment the evidence changes); i18n es/en (title, description with vars, hint). Tests: single sighting does not alert, dwell confirms once, own/foreign BSSID ignored, frozen payload does not confirm, auto-resolve, LocalSsids composition. Closes #1358 --- app/public/locales/en/translation.json | 5 + app/public/locales/es/translation.json | 5 + server-go/internal/adapters/agent.go | 52 +++++ server-go/internal/adapters/impostor.go | 170 +++++++++++++++ server-go/internal/adapters/impostor_test.go | 208 +++++++++++++++++++ server-go/internal/adapters/live.go | 5 + server-go/internal/alerts/alerts.go | 2 + server-go/internal/httpapi/agent.go | 6 + 8 files changed, 453 insertions(+) create mode 100644 server-go/internal/adapters/impostor.go create mode 100644 server-go/internal/adapters/impostor_test.go diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index f2803b95..5fe31500 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -88,6 +88,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.", + "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.", "uplink-switched": "Check the line that dropped: the router goes back to it on its own once it recovers.", "wan-down": "Check the modem/ONT and contact your ISP if the outage persists.", "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).", @@ -218,6 +219,10 @@ "description": "{{mac}} connected to {{router}}", "descriptionWhere": "{{mac}} · {{ip}} connected to {{where}}" }, + "wifi-ssid-impostor": { + "title": "SSID impostor detected", + "description": "{{bssid}} advertises {{ssid}} (heard by {{router}}, {{signal}} dBm)" + }, "uplink-restored": { "title": "Internet is back on {{to}}", "description": "{{router}} moved the traffic back to {{to}}" diff --git a/app/public/locales/es/translation.json b/app/public/locales/es/translation.json index 8042a79c..b21c8d1c 100644 --- a/app/public/locales/es/translation.json +++ b/app/public/locales/es/translation.json @@ -88,6 +88,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.", + "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.", "uplink-switched": "Revisa la línea que se ha caído: el router volverá a ella solo cuando se recupere.", "wan-down": "Comprueba el módem/ONT y contacta con tu operador si la caída persiste.", "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).", @@ -218,6 +219,10 @@ "description": "{{mac}} se ha conectado a {{router}}", "descriptionWhere": "{{mac}} · {{ip}} se ha conectado a {{where}}" }, + "wifi-ssid-impostor": { + "title": "SSID impostor detectado", + "description": "{{bssid}} anuncia {{ssid}} (oído por {{router}}, {{signal}} dBm)" + }, "uplink-restored": { "title": "Internet vuelve por {{to}}", "description": "{{router}} ha devuelto el tráfico a {{to}}" diff --git a/server-go/internal/adapters/agent.go b/server-go/internal/adapters/agent.go index a793d17f..5918ed05 100644 --- a/server-go/internal/adapters/agent.go +++ b/server-go/internal/adapters/agent.go @@ -12,6 +12,7 @@ package adapters import ( "fmt" + "sort" "strings" "sync" "time" @@ -201,6 +202,57 @@ func (r *AgentRegistry) LocalBssids() []string { return out } +// LocalSsids devuelve los SSID que los agentes reportan como propios +// (#1358): OwnBssids con SSID no vacío + SSIDs con al menos un AP local en +// dawn/usteer. Es la lista "auto-aprobada" para la detección de SSID +// impostor: cualquier BSSID ajeno anunciando uno de estos nombres es +// sospechoso. Dedup, ordenado para salida estable. +func (r *AgentRegistry) LocalSsids() []string { + r.mu.Lock() + defer r.mu.Unlock() + seen := map[string]bool{} + var out []string + add := func(ssid string) { + if ssid == "" || seen[ssid] { + return + } + seen[ssid] = true + out = append(out, ssid) + } + for _, st := range r.states { + if st.Payload == nil { + continue + } + if w := st.Payload.Data.Wireless; w != nil { + for _, ob := range w.OwnBssids { + add(ob.SSID) + } + } + if st.Payload.Data.Dawn != nil { + for name, ssid := range st.Payload.Data.Dawn.SSIDs { + for _, ap := range ssid.APs { + if ap.Local { + add(name) + break + } + } + } + } + if st.Payload.Data.Usteer != nil { + for name, ssid := range st.Payload.Data.Usteer.SSIDs { + for _, ap := range ssid.APs { + if ap.Local { + add(name) + break + } + } + } + } + } + sort.Strings(out) + return out +} + // Fresh devuelve el último payload si está dentro de su ventana de // frescura (TTL base o 3x cadencia declarada, #288). func (r *AgentRegistry) Fresh(slug string) (*probe.Payload, bool) { diff --git a/server-go/internal/adapters/impostor.go b/server-go/internal/adapters/impostor.go new file mode 100644 index 00000000..6711865a --- /dev/null +++ b/server-go/internal/adapters/impostor.go @@ -0,0 +1,170 @@ +package adapters + +// impostor.go — alerta wifi-ssid-impostor (#1358): un BSSID que NO es de la +// flota anunciando un SSID que SÍ es de la flota es la firma clásica de evil +// twin. No hace falta ninguna recolección nueva: los scans pasivos de +// vecinos ya caen en wifi_scans (Fase 18/#452) y los BSSIDs/SSIDs propios +// ya los recopila el registry de agentes (#1087) más las radios sondeadas +// por SSH (iwinfo). +// +// Anti-transitorio: un sighting suelto no alerta (un hotspot de paso con +// nombre parecido, un vecino que puso tu SSID un rato). El BSSID candidato +// tiene que aparecer en >=2 scans REALES (timestamps distintos en +// wifi_scans, no el mismo payload recongelado) separados al menos +// impostorDwell, con el último sighting reciente. Auto-resolve cuando lleva +// impostorGone sin verse. +// +// La lista de aprobados es la propia flota (zero-config): no hay nada que +// configurar para el caso común. + +import ( + "fmt" + "strings" + "time" + + "github.com/gnacho/netpulse/server-go/internal/alerts" +) + +const ( + // impostorDwell: separación mínima entre el primer y el último sighting + // para confirmar el impostor (con el scan típico de 30 min, el segundo + // scan real llega ~30 min después del primero). + impostorDwell = 20 * time.Minute + // impostorRecent: el último sighting debe ser más reciente que esto + // para que la alerta siga considerándose activa (2x el scan interval + // más holgado que solemos ver, 1h). + impostorRecent = 2 * time.Hour + // impostorWindow: sightings más viejos que esto no cuentan. + impostorWindow = 6 * time.Hour +) + +// EvalSsidImpostors: una pasada sobre wifi_scans recientes. La llama el +// handler de ingesta del agente tras guardar un scan nuevo (momento exacto +// en que la evidencia cambia). Barata: una query acotada por ventana. +func (l *Live) EvalSsidImpostors() { + if l.db == nil || l.agents == nil || l.engine == nil { + return + } + now := time.Now() + + ownBssids := map[string]bool{} + for _, b := range l.agents.LocalBssids() { + ownBssids[b] = true + } + // Las radios sondeadas por SSH (iwinfo) también son propias: sin esto un + // router sin agente anunciando el SSID de la flota sería falso positivo. + // Fuente: la caché de extras del último sondeo (radios con BSSID real). + l.mu.Lock() + for _, ex := range l.extrasCache { + for _, r := range ex.radios { + if r.BSSID != "" { + ownBssids[strings.ToUpper(r.BSSID)] = true + } + } + } + l.mu.Unlock() + fleetSsids := map[string]bool{} + for _, s := range l.agents.LocalSsids() { + fleetSsids[s] = true + } + if len(fleetSsids) == 0 { + return // sin flota wifi conocida no hay nada que suplantar + } + + rows, err := l.db.Query( + `SELECT bssid, ssid, router_id, signal_dbm, ts FROM wifi_scans WHERE ts >= ?`, + now.Add(-impostorWindow).Unix()) + if err != nil { + return + } + type sighting struct { + ssid string + routerID string + signal int + tss map[int64]bool // timestamps distintos (scans reales) + minTs int64 + maxTs int64 + } + byBssid := map[string]*sighting{} + for rows.Next() { + var bssid, ssid, routerID string + var signal, ts int64 + if rows.Scan(&bssid, &ssid, &routerID, &signal, &ts) != nil { + continue + } + bssid = strings.ToUpper(strings.TrimSpace(bssid)) + if bssid == "" || !fleetSsids[ssid] || ownBssids[bssid] { + continue + } + s := byBssid[bssid] + if s == nil { + // Inicializar con los datos de ESTE row: sin ORDER BY la BD + // puede devolver primero el sighting más reciente, y si el + // primero procesado no deja router/señal, la alerta saldría + // con esos campos vacíos. + s = &sighting{ssid: ssid, routerID: routerID, signal: int(signal), minTs: ts, maxTs: ts, tss: map[int64]bool{ts: true}} + byBssid[bssid] = s + continue + } + s.tss[ts] = true + if ts < s.minTs { + s.minTs = ts + } + if ts > s.maxTs { + s.maxTs = ts + s.routerID = routerID + s.signal = int(signal) + } + } + rows.Close() + + l.mu.Lock() + defer l.mu.Unlock() + // Nuevos confirmados: >=2 scans reales separados >= dwell, último reciente. + for bssid, s := range byBssid { + confirmed := len(s.tss) >= 2 && + time.Duration(s.maxTs-s.minTs)*time.Second >= impostorDwell && + time.Unix(s.maxTs, 0).After(now.Add(-impostorRecent)) + if !confirmed { + continue + } + alertID := "alert-ssid-impostor-" + bssid + if _, ok := l.impostorActive[bssid]; ok { + continue // ya emitida; el engine la conserva activa + } + routerName := s.routerID + if rn := l.routerDisplayName(s.routerID); rn != "" { + routerName = rn + } + l.engine.Emit(AlertEvent{ + ID: alertID, + // CatClients y no CatSignal: signal está en LevelNone por defecto + // (alertas de cobertura) y una alerta de SEGURIDAD no puede + // quedar invisible en la config stock (#1358). + Category: alerts.CatClients, + Urgent: true, + Severity: "critical", + Title: "SSID impostor detected", + Description: fmt.Sprintf("%s advertises %s (heard by %s, %d dBm)", bssid, s.ssid, routerName, s.signal), + Hint: alerts.HintFor(alerts.HintSsidImpostor), + Type: alerts.HintSsidImpostor, + Vars: map[string]string{ + "bssid": bssid, + "ssid": s.ssid, + "router": routerName, + "signal": fmt.Sprintf("%d", s.signal), + }, + RouterID: s.routerID, + }) + l.impostorActive[bssid] = alertID + } + // Auto-resolve: el BSSID alertado lleva impostorRecent sin aparecer en + // ningún scan (se apagó o se fue). Si vuelve, la gracia empieza de cero. + for bssid, alertID := range l.impostorActive { + s := byBssid[bssid] + if s == nil || time.Unix(s.maxTs, 0).Before(now.Add(-impostorRecent)) { + l.engine.Resolve(alertID) + delete(l.impostorActive, bssid) + } + } +} diff --git a/server-go/internal/adapters/impostor_test.go b/server-go/internal/adapters/impostor_test.go new file mode 100644 index 00000000..150f7965 --- /dev/null +++ b/server-go/internal/adapters/impostor_test.go @@ -0,0 +1,208 @@ +package adapters + +// impostor_test.go — #1358: alerta wifi-ssid-impostor desde wifi_scans + +// BSSIDs/SSIDs propios del registry. BD real en dir temporal (wifi_scans +// la crea db.Open), engine en memoria (l.engine de NewLive). + +import ( + "testing" + "time" + + "github.com/gnacho/netpulse/agent/probe" + "github.com/gnacho/netpulse/server-go/internal/db" +) + +// newImpostorLive: Live con db real (wifi_scans) y registry con la flota +// mínima: un agente ("ap1") cuyo SSID propio es "temiscira". +func newImpostorLive(t *testing.T) (*Live, *AgentRegistry) { + t.Helper() + d, err := db.Open(t.TempDir()) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { d.Close() }) + reg := NewAgentRegistry(90 * time.Second) + reg.Ingest(&probe.Payload{ + Router: "ap1", + Version: "3.0.16", + Ts: time.Now().Unix(), + Data: probe.PayloadData{ + Wireless: &probe.WirelessData{ + OwnBssids: []probe.OwnBSSID{ + {Iface: "wlan0", BSSID: "AA:BB:CC:00:00:01", SSID: "temiscira"}, + }, + }, + }, + }) + l := NewLive(nil, d, []RouterConfig{{ID: "ap1", Name: "AP Uno"}}, nil) + l.SetAgents(reg) + return l, reg +} + +// insertScan: sighting directo en wifi_scans (lo que haría SaveScan). +func insertScan(t *testing.T, l *Live, routerID, bssid, ssid string, ts time.Time) { + t.Helper() + _, err := l.db.Exec( + `INSERT INTO wifi_scans (router_id, iface, bssid, ssid, channel, freq, signal_dbm, width_mhz, ts) + VALUES (?, 'wlan0', ?, ?, 6, 2437, -55, 20, ?)`, + routerID, bssid, ssid, ts.Unix()) + if err != nil { + t.Fatalf("insert scan: %v", err) + } +} + +func impostorAlerts(l *Live) []AlertEvent { + var out []AlertEvent + for _, a := range l.engine.List() { + if a.Type == "wifi-ssid-impostor" { + out = append(out, a) + } + } + return out +} + +func TestImpostorSingleSightingDoesNotAlert(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:01", "temiscira", now) + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 0 { + t.Fatalf("un sighting suelto no debe alertar; alertas: %+v", got) + } +} + +func TestImpostorConfirmedAfterDwell(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:02", "temiscira", now.Add(-30*time.Minute)) + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 0 { + t.Fatalf("un solo scan real aunque sea viejo no debe alertar") + } + // Segundo scan real (otro ts) separado >= dwell: confirma. + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:02", "temiscira", now) + l.EvalSsidImpostors() + got := impostorAlerts(l) + if len(got) != 1 { + t.Fatalf("dwell cumplido debe alertar una vez; alertas: %d", len(got)) + } + a := got[0] + if !a.Urgent || a.Severity != "critical" || a.Category != "clients" { + t.Fatalf("severidad/categoría/urgencia: %+v", a) + } + if a.Vars["bssid"] != "DE:AD:BE:EF:00:02" || a.Vars["ssid"] != "temiscira" || a.Vars["router"] != "AP Uno" { + t.Fatalf("vars: %+v", a.Vars) + } + // Re-evaluar con la misma evidencia NO duplica (ID estable + tracking). + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 1 { + t.Fatalf("re-evaluación no debe duplicar; alertas: %d", len(got)) + } +} + +func TestImpostorOwnBssidIgnored(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + // BSSID propio (del registry) anunciando el SSID propio: normal. + insertScan(t, l, "ap1", "AA:BB:CC:00:00:01", "temiscira", now.Add(-40*time.Minute)) + insertScan(t, l, "ap1", "aa:bb:cc:00:00:01", "temiscira", now) // minúsculas + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 0 { + t.Fatalf("BSSID propio no es impostor; alertas: %+v", got) + } +} + +func TestImpostorForeignSsidIgnored(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + // SSID que no es de la flota: vecino normal. + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:03", "wifi-del-vecino", now.Add(-40*time.Minute)) + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:03", "wifi-del-vecino", now) + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 0 { + t.Fatalf("SSID ajeno no alerta; alertas: %+v", got) + } +} + +func TestImpostorSamePayloadFrozenDoesNotConfirm(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + // El mismo scan (mismo ts) duplicado por re-push del payload congelado: + // NO son dos scans reales. + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:04", "temiscira", now.Add(-30*time.Minute)) + insertScan(t, l, "ap2", "DE:AD:BE:EF:00:04", "temiscira", now.Add(-30*time.Minute)) + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 0 { + t.Fatalf("mismo ts en dos routers no confirma; alertas: %+v", got) + } +} + +func TestImpostorAutoResolve(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:05", "temiscira", now.Add(-90*time.Minute)) + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:05", "temiscira", now.Add(-60*time.Minute)) + l.EvalSsidImpostors() + if got := impostorAlerts(l); len(got) != 1 { + t.Fatalf("debió alertar (dwell cumplido y reciente); alertas: %d", len(got)) + } + // Pasan >impostorRecent sin nuevos sightings: resolve. La ventana de la + // query usa el reloj real; simulamos el paso del tiempo retirando la + // evidencia (equivale a que los scans envejecieron fuera de la ventana). + _, _ = l.db.Exec(`DELETE FROM wifi_scans`) + l.EvalSsidImpostors() + if l.engine.Has("alert-ssid-impostor-DE:AD:BE:EF:00:05") { + t.Fatal("impostor desaparecido debe auto-resolverse") + } + if len(l.impostorActive) != 0 { + t.Fatalf("tracking residual: %+v", l.impostorActive) + } +} + +func TestLocalSsids(t *testing.T) { + reg := NewAgentRegistry(90 * time.Second) + reg.Ingest(&probe.Payload{ + Router: "ap1", Version: "3.0.16", Ts: time.Now().Unix(), + Data: probe.PayloadData{ + Wireless: &probe.WirelessData{ + OwnBssids: []probe.OwnBSSID{ + {Iface: "wlan0", BSSID: "AA:BB:CC:00:00:01", SSID: "temiscira"}, + {Iface: "wlan1", BSSID: "AA:BB:CC:00:00:02", SSID: "temiscira-guest"}, + {Iface: "wlan2", BSSID: "AA:BB:CC:00:00:03", SSID: ""}, // sin SSID: fuera + }, + }, + Dawn: &probe.DawnData{SSIDs: map[string]probe.DawnSSID{ + "temiscira": {APs: []probe.DawnAP{{BSSID: "AA:BB:CC:00:00:01", Local: true}}}, + "vecina": {APs: []probe.DawnAP{{BSSID: "11:22:33:44:55:66", Local: false}}}, // no local: fuera + }}, + }, + }) + got := reg.LocalSsids() + want := map[string]bool{"temiscira": true, "temiscira-guest": true} + if len(got) != len(want) { + t.Fatalf("LocalSsids = %v", got) + } + for _, s := range got { + if !want[s] { + t.Fatalf("SSID inesperado: %q (got %v)", s, got) + } + } +} + +func TestImpostorAlertUsesLatestSighting(t *testing.T) { + l, _ := newImpostorLive(t) + now := time.Now() + // El sighting RECIENTE insertado primero (rowid menor: la BD lo + // devuelve antes sin ORDER BY). Sin inicializar router/señal en la + // creación del tracking, la alerta salía con esos campos vacíos. + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:06", "temiscira", now) + insertScan(t, l, "ap1", "DE:AD:BE:EF:00:06", "temiscira", now.Add(-30*time.Minute)) + l.EvalSsidImpostors() + got := impostorAlerts(l) + if len(got) != 1 { + t.Fatalf("debió alertar; alertas: %d", len(got)) + } + if got[0].Vars["router"] != "AP Uno" || got[0].Vars["signal"] != "-55" { + t.Fatalf("vars deben ser del sighting más reciente: %+v", got[0].Vars) + } +} diff --git a/server-go/internal/adapters/live.go b/server-go/internal/adapters/live.go index a74948d1..c6b558c4 100644 --- a/server-go/internal/adapters/live.go +++ b/server-go/internal/adapters/live.go @@ -319,6 +319,10 @@ type Live struct { unknownGrace map[string]int unknownAlerted map[string]bool unknownGraceNum int // ticks antes de declarar desconocido (default 3, #234) + // impostorActive (#1358): BSSID impostor → alertID emitida. Solo en + // memoria a propósito: si el server reinicia y el impostor sigue + // visible, la alerta se re-emite (seguridad prima sobre re-aviso). + impostorActive map[string]string // Presencia wireless (issue #184): devicePresence = MAC → online del // último ciclo evaluado; presenceMisses = ticks seguidos sin verse. // presenceSeen evita la avalancha del primer ciclo (anti-arranque). @@ -466,6 +470,7 @@ func NewLive(cfg *config.Config, d *db.DB, initial []RouterConfig, pool *SSHPool unknownGrace: map[string]int{}, unknownAlerted: map[string]bool{}, unknownGraceNum: 3, // default de #234; el engine/demo puede afinarlo + impostorActive: map[string]string{}, devicePresence: map[string]bool{}, presenceMisses: map[string]int{}, presenceOfflineAfter: 3, diff --git a/server-go/internal/alerts/alerts.go b/server-go/internal/alerts/alerts.go index d8dffe48..ada4d09c 100644 --- a/server-go/internal/alerts/alerts.go +++ b/server-go/internal/alerts/alerts.go @@ -95,6 +95,7 @@ const ( HintPortFlapping = "port-flapping" HintDeviceOffline = "device-offline" HintUnknownDevice = "unknown-device" + HintSsidImpostor = "wifi-ssid-impostor" HintFirmware = "firmware-outdated" HintWanDown = "wan-down" HintWifiWeak = "wifi-weak" @@ -150,6 +151,7 @@ var Hints = map[string]string{ HintPortFlapping: "Check the cable or switch port: an unstable link is usually the culprit.", HintDeviceOffline: "Verify the device power and network cable.", 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.", 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.", diff --git a/server-go/internal/httpapi/agent.go b/server-go/internal/httpapi/agent.go index e1e57166..2fab2de7 100644 --- a/server-go/internal/httpapi/agent.go +++ b/server-go/internal/httpapi/agent.go @@ -269,6 +269,12 @@ func (s *server) handleIngestAgent(w http.ResponseWriter, r *http.Request) { if err := s.channelPlan.SaveScan(p.Router, p.Ts, p.Data.Wireless.Scans); err != nil { log.Printf("[netpulse] error guardando scans de %s: %v", p.Router, err) } + // #1358: con el scan recién guardado, reevalúa impostores de SSID + // (BSSID ajeno anunciando un SSID de la flota). Solo adaptadores que + // lo implementan (live); demo se omite. + if ev, ok := s.adapter.(interface{ EvalSsidImpostors() }); ok { + ev.EvalSsidImpostors() + } } // #1214: survey crudo del agente para la lente "Ahora" en unidades // agent-only. El adaptador Live lo cachea en memoria con TTL; los