Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)"
Expand Down
5 changes: 5 additions & 0 deletions app/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)"
Expand Down
107 changes: 101 additions & 6 deletions server-go/internal/adapters/device_seen.go
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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 (?, ?, ?, ?, ?, ?)
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion server-go/internal/adapters/live.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
114 changes: 114 additions & 0 deletions server-go/internal/adapters/newclient_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions server-go/internal/alerts/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand Down
Loading