diff --git a/AGENTS.md b/AGENTS.md index 293ba0426..fd35106a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,7 +211,8 @@ All colors MUST use CSS variables. Never hardcode `#hex` values outside of `:roo ### last_heard vs last_seen - `last_seen` = DB timestamp, only updates on adverts/direct upserts -- `last_heard` = from in-memory packet store, updates on ALL traffic +- `last_heard` = the newer of `last_seen` and the certain last heard from the in-memory packet store (#2260): the node's own packets plus relay hops whose prefix can only be this node. A 1-byte hop shared with other repeaters is NOT evidence, and neither is `last_relayed` (it counts those shared prefixes too). Liveness — status, Last Heard, the notify offline check — uses only this. +- Status from `getNodeStatus(n)` is `'active'`, `'stale'` or, for a repeater or room, `'uncertain'`: nothing certain within the silent window, but `last_relayed` (its shared prefix in a path) is. Treat `'uncertain'` as not proven alive (dim it like stale); never as active. - Always prefer `n.last_heard || n.last_seen` for display and status calculation ### Packet Filter (packet-filter.js) diff --git a/cmd/server/certain_heard.go b/cmd/server/certain_heard.go new file mode 100644 index 000000000..9c3da21dc --- /dev/null +++ b/cmd/server/certain_heard.go @@ -0,0 +1,156 @@ +package main + +import ( + "strings" + "time" + + "github.com/meshcore-analyzer/packetpath" +) + +// Certain liveness (#2260). certainHeard holds, per pubkey, the newest +// FirstSeen of a transmission that can only have come from that node: +// +// - its own packets (decoded pubKey / srcPubKey), and +// - flood relay hops whose prefix, at the packet's path hash size, matches +// exactly one node that can appear in a path, and no retired one +// (inactive_nodes) that may still relay. +// +// A 1-byte hop shared by several repeaters is not evidence: the resolver may +// still attribute it (affinity, geography) for analytics, but a silent node +// must not stay "Active" on traffic from a namesake prefix. The firmware +// lets the originator pick the hash size for the whole path +// (Mesh::sendFlood) and every relay appends its own hash at that size +// (Mesh::routeRecvPacket), so a multibyte repeater legitimately appears as a +// 1-byte hop; only its uniqueness decides. Direct-route paths list the hops +// still to come (Mesh::removeSelfFromPath), and TRACE reuses the path bytes +// for SNR, so neither proves a hop transmitted. +// +// Liveness reads it: Last Heard, the node status, the nodes list and the +// notify offline check. One entry per node that was ever certainly heard, +// dropped with the node's byNode list on eviction. + +// noteCertainHeard records tx as certain evidence for pubkey. Caller holds +// s.mu for writing. +func (s *PacketStore) noteCertainHeard(pubkey string, tx *StoreTx) { + if pubkey == "" || tx == nil || tx.FirstSeen == "" { + return + } + if s.certainHeard == nil { + s.certainHeard = make(map[string]string) + } + if tx.FirstSeen > s.certainHeard[pubkey] { + s.certainHeard[pubkey] = tx.FirstSeen + } +} + +// signatureFailed reports a decoded advert whose signature did not verify +// (signatureValid false): a corrupted or forged advert is no evidence the +// node transmitted. Absent means not checked, which is not a failure. +func (tx *StoreTx) signatureFailed() bool { + v, ok := tx.ParsedDecoded()["signatureValid"].(bool) + return ok && !v +} + +// relayHopHexLen is the hex length of one relay hop in tx's path, or 0 when +// the path is no evidence of who transmitted: not a flood route, a TRACE, or +// an unreadable header. +func relayHopHexLen(tx *StoreTx) int { + if tx.PayloadType != nil && !packetpath.PathBytesAreHops(byte(*tx.PayloadType)) { + return 0 + } + routeType, pathByte, ok := packetpath.PathByte(tx.RawHex) + if !ok || (routeType != packetpath.RouteFlood && routeType != packetpath.RouteTransportFlood) { + return 0 + } + size := int(pathByte>>6) + 1 + if size > 3 { // reserved (firmware Packet::isValidPathLen) + return 0 + } + return 2 * size +} + +// noteCertainRelays records tx for each resolved relay hop pubkey whose hop +// prefix is unique among path-capable nodes in pm. pm nil records nothing. +// Caller holds s.mu for writing. +func (s *PacketStore) noteCertainRelays(tx *StoreTx, pks []string, pm *prefixMap) { + if pm == nil || len(pks) == 0 { + return + } + n := relayHopHexLen(tx) + if n == 0 { + return + } + for _, pk := range pks { + if uniqueHopPrefix(pk, n, pm) { + s.noteCertainHeard(pk, tx) + } + } +} + +// uniqueHopPrefix reports whether pk's first n hex characters, a relay hop +// at that hash size, match exactly one path-capable node in pm, pk itself, +// and no retired one. A repeater that never advertised is unknown to both +// and cannot be ruled out. +func uniqueHopPrefix(pk string, n int, pm *prefixMap) bool { + if pm == nil || n == 0 || len(pk) < n { + return false + } + prefix := strings.ToLower(pk[:n]) + if _, shared := pm.retired[prefix]; shared { + return false + } + c := pm.relayCandidates(prefix) + return len(c) == 1 && strings.EqualFold(c[0].PublicKey, pk) +} + +// markRetired loads the hop prefixes (1 to 3 bytes) of the path-capable +// nodes in inactive_nodes into pm.retired. Rows of nodes also in nodes are +// skipped: retention copies a node there and nothing removes it when the +// node is heard again, and a node is no namesake of itself. A database +// without the table leaves it empty. +func (pm *prefixMap) markRetired(db *DB) error { + if db == nil || !db.inactiveNodesPresent() { + return nil + } + rows, err := db.conn.Query(`SELECT public_key, COALESCE(role, '') FROM inactive_nodes + WHERE lower(public_key) NOT IN (SELECT lower(public_key) FROM nodes)`) + if err != nil { + return err + } + defer rows.Close() + retired := map[string]struct{}{} + for rows.Next() { + var pk, role string + if err := rows.Scan(&pk, &role); err != nil { + return err + } + if !canAppearInPath(role) { + continue + } + pk = strings.ToLower(pk) + for n := 2; n <= 6 && n <= len(pk); n += 2 { + retired[pk[:n]] = struct{}{} + } + } + if err := rows.Err(); err != nil { + return err + } + pm.retired = retired + return nil +} + +// lastHeardValue is a node's last_heard in /api/nodes and the node detail: +// the newer of its DB last_seen (adverts; nil or unparsable counts as +// never) and heard, its certain last heard from LastHeardMap (zero for +// none). Before #2260 last_heard was a plain copy of last_seen. +func lastHeardValue(lastSeen interface{}, heard time.Time) interface{} { + if heard.IsZero() { + return lastSeen + } + if s, ok := lastSeen.(string); ok { + if t, ok := parseRelayTS(s); ok && !heard.After(t) { + return lastSeen + } + } + return heard.UTC().Format("2006-01-02T15:04:05.000Z") +} diff --git a/cmd/server/certain_heard_loadpaths_test.go b/cmd/server/certain_heard_loadpaths_test.go new file mode 100644 index 000000000..7d49813ad --- /dev/null +++ b/cmd/server/certain_heard_loadpaths_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "database/sql" + "strings" + "testing" + "time" +) + +// Review (#2261): every load path must feed certainHeard. A nil prefix map +// at any of the call sites in Load, loadChunk, scanAndMergeChunk, live +// ingest or late observations would leave the unit tests green; these go +// through the real paths and assert LastHeardMap. + +const lpRelay = "4f2a" + "5555555555555555555555555555555555555555555555555555555555555" + +// lpFixture is one flood transmission whose single 2-byte hop is lpRelay, +// a registered repeater, observed a minute ago. withResolved keeps the +// observation's resolved_path (the ingestor resolved the hop); otherwise it +// is NULL and the server re-resolves from path_json. hopInFirstObs false +// leaves the first observation without a path (for the late-observation +// case). +func lpFixture(t *testing.T, withResolved, hopInFirstObs bool) (*DB, *sql.DB, string) { + t.Helper() + path := createTestDBWithResolvedPath(t, 1, []string{lpRelay}) + w, err := sql.Open("sqlite3", path+"?_journal_mode=WAL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { w.Close() }) + at := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339) + raw := "1541" + lpRelay[:4] + "00" // GRP_TXT, flood, path byte: 2-byte hashes x1 + pathJSON, resolved := `["`+strings.ToUpper(lpRelay[:4])+`"]`, interface{}(`["`+lpRelay+`"]`) + if !withResolved { + resolved = nil + } + if !hopInFirstObs { + pathJSON, resolved = `[]`, nil + } + for _, q := range []struct { + sql string + args []interface{} + }{ + {`ALTER TABLE transmissions ADD COLUMN from_pubkey TEXT`, nil}, + {`UPDATE transmissions SET raw_hex=?, route_type=1, payload_type=5, first_seen=?`, []interface{}{raw, at}}, + {`UPDATE observations SET raw_hex=?, timestamp=?, path_json=?, resolved_path=?`, []interface{}{raw, at, pathJSON, resolved}}, + {`INSERT INTO nodes (public_key, name, role) VALUES (?, 'Relay', 'repeater')`, []interface{}{lpRelay}}, + } { + if _, err := w.Exec(q.sql, q.args...); err != nil { + t.Fatalf("%s: %v", q.sql, err) + } + } + db, err := OpenDB(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.conn.Close() }) + return db, w, at +} + +func TestCertainHeardFedByEveryLoadPath(t *testing.T) { + type mode struct { + name string + withResolved, hopInFirstObs bool + run func(t *testing.T, s *PacketStore, db *DB, w *sql.DB, at string) + } + load := func(t *testing.T, s *PacketStore, _ *DB, _ *sql.DB, _ string) { + if err := s.Load(); err != nil { + t.Fatal(err) + } + } + chunk := func(t *testing.T, s *PacketStore, _ *DB, _ *sql.DB, _ string) { + if err := s.loadChunk(time.Now().Add(-time.Hour), time.Now()); err != nil { + t.Fatal(err) + } + } + chunked := func(t *testing.T, s *PacketStore, _ *DB, _ *sql.DB, _ string) { + if err := s.LoadChunked(1); err != nil { + t.Fatal(err) + } + } + live := func(t *testing.T, s *PacketStore, _ *DB, _ *sql.DB, _ string) { + s.IngestNewFromDB(0, 100) + } + late := func(t *testing.T, s *PacketStore, db *DB, w *sql.DB, at string) { + if err := s.Load(); err != nil { + t.Fatal(err) + } + if heard, _ := s.LastHeardMap([]string{lpRelay}); len(heard) != 0 { + t.Fatal("setup: the first observation carries no hop") + } + maxObs := db.GetMaxObservationID() + if _, err := w.Exec(`INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp, raw_hex, resolved_path) + VALUES (2, 1, 'obs2', 'Obs2', 'RX', -9, -79, 5, ?, ?, '', NULL)`, `["`+strings.ToUpper(lpRelay[:4])+`"]`, at); err != nil { + t.Fatal(err) + } + s.IngestNewObservations(maxObs, 100) + } + for _, m := range []mode{ + {"Load/resolved_path", true, true, load}, + {"Load/path_json", false, true, load}, + {"loadChunk/resolved_path", true, true, chunk}, + {"loadChunk/path_json", false, true, chunk}, + {"LoadChunked/resolved_path", true, true, chunked}, + {"LoadChunked/path_json", false, true, chunked}, + {"IngestNewFromDB", false, true, live}, + {"IngestNewObservations", false, false, late}, + } { + t.Run(m.name, func(t *testing.T) { + db, w, at := lpFixture(t, m.withResolved, m.hopInFirstObs) + s := NewPacketStore(db, &PacketStoreConfig{}) + s.useResolvedPathIndex = true + s.initResolvedPathIndex() + m.run(t, s, db, w, at) + heard, _ := s.LastHeardMap([]string{lpRelay}) + want, _ := time.Parse(time.RFC3339, at) + if !heard[lpRelay].Equal(want) { + t.Fatalf("LastHeardMap[relay] = %v; want %v: this path does not feed certainHeard", heard[lpRelay], want) + } + }) + } +} diff --git a/cmd/server/certain_heard_test.go b/cmd/server/certain_heard_test.go new file mode 100644 index 000000000..3a6703092 --- /dev/null +++ b/cmd/server/certain_heard_test.go @@ -0,0 +1,408 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/meshcore-analyzer/users" +) + +// Three repeaters: two share the first pubkey byte 0x67 (as on the +// instance in #2260, where nine nodes did), one has a unique first byte. +var ( + chA = "67a7" + strings.Repeat("1", 60) // the silent repeater + chB = "67f4" + strings.Repeat("2", 60) // its 1-byte namesake, still relaying + chC = "40aa" + strings.Repeat("3", 60) // unique first byte +) + +func chPM() *prefixMap { + return buildPrefixMap([]nodeInfo{ + {PublicKey: chA, Role: "repeater"}, + {PublicKey: chB, Role: "repeater"}, + {PublicKey: chC, Role: "repeater"}, + }) +} + +// chTx builds a transmission whose raw header carries the route type and the +// path hash size (firmware docs/packet_format.md: header, transport codes for +// transport routes, then the path byte with the hash size in its top bits). +func chTx(id, routeType, payloadType, hashSize int, firstSeen time.Time) *StoreTx { + raw := fmt.Sprintf("%02x", payloadType<<2|routeType) + if routeType == 0 || routeType == 3 { + raw += "00000000" + } + raw += fmt.Sprintf("%02x", (hashSize-1)<<6|1) + strings.Repeat("ab", hashSize) + "00" + pt := payloadType + return &StoreTx{ID: id, RawHex: raw, PayloadType: &pt, FirstSeen: firstSeen.UTC().Format(time.RFC3339)} +} + +func TestRelayHopHexLen(t *testing.T) { + at := time.Date(2026, 10, 18, 16, 0, 0, 0, time.UTC) + cases := []struct { + name string + tx *StoreTx + want int + }{ + {"flood 1-byte", chTx(1, 1, 5, 1, at), 2}, + {"flood 2-byte", chTx(2, 1, 5, 2, at), 4}, + {"flood 3-byte", chTx(3, 1, 5, 3, at), 6}, + {"transport flood 2-byte", chTx(4, 0, 5, 2, at), 4}, + {"direct: path lists hops still to come", chTx(5, 2, 5, 1, at), 0}, + {"transport direct", chTx(6, 3, 5, 1, at), 0}, + {"TRACE: path bytes are SNR", chTx(7, 1, packetpathTrace, 1, at), 0}, + {"no raw", &StoreTx{ID: 8}, 0}, + } + for _, c := range cases { + if got := relayHopHexLen(c.tx); got != c.want { + t.Errorf("%s: %d; want %d", c.name, got, c.want) + } + } +} + +const packetpathTrace = 0x09 + +func chStore() *PacketStore { + s := makeTestStore(0, time.Now(), 0) + s.byPathHop = make(map[string][]*StoreTx) + s.initResolvedPathIndex() + return s +} + +func TestNoteCertainRelays(t *testing.T) { + pm := chPM() + t0 := time.Date(2026, 10, 18, 16, 49, 25, 0, time.UTC) + s := chStore() + hopsSeen := map[string]bool{} + + // 2-byte flood hop 67a7: only chA has it, certain. + s.indexResolvedPathHops(chTx(1, 1, 5, 2, t0), []string{chA}, hopsSeen, pm) + // Later 1-byte flood hops 67 (shared with chB) and 40 (unique): the + // resolver attributed 67 to chA, but only 40 is certain. + later := t0.Add(70 * time.Hour) + s.indexResolvedPathHops(chTx(2, 1, 5, 1, later), []string{chA, chC}, hopsSeen, pm) + // Direct route and TRACE never count; a nil prefix map records nothing. + s.indexResolvedPathHops(chTx(3, 2, 5, 2, later), []string{chA}, hopsSeen, pm) + s.indexResolvedPathHops(chTx(4, 1, packetpathTrace, 2, later), []string{chA}, hopsSeen, pm) + s.indexResolvedPathHops(chTx(5, 1, 5, 2, later), []string{chA}, hopsSeen, nil) + + if got := s.certainHeard[chA]; got != t0.Format(time.RFC3339) { + t.Errorf("chA certain = %q; want the 2-byte hop at %s", got, t0.Format(time.RFC3339)) + } + if got := s.certainHeard[chC]; got != later.Format(time.RFC3339) { + t.Errorf("chC certain = %q; want the unique 1-byte hop", got) + } + if len(s.byNode[chA]) != 5 { + t.Errorf("byNode[chA] = %d packets; attribution for analytics must be unchanged", len(s.byNode[chA])) + } + + // The cold-load path (resolved_path NULL) feeds liveness the same way. + s.indexColdLoadRelayHops(chTx(6, 1, 5, 2, later.Add(time.Hour)), []string{chB}, pm) + if got := s.certainHeard[chB]; got != later.Add(time.Hour).Format(time.RFC3339) { + t.Errorf("chB certain from cold load = %q", got) + } +} + +func TestIndexByNodeSenderIsCertain(t *testing.T) { + s := chStore() + at := time.Date(2026, 10, 18, 16, 45, 51, 0, time.UTC) + tx := chTx(1, 1, 4, 1, at) + tx.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"destPubKey":%q}`, chA, chC) + s.indexByNode(tx) + if s.certainHeard[chA] != at.Format(time.RFC3339) { + t.Errorf("sender not certain: %q", s.certainHeard[chA]) + } + if _, ok := s.certainHeard[chC]; ok { + t.Error("a destination is no evidence the node is alive") + } +} + +// #2260 regression: a repeater last certainly heard on 18 Oct, after which +// only its 1-byte namesake prefix shows up in paths. Liveness must stay at +// 18 Oct and the offline mail must go out once the 72 h window has passed. +func TestSilentRepeaterWithNamesakePrefixGoesOffline(t *testing.T) { + pm := chPM() + lastCertain := time.Date(2026, 10, 18, 16, 49, 25, 0, time.UTC) + s := chStore() + hopsSeen := map[string]bool{} + s.indexResolvedPathHops(chTx(1, 1, 5, 2, lastCertain), []string{chA}, hopsSeen, pm) + for i := 0; i < 20; i++ { + ts := lastCertain.Add(time.Duration(70+i) * time.Hour) + s.indexResolvedPathHops(chTx(2+i, 1, 5, 1, ts), []string{chA}, hopsSeen, pm) + } + + heard, _ := s.LastHeardMap([]string{chA}) + if !heard[chA].Equal(lastCertain) { + t.Fatalf("LastHeardMap = %v; want %v", heard[chA], lastCertain) + } + + in := evInput() + in.Now = lastCertain.Add(73 * time.Hour) + in.Watches = []users.NotifyWatch{{UserID: evUser, Pubkey: chA}} + in.Nodes[chA] = notifyNode{Pubkey: chA, Name: "Silent", Role: "repeater", LastSeen: lastCertain.Add(-4 * time.Minute).Format(time.RFC3339)} + in.Heard = heard + if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, chA); got != users.NotifyBad { + t.Fatalf("node.offline = %q; want bad", got) + } +} + +func TestCertainHeardEvictedWithNode(t *testing.T) { + now := time.Now().UTC() + s := makeTestStore(4, now.Add(-48*time.Hour), 0) + for i, tx := range s.packets { + s.noteCertainHeard(fmt.Sprintf("pk%04d", i), tx) // makeTestStore's sender keys + } + if len(s.certainHeard) != 4 { + t.Fatalf("certainHeard = %d entries; want 4", len(s.certainHeard)) + } + s.retentionHours = 24 + s.EvictStale() + if len(s.certainHeard) != 0 { + t.Fatalf("certainHeard = %v after evicting every packet; want empty", s.certainHeard) + } +} + +func TestLastHeardValue(t *testing.T) { + heard := time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC) + cases := []struct { + name string + lastSeen interface{} + heard time.Time + want interface{} + }{ + {"certain newer than advert", "2026-10-19T08:00:00Z", heard, "2026-10-22T09:00:00.000Z"}, + {"advert newer", "2026-10-22T10:00:00Z", heard, "2026-10-22T10:00:00Z"}, + {"nothing certain", "2026-10-19T08:00:00Z", time.Time{}, "2026-10-19T08:00:00Z"}, + {"never advertised", nil, heard, "2026-10-22T09:00:00.000Z"}, + {"nothing at all", nil, time.Time{}, nil}, + } + for _, c := range cases { + if got := lastHeardValue(c.lastSeen, c.heard); got != c.want { + t.Errorf("%s: %v; want %v", c.name, got, c.want) + } + } +} + +// /api/nodes and the node detail report the certain last heard, not a copy +// of last_seen, and the health endpoint's lastHeard is the same value. +func TestNodeEndpointsLastHeardIsCertain(t *testing.T) { + srv, router := setupTestServer(t) + const pk = "aabbccdd11223344" + heardAt := time.Now().UTC().Add(time.Minute).Truncate(time.Second) + srv.store.mu.Lock() + srv.store.certainHeard = map[string]string{pk: heardAt.Format(time.RFC3339)} + srv.store.mu.Unlock() + + get := func(url string, out interface{}) { + t.Helper() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", url, nil)) + if w.Code != 200 { + t.Fatalf("%s: %d", url, w.Code) + } + if err := json.Unmarshal(w.Body.Bytes(), out); err != nil { + t.Fatal(err) + } + } + type node struct { + PublicKey string `json:"public_key"` + LastSeen string `json:"last_seen"` + LastHeard string `json:"last_heard"` + } + want := heardAt.Format("2006-01-02T15:04:05.000Z") + + var list struct { + Nodes []node `json:"nodes"` + } + get("/api/nodes?limit=50", &list) + found := false + for _, n := range list.Nodes { + if n.PublicKey == pk { + found = true + if n.LastHeard != want || n.LastSeen == want { + t.Errorf("list: last_heard %q last_seen %q; want last_heard %q", n.LastHeard, n.LastSeen, want) + } + } else if n.LastHeard != n.LastSeen { + t.Errorf("list %s: nothing certain, last_heard %q must be last_seen %q", n.PublicKey, n.LastHeard, n.LastSeen) + } + } + if !found { + t.Fatalf("node %s not listed", pk) + } + + var detail struct { + Node node `json:"node"` + } + get("/api/nodes/"+pk, &detail) + if detail.Node.LastHeard != want { + t.Errorf("detail last_heard %q; want %q", detail.Node.LastHeard, want) + } + + var health struct { + Stats struct { + LastHeard string `json:"lastHeard"` + } `json:"stats"` + } + get("/api/nodes/"+pk+"/health", &health) + if health.Stats.LastHeard != heardAt.Format(time.RFC3339) { + t.Errorf("health lastHeard %q; want %q", health.Stats.LastHeard, heardAt.Format(time.RFC3339)) + } +} + +// noteCertainRelays runs per observation on ingest, under the write lock. +// An 8-hop 2-byte flood path against a 2,000-repeater prefix map: two hex +// bytes of header parsing plus one map lookup per hop. Quote in the PR. +func BenchmarkNoteCertainRelays(b *testing.B) { + nodes := make([]nodeInfo, 0, 2000) + for i := 0; i < 2000; i++ { + nodes = append(nodes, nodeInfo{PublicKey: fmt.Sprintf("%064x", i*7919), Role: "repeater"}) + } + pm := buildPrefixMap(nodes) + pks := make([]string, 0, 8) + for i := 0; i < 8; i++ { + pks = append(pks, nodes[i*250].PublicKey) + } + s := chStore() + tx := chTx(1, 1, 5, 2, time.Date(2026, 10, 18, 16, 0, 0, 0, time.UTC)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.noteCertainRelays(tx, pks, pm) + } +} + +func TestIndexByNodeFailedSignatureIsNotCertain(t *testing.T) { + s := chStore() + at := time.Date(2026, 10, 18, 16, 45, 51, 0, time.UTC) + forged := chTx(1, 1, 4, 1, at) + forged.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"signatureValid":false}`, chA) + s.indexByNode(forged) + if _, ok := s.certainHeard[chA]; ok { + t.Fatal("an advert whose signature failed is no evidence") + } + if len(s.byNode[chA]) != 1 { + t.Fatal("it is still indexed for analytics") + } + valid := chTx(2, 1, 4, 1, at) + valid.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"signatureValid":true}`, chA) + s.indexByNode(valid) + if s.certainHeard[chA] != at.Format(time.RFC3339) { + t.Fatalf("a verified advert is evidence: %q", s.certainHeard[chA]) + } +} + +func TestNodeSearchLastHeardIsCertain(t *testing.T) { + srv, router := setupTestServer(t) + const pk = "aabbccdd11223344" + heardAt := time.Now().UTC().Add(time.Minute).Truncate(time.Second) + srv.store.mu.Lock() + srv.store.certainHeard = map[string]string{pk: heardAt.Format(time.RFC3339)} + srv.store.mu.Unlock() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/nodes/search?q=TestRepeater", nil)) + var body struct { + Nodes []struct { + PublicKey string `json:"public_key"` + LastHeard string `json:"last_heard"` + } `json:"nodes"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for _, n := range body.Nodes { + if n.PublicKey == pk { + if want := heardAt.Format("2006-01-02T15:04:05.000Z"); n.LastHeard != want { + t.Fatalf("search last_heard %q; want %q", n.LastHeard, want) + } + return + } + } + t.Fatalf("node not found in %s", w.Body.String()) +} + +// Review (#2261): a repeater moved to inactive_nodes by retention may still +// relay, so its prefix keeps a hop ambiguous although the nodes table has +// only one candidate. +func TestRetiredNamesakeKeepsHopUncertain(t *testing.T) { + db := setupTestDB(t) + if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil { + t.Fatal(err) + } + for _, q := range []string{ + `INSERT INTO inactive_nodes (public_key, role) VALUES ('40bb` + strings.Repeat("9", 60) + `', 'repeater')`, + `INSERT INTO inactive_nodes (public_key, role) VALUES ('6700` + strings.Repeat("8", 60) + `', 'companion')`, + } { + if _, err := db.conn.Exec(q); err != nil { + t.Fatal(err) + } + } + pm := chPM() + if err := pm.markRetired(db); err != nil { + t.Fatal(err) + } + if uniqueHopPrefix(chC, 2, pm) { + t.Error("1-byte hop 40 is shared with a retired repeater") + } + if !uniqueHopPrefix(chC, 4, pm) { + t.Error("2-byte hop 40aa is still only chC") + } + if _, ok := pm.retired["67"]; ok { + t.Error("a retired companion cannot relay; its prefix must not count") + } +} + +// Review (#2261): retention copies a row into inactive_nodes and nothing +// removes it when the node is heard again, so a node can be its own +// "retired namesake". That row must not take its certain relays away. +func TestReturnedNodeIsNotItsOwnRetiredNamesake(t *testing.T) { + db := setupTestDB(t) + if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil { + t.Fatal(err) + } + for _, q := range []string{ + `INSERT INTO nodes (public_key, role) VALUES ('` + chC + `', 'repeater')`, + `INSERT INTO inactive_nodes (public_key, role) VALUES ('` + strings.ToUpper(chC) + `', 'repeater')`, + } { + if _, err := db.conn.Exec(q); err != nil { + t.Fatal(err) + } + } + pm := chPM() + if err := pm.markRetired(db); err != nil { + t.Fatal(err) + } + if !uniqueHopPrefix(chC, 4, pm) { + t.Error("2-byte hop 40aa is only chC; its own stale inactive_nodes row must not count") + } +} + +// The owner of an "uncertain" repeater (#2260) gets the offline mail, worded +// so it says why: nothing certain, only its shared prefix still in paths. +func TestOfflineMailExplainsUncertain(t *testing.T) { + last := evNow.Add(-80 * time.Hour) + in := evInput() + in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Silent", Role: "repeater", LastSeen: last.Format(time.RFC3339)} + in.Heard[evPkA] = last + in.SharedSeen = map[string]time.Time{evPkA: evNow.Add(-4 * time.Minute)} + in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)} + ch := evaluateNotifications(in).Changes[evUser] + if len(ch) != 1 || ch[0].To != users.NotifyBad || !ch[0].SharedSeen.Equal(evNow.Add(-4*time.Minute)) { + t.Fatalf("changes = %+v; want one offline change carrying the shared-prefix time", ch) + } + txt := notifyChangeText(ch[0], "") + if !strings.Contains(txt, "offline (nothing that can only be this node") || !strings.Contains(txt, "possibly another node") { + t.Fatalf("mail line %q does not explain the uncertain state", txt) + } + + // A shared-prefix sighting older than the last certain packet adds nothing. + in.SharedSeen[evPkA] = last.Add(-time.Hour) + if ch := evaluateNotifications(in).Changes[evUser]; len(ch) != 1 || !ch[0].SharedSeen.IsZero() { + t.Fatalf("changes = %+v; want a plain offline change", ch) + } + if txt := notifyChangeText(evaluateNotifications(in).Changes[evUser][0], ""); !strings.HasSuffix(strings.SplitN(txt, ", ", 2)[0], "offline") { + t.Fatalf("plain offline line = %q", txt) + } +} diff --git a/cmd/server/chunked_load.go b/cmd/server/chunked_load.go index db9313a54..a37242c97 100644 --- a/cmd/server/chunked_load.go +++ b/cmd/server/chunked_load.go @@ -635,7 +635,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold rpStr := nullStrVal(resolvedPathStr) if rpStr != "" { pks := resolvedPubkeysFromJSON(rpStr) - s.indexResolvedPathHops(tx, pks, hopsSeen) + s.indexResolvedPathHops(tx, pks, hopsSeen, relayPM) } else if relayPM != nil && obsPJ != "" && obsPJ != "[]" { // resolved_path is NULL on live (since #1287 relay data is // persisted as neighbor_edges, not per-observation). Re-resolve @@ -647,9 +647,7 @@ func (s *PacketStore) scanAndMergeChunk(rows *sql.Rows, relayPM *prefixMap, cold // resolved_path/path-hop indexes must NOT be populated here. // PR #1643 R1 munger #1: unique_prefix-only gate. rp := resolvePathForObsColdLoad(obsPJ, obsIDStr, tx, relayPM, coldLoadAmbiguousHopsSkipped) - for _, pk := range extractResolvedPubkeys(rp) { - s.addToByNode(tx, pk) - } + s.indexColdLoadRelayHops(tx, extractResolvedPubkeys(rp), relayPM) } tx.addObservation(obs) diff --git a/cmd/server/notify_eval.go b/cmd/server/notify_eval.go index d5c9aa4df..58f00578c 100644 --- a/cmd/server/notify_eval.go +++ b/cmd/server/notify_eval.go @@ -33,17 +33,20 @@ type notifyObserver struct { // notifyInput is everything one evaluation reads. type notifyInput struct { - Now time.Time - Health HealthThresholds // resolved, as Config.GetHealthThresholds returns it - LowMv int // Config.LowBatteryMv - Accounts map[int64]users.User - Prefs []users.NotifyPrefs - Watches []users.NotifyWatch - States []users.NotifyState - Nodes map[string]notifyNode // watched nodes, plus every foreign node when an admin chose foreign.new - Heard map[string]time.Time // newest packet involving the node in the packet store (node page "Last Heard") - Relayed map[string]time.Time // last relay hop, used for repeaters and rooms only (#1598) - Observers []notifyObserver // nil unless an admin chose observer.offline + Now time.Time + Health HealthThresholds // resolved, as Config.GetHealthThresholds returns it + LowMv int // Config.LowBatteryMv + Accounts map[int64]users.User + Prefs []users.NotifyPrefs + Watches []users.NotifyWatch + States []users.NotifyState + Nodes map[string]notifyNode // watched nodes, plus every foreign node when an admin chose foreign.new + Heard map[string]time.Time // newest packet certainly from the node (node page "Last Heard", #2260) + // SharedSeen is, per repeater and room, when its hop prefix (shared with + // other repeaters, last_relayed) was last in a path. Not evidence: it + // only explains an offline mail for a node that is "uncertain" in the UI. + SharedSeen map[string]time.Time + Observers []notifyObserver // nil unless an admin chose observer.offline // IngestStale: the newest packet in the store is old, so a silent node // and a silent feed look the same. node.offline and observer.offline // are then not compared (states unchanged, no changes). @@ -66,9 +69,12 @@ type notifyChange struct { Name string // display name at evaluation time To string // users.NotifyGood, users.NotifyBad or users.NotifyTold BatteryMv *int // node.battery only - Text string // node.external only: the alert text ("" when resolved) - URL string // node.external only: the alert's link, "" for the node page - At time.Time + // SharedSeen: node.offline only, when the node's shared hop prefix was + // still in a path after its last certain packet (zero otherwise). + SharedSeen time.Time + Text string // node.external only: the alert text ("" when resolved) + URL string // node.external only: the alert's link, "" for the node page + At time.Time } // notifyResult: Changes per user, ordered by event then name; States are @@ -95,13 +101,6 @@ var notifyEventRank = map[string]int{ users.NotifyForeignNew: 3, users.NotifyObserverOffline: 4, } -// notifyInfra reports the roles whose relay activity counts as heard -// (GetHealthMs and roles.js use the same two). -func notifyInfra(role string) bool { - r := strings.ToLower(role) - return r == "repeater" || r == "room" -} - // nodeLabel is the node's name made safe for a mail line (mailSafeText), // or the first 8 hex characters of its key. func nodeLabel(n notifyNode, known bool, pk string) string { @@ -115,9 +114,11 @@ func nodeLabel(n notifyNode, known bool, pk string) string { } // nodeOnlineState is good when the node was heard within its role's silent -// window, like roles.js getNodeStatus: the latest of last_seen, the packet -// store's last heard and, for repeaters and rooms, the last relay hop. A -// node missing from the analyzer database is bad. While prev is good, the +// window, like roles.js getNodeStatus: the latest of last_seen and the packet +// store's certain last heard (its own packets and relay hops that can only be +// this node, #2260). last_relayed is not evidence: it also counts 1-byte +// prefix matches shared with other repeaters, which kept a silent repeater +// "online" for days. A node missing from the analyzer database is bad. While prev is good, the // latest evidence is never older than in.IngestResumedAt (the outage grace). func nodeOnlineState(pk string, n notifyNode, known bool, prev string, in *notifyInput) string { if !known { @@ -127,11 +128,6 @@ func nodeOnlineState(pk string, n notifyNode, known bool, prev string, in *notif if t, ok := in.Heard[pk]; ok && t.After(last) { last = t } - if notifyInfra(n.Role) { - if t, ok := in.Relayed[pk]; ok && t.After(last) { - last = t - } - } if prev == users.NotifyGood && last.Before(in.IngestResumedAt) { last = in.IngestResumedAt } @@ -298,7 +294,14 @@ func evaluateNotifications(in notifyInput) notifyResult { name := nodeLabel(n, known, pk) if p.Has(users.NotifyNodeOffline) && !in.IngestStale { k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeOffline, Subject: pk} - compare(k, name, nodeOnlineState(pk, n, known, prev[k], &in), nil) + state := nodeOnlineState(pk, n, known, prev[k], &in) + before := len(res.Changes[p.UserID]) + compare(k, name, state, nil) + if state == users.NotifyBad && len(res.Changes[p.UserID]) > before { + if seen, ok := in.SharedSeen[pk]; ok && seen.After(in.Heard[pk]) { + res.Changes[p.UserID][before].SharedSeen = seen + } + } } if p.Has(users.NotifyNodeBattery) && known && n.BatteryMv != nil { k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeBattery, Subject: pk} diff --git a/cmd/server/notify_eval_test.go b/cmd/server/notify_eval_test.go index b3704e353..d73a3dbb8 100644 --- a/cmd/server/notify_eval_test.go +++ b/cmd/server/notify_eval_test.go @@ -41,7 +41,6 @@ func evInput() notifyInput { Watches: []users.NotifyWatch{{UserID: evUser, Pubkey: evPkA}}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}, - Relayed: map[string]time.Time{}, } } @@ -110,24 +109,29 @@ func TestEvaluateOfflineAndBack(t *testing.T) { } } -func TestEvaluateRelayAwareInfra(t *testing.T) { +// Liveness is last_seen or the store's certain last heard (#2260): a +// repeater whose advert is days old but that certainly relayed an hour ago is +// online; one with nothing certain inside the silent window is offline. There +// is no separate relay input any more: last_relayed also counts 1-byte prefix +// matches of other repeaters and kept a silent repeater online for days. +func TestEvaluateCertainHeard(t *testing.T) { cases := []struct { name, role, lastSeen string - relayed time.Duration + heard time.Duration want string }{ - {"repeater relaying", "repeater", evAgo(80 * time.Hour), time.Hour, users.NotifyGood}, - {"room relaying, role case", "Room", evAgo(80 * time.Hour), time.Hour, users.NotifyGood}, + {"repeater certainly relaying", "repeater", evAgo(80 * time.Hour), time.Hour, users.NotifyGood}, + {"room certainly relaying, role case", "Room", evAgo(80 * time.Hour), time.Hour, users.NotifyGood}, {"repeater silent exactly 72 h", "repeater", evAgo(72 * time.Hour), 0, users.NotifyBad}, {"repeater 71 h", "repeater", evAgo(71 * time.Hour), 0, users.NotifyGood}, - {"companion: relay time ignored", "companion", evAgo(30 * time.Hour), time.Hour, users.NotifyBad}, + {"repeater last certain packet 73 h ago", "repeater", evAgo(80 * time.Hour), 73 * time.Hour, users.NotifyBad}, {"no timestamp at all", "repeater", "", 0, users.NotifyBad}, } for _, c := range cases { in := evInput() in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: c.role, LastSeen: c.lastSeen} - if c.relayed > 0 { - in.Relayed[evPkA] = evNow.Add(-c.relayed) + if c.heard > 0 { + in.Heard[evPkA] = evNow.Add(-c.heard) } if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, evPkA); got != c.want { t.Errorf("%s: %q; want %q", c.name, got, c.want) @@ -306,7 +310,7 @@ func TestEvaluateChangesAreOrderedPerUser(t *testing.T) { // in the PR. func BenchmarkEvaluateNotifications(b *testing.B) { in := notifyInput{Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300, - Accounts: map[int64]users.User{}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}, Relayed: map[string]time.Time{}} + Accounts: map[int64]users.User{}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}} for i := 0; i < 2000; i++ { pk := fmt.Sprintf("%064x", i) in.Nodes[pk] = notifyNode{Pubkey: pk, Role: "repeater", LastSeen: evAgo(time.Duration(i) * time.Minute), BatteryMv: evMv(3200 + i%300)} diff --git a/cmd/server/notify_service.go b/cmd/server/notify_service.go index 211de6203..b33031eef 100644 --- a/cmd/server/notify_service.go +++ b/cmd/server/notify_service.go @@ -190,12 +190,6 @@ func (n *notifier) tick(ctx context.Context) { log.Printf("[notify] read nodes: %v", err) return } - var infra []string - for _, pk := range pubkeys { - if nd, ok := nodes[pk]; ok && notifyInfra(nd.Role) { - infra = append(infra, pk) - } - } var observers []notifyObserver if wantObservers { if observers, err = n.src.observers(); err != nil { @@ -208,9 +202,15 @@ func (n *notifier) tick(ctx context.Context) { external = n.readExternal(now) } heard, lock := n.src.lastHeard(pubkeys) + var infra []string + for _, pk := range pubkeys { + if r := strings.ToLower(nodes[pk].Role); r == "repeater" || r == "room" { + infra = append(infra, pk) + } + } res := evaluateNotifications(notifyInput{Now: now, Health: n.src.health(), LowMv: n.src.lowBatteryMv(), Accounts: accounts, Prefs: prefs, Watches: watches, States: states, Nodes: nodes, - Heard: heard, Relayed: n.src.lastRelayed(infra), Observers: observers, IngestStale: stale, IngestResumedAt: n.ingestResumedAt, External: external}) + Heard: heard, SharedSeen: n.src.sharedPrefixSeen(infra), Observers: observers, IngestStale: stale, IngestResumedAt: n.ingestResumedAt, External: external}) if err := st.DeleteNotifyStates(res.Drop); err != nil { log.Printf("[notify] delete dropped states, nothing mailed this time: %v", err) return @@ -369,6 +369,11 @@ func notifyChangeText(c notifyChange, label string) string { what = "back online" if bad { what = "offline" + if !c.SharedSeen.IsZero() { + // #2260: the UI shows such a node as "uncertain". + what += " (nothing that can only be this node; its hop prefix, shared with other repeaters, was last in a path " + + c.SharedSeen.UTC().Format("2006-01-02 15:04 UTC") + ", possibly another node)" + } } case users.NotifyNodeBattery: what = "battery recovered" diff --git a/cmd/server/notify_service_test.go b/cmd/server/notify_service_test.go index 884876c88..bab0d543c 100644 --- a/cmd/server/notify_service_test.go +++ b/cmd/server/notify_service_test.go @@ -20,7 +20,7 @@ type fakeNotifySource struct { low int nodeMap map[string]notifyNode heard map[string]time.Time - relayed map[string]time.Time + shared map[string]time.Time obs []notifyObserver newestAt func() time.Time // newest packet in the store; nil means none lock time.Duration // what lastHeard reports as its lock time @@ -57,11 +57,11 @@ func (f *fakeNotifySource) nodes(pubkeys []string, withForeign bool) (map[string } return out, nil } +func (f *fakeNotifySource) sharedPrefixSeen([]string) map[string]time.Time { return f.shared } func (f *fakeNotifySource) lastHeard(pks []string) (map[string]time.Time, time.Duration) { f.heardAsked = append([]string(nil), pks...) return f.heard, f.lock } -func (f *fakeNotifySource) lastRelayed([]string) map[string]time.Time { return f.relayed } func (f *fakeNotifySource) newestPacket() time.Time { if f.newestAt == nil { return time.Time{} @@ -103,7 +103,7 @@ func newNotifyFixture(t *testing.T, ns notifySettings) *notifyFixture { clk := ¬ifyClock{t: time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)} a.st.SetClock(clk.Now) src := &fakeNotifySource{isReady: true, hs: (&Config{}).GetHealthThresholds(), low: 3300, - nodeMap: map[string]notifyNode{}, heard: map[string]time.Time{}, relayed: map[string]time.Time{}, newestAt: clk.Now} + nodeMap: map[string]notifyNode{}, heard: map[string]time.Time{}, newestAt: clk.Now} if ns.enabled { a.notify = newNotifier(a, src, clk.Now) } diff --git a/cmd/server/notify_source.go b/cmd/server/notify_source.go index f95f8011c..93b8dbb0f 100644 --- a/cmd/server/notify_source.go +++ b/cmd/server/notify_source.go @@ -15,7 +15,7 @@ type notifySource interface { lowBatteryMv() int nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) lastHeard(pubkeys []string) (map[string]time.Time, time.Duration) // and how long the store lock was held - lastRelayed(pubkeys []string) map[string]time.Time + sharedPrefixSeen(pubkeys []string) map[string]time.Time // last_relayed, for the mail text only observers() ([]notifyObserver, error) newestPacket() time.Time // zero when the store holds no packet externalFeed() ([]byte, error) // the externalAlerts feed body (#2177); only called when configured @@ -53,13 +53,23 @@ func (src serverNotifySource) lastHeard(pubkeys []string) (map[string]time.Time, return src.s.store.LastHeardMap(pubkeys) } -// lastRelayed reads the cached relay metrics (the map /api/nodes uses; the -// background recomputer refreshes it). Pass repeaters and rooms only. -func (src serverNotifySource) lastRelayed(pubkeys []string) map[string]time.Time { +// sharedPrefixSeen reads last_relayed from the cached relay metrics (the map +// /api/nodes uses). It counts 1-byte prefixes shared with other repeaters, +// so it only words the offline mail, never decides it (#2260). +func (src serverNotifySource) sharedPrefixSeen(pubkeys []string) map[string]time.Time { + out := map[string]time.Time{} if src.s.store == nil || len(pubkeys) == 0 { - return map[string]time.Time{} + return out } - return relayTimes(src.s.store.GetRepeaterRelayInfoMap(src.s.cfg.GetHealthThresholds().RelayActiveHours), pubkeys) + m := src.s.store.GetRepeaterRelayInfoMap(src.s.cfg.GetHealthThresholds().RelayActiveHours) + for _, pk := range pubkeys { + if info, ok := lookupRelayInfo(m, pk); ok { + if t, ok := parseRelayTS(info.LastRelayed); ok { + out[pk] = t + } + } + } + return out } func (src serverNotifySource) newestPacket() time.Time { @@ -83,22 +93,6 @@ func (src serverNotifySource) observers() ([]notifyObserver, error) { return notifyObserversFrom(list), nil } -// relayTimes parses LastRelayed for the given keys; keys without a relay -// time are absent. -func relayTimes(m map[string]RepeaterRelayInfo, pubkeys []string) map[string]time.Time { - out := make(map[string]time.Time, len(pubkeys)) - for _, pk := range pubkeys { - info, ok := lookupRelayInfo(m, pk) - if !ok || info.LastRelayed == "" { - continue - } - if t, ok := parseRelayTS(info.LastRelayed); ok { - out[pk] = t - } - } - return out -} - func notifyObserversFrom(list []Observer) []notifyObserver { out := make([]notifyObserver, 0, len(list)) for _, o := range list { @@ -171,20 +165,18 @@ func (db *DB) NotifyNodes(pubkeys []string, withForeign bool) (map[string]notify return out, nil } -// LastHeardMap returns, per pubkey with packets in the store, the newest -// FirstSeen among them: the node page's "Last Heard" (GetNodeHealth) in -// bulk, and how long the read lock was held (logged once per tick). One -// read lock, O(packets of the given nodes), string compares only; parsing +// LastHeardMap returns, per pubkey, the newest FirstSeen of a transmission +// certainly from that node (certainHeard, #2260): the node page's "Last +// Heard" (GetNodeHealth) in bulk, and how long the read lock was held +// (logged once per tick). One read lock, one map lookup per pubkey; parsing // happens after the lock is released. func (s *PacketStore) LastHeardMap(pubkeys []string) (map[string]time.Time, time.Duration) { latest := make(map[string]string, len(pubkeys)) s.mu.RLock() locked := time.Now() for _, pk := range pubkeys { - for _, tx := range s.byNode[pk] { - if tx != nil && tx.FirstSeen > latest[pk] { - latest[pk] = tx.FirstSeen - } + if ts := s.certainHeard[pk]; ts != "" { + latest[pk] = ts } } held := time.Since(locked) diff --git a/cmd/server/notify_source_test.go b/cmd/server/notify_source_test.go index 1d5f025a5..3c62f421d 100644 --- a/cmd/server/notify_source_test.go +++ b/cmd/server/notify_source_test.go @@ -56,9 +56,9 @@ func TestDBNotifyNodesChunksLongLists(t *testing.T) { } func TestPacketStoreLastHeardMap(t *testing.T) { - s := &PacketStore{byNode: map[string][]*StoreTx{ - evPkA: {{FirstSeen: "2026-10-07T10:00:00Z"}, {FirstSeen: "2026-10-07T11:30:00.000Z"}, nil, {FirstSeen: "2026-10-07T09:00:00Z"}}, - evPkB: {{FirstSeen: "not a time"}}, + s := &PacketStore{certainHeard: map[string]string{ + evPkA: "2026-10-07T11:30:00.000Z", + evPkB: "not a time", }} got, lock := s.LastHeardMap([]string{evPkA, evPkB, evPkF}) if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 30, 0, 0, time.UTC)) { @@ -69,17 +69,15 @@ func TestPacketStoreLastHeardMap(t *testing.T) { } } -// 5,000 watched nodes with 20 packets each, the spec's upper figure. Quote -// the result in the PR: this runs under the store's read lock. +// 5,000 watched nodes, the spec's upper figure. Quote the result in the PR: +// this runs under the store's read lock (one map lookup per node, #2260). func BenchmarkLastHeardMap(b *testing.B) { - s := &PacketStore{byNode: map[string][]*StoreTx{}} + s := &PacketStore{certainHeard: map[string]string{}} keys := make([]string, 0, 5000) for i := 0; i < 5000; i++ { pk := fmt.Sprintf("%064x", i) keys = append(keys, pk) - for j := 0; j < 20; j++ { - s.byNode[pk] = append(s.byNode[pk], &StoreTx{FirstSeen: evNow.Add(-time.Duration(j) * time.Minute).Format(time.RFC3339)}) - } + s.certainHeard[pk] = evNow.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339) } b.ResetTimer() for i := 0; i < b.N; i++ { @@ -87,17 +85,6 @@ func BenchmarkLastHeardMap(b *testing.B) { } } -func TestRelayTimes(t *testing.T) { - m := map[string]RepeaterRelayInfo{ - evPkA: {LastRelayed: "2026-10-07T11:00:00Z"}, - evPkB: {}, - } - got := relayTimes(m, []string{evPkA, evPkB, evPkF}) - if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 0, 0, 0, time.UTC)) { - t.Fatalf("relayTimes = %v", got) - } -} - func TestNotifyObserversFrom(t *testing.T) { name, seen := "Roof", "2026-10-07T11:00:00Z" got := notifyObserversFrom([]Observer{{ID: "OBS1", Name: &name, LastSeen: &seen}, {ID: "OBS2"}}) @@ -108,7 +95,7 @@ func TestNotifyObserversFrom(t *testing.T) { func TestServerNotifySourceWithoutStore(t *testing.T) { src := serverNotifySource{s: &Server{cfg: &Config{}}} - if heard, lock := src.lastHeard([]string{evPkA}); len(heard) != 0 || lock != 0 || len(src.lastRelayed([]string{evPkA})) != 0 { + if heard, lock := src.lastHeard([]string{evPkA}); len(heard) != 0 || lock != 0 { t.Fatal("a server without a packet store reported times") } if src.lowBatteryMv() != 3300 || src.health().InfraSilentHours != 72 { diff --git a/cmd/server/pathhop_dedupe_2108_bench_test.go b/cmd/server/pathhop_dedupe_2108_bench_test.go index bd53037aa..bbffc5513 100644 --- a/cmd/server/pathhop_dedupe_2108_bench_test.go +++ b/cmd/server/pathhop_dedupe_2108_bench_test.go @@ -39,7 +39,7 @@ func phdBenchStore(n int) (store *PacketStore, resolve func(j int) []string) { store.byTxID[tx.ID] = tx store.byPayloadType[pt] = append(store.byPayloadType[pt], tx) addTxToPathHopIndex(store.byPathHop, tx) - store.indexResolvedPathHops(tx, resolve(i), hopsSeen) + store.indexResolvedPathHops(tx, resolve(i), hopsSeen, nil) } return store, resolve } @@ -78,7 +78,7 @@ func BenchmarkLateObservationIndex_2108(b *testing.B) { b.StartTimer() } store.mu.Lock() - store.indexResolvedPathHops(store.packets[j], pks[i%batch], hopsSeen) + store.indexResolvedPathHops(store.packets[j], pks[i%batch], hopsSeen, nil) store.mu.Unlock() } b.StopTimer() @@ -142,7 +142,7 @@ func BenchmarkTrafficShareScoreMap_2108(b *testing.B) { hopsSeen := map[string]bool{} for o := 1; o < c.obs; o++ { for j, tx := range store.packets { - store.indexResolvedPathHops(tx, resolve(j), hopsSeen) + store.indexResolvedPathHops(tx, resolve(j), hopsSeen, nil) } } if c.reversed { diff --git a/cmd/server/pathhop_eviction_1908_test.go b/cmd/server/pathhop_eviction_1908_test.go index a10bfd439..5a1060ce4 100644 --- a/cmd/server/pathhop_eviction_1908_test.go +++ b/cmd/server/pathhop_eviction_1908_test.go @@ -42,7 +42,7 @@ func TestPathHopEviction1908(t *testing.T) { tx.ScopeName = &expiredScope } addTxToPathHopIndex(store.byPathHop, tx) - store.indexResolvedPathHops(tx, []string{shared, pk}, hopsSeen) + store.indexResolvedPathHops(tx, []string{shared, pk}, hopsSeen, nil) } // Repeated observations no longer append the same resolved // association again (#2108), but eviction must still drop every diff --git a/cmd/server/pathhop_record_2108_test.go b/cmd/server/pathhop_record_2108_test.go index b10e549f7..5e5049b5b 100644 --- a/cmd/server/pathhop_record_2108_test.go +++ b/cmd/server/pathhop_record_2108_test.go @@ -77,7 +77,7 @@ func phdRecordStore(count int) (store *PacketStore, old, young []*StoreTx) { young = append(young, tx) } addTxToPathHopIndex(store.byPathHop, tx) - store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen) + store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen, nil) } return store, old, young } @@ -107,7 +107,7 @@ func TestPathHopResolvedRecordBoundedAndEvicted_2108(t *testing.T) { hopsSeen := map[string]bool{} for _, tx := range store.packets { for k := 0; k < 10; k++ { - store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen) + store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen, nil) } } if got := len(store.pathHopResolved); got != len(old)+len(young) { @@ -141,7 +141,7 @@ func TestPathHopResolvedRecordBoundedAndEvicted_2108(t *testing.T) { } // A survivor's next observation still adds nothing. for _, tx := range young { - store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen) + store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen, nil) if n := phdEntriesOf(store, tx); n != phdRecordRefsPerTx { t.Fatalf("tx %d has %d entries after eviction and another observation, want %d", tx.ID, n, phdRecordRefsPerTx) } @@ -167,7 +167,7 @@ func TestPathHopResolvedRecordAcrossRebuild_2108(t *testing.T) { store, _, young := phdRecordStore(40) gone := store.packets[0] goneOnly := strings.Repeat("e", 64) // a relay only the removed transmission went through - store.indexResolvedPathHops(gone, []string{goneOnly}, map[string]bool{}) + store.indexResolvedPathHops(gone, []string{goneOnly}, map[string]bool{}, nil) store.packets = store.packets[1:] delete(store.byTxID, gone.ID) delete(store.byHash, gone.Hash) @@ -190,7 +190,7 @@ func TestPathHopResolvedRecordAcrossRebuild_2108(t *testing.T) { if _, ok := store.pathHopResolved[tx]; !ok { t.Fatalf("rebuild dropped the record of live tx %d", tx.ID) } - store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen) + store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen, nil) if n := phdEntriesOf(store, tx); n != phdRecordRefsPerTx { t.Fatalf("tx %d has %d entries after a rebuild and another observation, want %d", tx.ID, n, phdRecordRefsPerTx) } @@ -216,7 +216,7 @@ func TestPathHopResolvedRecordClearedWithEmptyIndex_2108(t *testing.T) { } hopsSeen := map[string]bool{} for _, tx := range young { - store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen) + store.indexResolvedPathHops(tx, phdFreshKeys(), hopsSeen, nil) if n := phdEntriesOf(store, tx); n != phdRecordRefsPerTx { t.Fatalf("tx %d has %d entries after the rebuild and another observation, want %d", tx.ID, n, phdRecordRefsPerTx) } @@ -278,7 +278,7 @@ func BenchmarkPathHopRecordMemory_2108(b *testing.B) { r := (i*31 + j*997) % relays pks[j] = fmt.Sprintf("%02x%062x", r%256, r) } - store.indexResolvedPathHops(tx, pks, hopsSeen) + store.indexResolvedPathHops(tx, pks, hopsSeen, nil) } } with := heap() diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 13bd5016d..c17a871fe 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1634,8 +1634,18 @@ func (s *Server) buildNodesResponse(r *http.Request) (NodeListResponse, error) { maxUseful := maxFloat(usefulMap) // #1879: which rule places each node for ?region=. Atomic snapshot. placements := s.store.GetNodeRegionPlacements() + // #2260: last_heard is the newer of last_seen and the certain last + // heard. One read lock and a map lookup per node. + pageKeys := make([]string, 0, len(nodes)) for _, node := range nodes { if pk, ok := node["public_key"].(string); ok { + pageKeys = append(pageKeys, pk) + } + } + heard, _ := s.store.LastHeardMap(pageKeys) + for _, node := range nodes { + if pk, ok := node["public_key"].(string); ok { + node["last_heard"] = lastHeardValue(node["last_seen"], heard[pk]) EnrichNodeWithHashSize(node, hashInfo[pk]) mbEntry, _ := s.store.GetMultibyteCapFor(pk) EnrichNodeWithMultiByte(node, mbEntry) @@ -1771,6 +1781,21 @@ func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + // #2260: same last_heard as /api/nodes, not a copy of last_seen. + if s.store != nil && len(nodes) > 0 { + keys := make([]string, 0, len(nodes)) + for _, node := range nodes { + if pk, ok := node["public_key"].(string); ok { + keys = append(keys, pk) + } + } + heard, _ := s.store.LastHeardMap(keys) + for _, node := range nodes { + if pk, ok := node["public_key"].(string); ok { + node["last_heard"] = lastHeardValue(node["last_seen"], heard[pk]) + } + } + } // Filter blacklisted nodes from search results if len(s.cfg.NodeBlacklist) > 0 { filtered := make([]map[string]interface{}, 0, len(nodes)) @@ -1847,6 +1872,8 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { mbEntry, _ := s.store.GetMultibyteCapFor(pubkey) EnrichNodeWithMultiByte(node, mbEntry) node["region_placement"] = RegionPlacementFor(s.store.GetNodeRegionPlacements(), pubkey) + heard, _ := s.store.LastHeardMap([]string{pubkey}) + node["last_heard"] = lastHeardValue(node["last_seen"], heard[pubkey]) if role, _ := node["role"].(string); role == "repeater" || role == "room" { ht := s.cfg.GetHealthThresholds() info := s.store.GetRepeaterRelayInfo(pubkey, ht.RelayActiveHours) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index bac94ed0a..b33b92224 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -820,21 +820,15 @@ func TestNodeHealthNotFound(t *testing.T) { func TestNodeHealthPartialFromPackets(t *testing.T) { srv, router := setupTestServer(t) - // Inject a packet into byNode for a pubkey that doesn't exist in the nodes table + // Index the companion's own packet (srcPubKey) for a pubkey that doesn't + // exist in the nodes table. Going through indexByNode also records it as + // certain evidence, which is what stats.lastHeard reports (#2260). ghostPubkey := "ghost_companion_no_advert" now := time.Now().UTC().Format(time.RFC3339) snr := 5.0 srv.store.mu.Lock() - if srv.store.byNode == nil { - srv.store.byNode = make(map[string][]*StoreTx) - } - if srv.store.nodeHashes == nil { - srv.store.nodeHashes = newNodeSets() - } - srv.store.byNode[ghostPubkey] = []*StoreTx{ - {Hash: "abc123", FirstSeen: now, SNR: &snr, ObservationCount: 1}, - } - srv.store.nodeHashes[ghostPubkey] = map[int]struct{}{0: {}} + srv.store.indexByNode(&StoreTx{ID: 999999, Hash: "abc123", FirstSeen: now, SNR: &snr, ObservationCount: 1, + DecodedJSON: `{"srcPubKey":"` + ghostPubkey + `"}`}) srv.store.mu.Unlock() req := httptest.NewRequest("GET", "/api/nodes/"+ghostPubkey+"/health", nil) diff --git a/cmd/server/store.go b/cmd/server/store.go index 80cb8156e..68a61a3eb 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -157,10 +157,11 @@ func parseObsTimestamp(s string) (time.Time, bool) { // decodedCacheDroppedKeys are payload fields no server code reads: the // ciphertext and MAC of encrypted payloads, advert signatures, the ISO copy -// of a timestamp, raw path data and control-packet details. +// of a timestamp, raw path data and control-packet details. signatureValid +// stays: liveness ignores adverts whose signature failed (#2260). // TestDecodedCacheDroppedKeysAreUnread fails if code starts naming one. var decodedCacheDroppedKeys = []string{ - "encryptedData", "mac", "signature", "signatureValid", "timestampISO", "pathData", + "encryptedData", "mac", "signature", "timestampISO", "pathData", "ctrlPubKey", "ctrlSubtype", "ctrlTag", "ctrlFlags", "ctrlZeroHop", "ctrlLength", } @@ -256,6 +257,7 @@ type PacketStore struct { byObserver map[string][]*StoreObs // observer_id → observations byNode map[string][]*StoreTx // pubkey → transmissions nodeHashes map[string]map[int]struct{} // pubkey → set of tx IDs listed in byNode[pubkey] + certainHeard map[string]string // pubkey → newest FirstSeen certainly from that node (certain_heard.go) byPathHop map[string][]*StoreTx // lowercase hop/pubkey → transmissions with that hop in path byPayloadType map[int][]*StoreTx // payload_type → transmissions loaded bool @@ -1095,7 +1097,7 @@ func (s *PacketStore) Load() error { if rpStr != "" { pks := resolvedPubkeysFromJSON(rpStr) // Single point of truth — see indexResolvedPathHops doc + #1558. - s.indexResolvedPathHops(tx, pks, hopsSeen) + s.indexResolvedPathHops(tx, pks, hopsSeen, relayPM) } else if relayPM != nil && obsPJ != "" && obsPJ != "[]" { // resolved_path not persisted — reconstruct relay hops from // path_json so relay-node analytics history survives a restart. @@ -1108,9 +1110,7 @@ func (s *PacketStore) Load() error { // PR #1643 R1 munger #1: gate on unique_prefix only — ambiguous // hops are silently dropped (skipped counter logged at end). rp := resolvePathForObsColdLoad(obsPJ, obsIDStr, tx, relayPM, &coldLoadAmbiguousHopsSkipped) - for _, pk := range extractResolvedPubkeys(rp) { - s.addToByNode(tx, pk) - } + s.indexColdLoadRelayHops(tx, extractResolvedPubkeys(rp), relayPM) } tx.addObservation(obs) @@ -1587,14 +1587,12 @@ func (s *PacketStore) loadChunk(from, to time.Time) error { // stats collapse after restart for relay-heavy nodes. The // pubkey union was pre-built outside the lock above. if pks := localResolvedPKsByTx[tx.ID]; len(pks) > 0 { - s.indexResolvedPathHops(tx, pks, hopsSeen) + s.indexResolvedPathHops(tx, pks, hopsSeen, relayPM) } // path_json fallback (resolved_path NULL): byNode ONLY, so relay // nodes keep their analytics history across a restart without // polluting the resolved_path/path-hop indexes (#1352). - for _, pk := range localByNodePKsByTx[tx.ID] { - s.addToByNode(tx, pk) - } + s.indexColdLoadRelayHops(tx, localByNodePKsByTx[tx.ID], relayPM) } s.mu.Unlock() runtime.Gosched() @@ -1975,17 +1973,32 @@ func skipPlainJSONString(s string, i int) (int, bool) { // idempotent per (transmission, pubkey) — byNode via nodeHashes, the // resolved index via its forward-list check, byPathHop via // pathHopResolved (#2108). -func (s *PacketStore) indexResolvedPathHops(tx *StoreTx, pks []string, hopsSeen map[string]bool) { +// - pm decides which hops are certain liveness evidence (#2260, +// noteCertainRelays); nil records none. +func (s *PacketStore) indexResolvedPathHops(tx *StoreTx, pks []string, hopsSeen map[string]bool, pm *prefixMap) { if len(pks) == 0 { return } for _, pk := range pks { s.addToByNode(tx, pk) } + s.noteCertainRelays(tx, pks, pm) s.addResolvedPubkeysToPathHopIndex(tx, pks, hopsSeen) s.addToResolvedPubkeyIndex(tx.ID, pks) } +// indexColdLoadRelayHops is the byNode-only counterpart of +// indexResolvedPathHops for relay hops re-resolved from path_json at cold +// load (resolved_path NULL, #1352): the path-hop and resolved indexes stay +// untouched, liveness still learns from the certain hops. Caller holds s.mu +// for writing. +func (s *PacketStore) indexColdLoadRelayHops(tx *StoreTx, pks []string, pm *prefixMap) { + for _, pk := range pks { + s.addToByNode(tx, pk) + } + s.noteCertainRelays(tx, pks, pm) +} + // indexByNode extracts pubkeys from decoded_json and indexes the transmission. // indexByNode indexes a transmission under all pubkeys found in its decoded // JSON. Resolved path pubkeys are handled separately via the decode-window. @@ -2004,6 +2017,12 @@ func (s *PacketStore) indexByNode(tx *StoreTx) bool { foundNew = true } indexed[v] = true + // The sender's own packet is certain evidence (#2260); + // a destination is not, the node need not be alive, and + // neither is an advert whose signature failed. + if field != "destPubKey" && !tx.signatureFailed() { + s.noteCertainHeard(v, tx) + } } } } @@ -3210,7 +3229,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac rpForBroadcast = resolvePathForObs(r.pathJSON, r.observerID, tx, cachedPM, cachedGraph) resolvedPubkeys = extractResolvedPubkeys(rpForBroadcast) // Single point of truth — see indexResolvedPathHops doc + #1558. - s.indexResolvedPathHops(tx, resolvedPubkeys, hopsSeen) + s.indexResolvedPathHops(tx, resolvedPubkeys, hopsSeen, cachedPM) } // Stash rpForBroadcast for later broadcast/persist (keyed by obs ID) if rpForBroadcast != nil { @@ -3524,7 +3543,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string] obsResolvedPath = resolvePathForObs(r.pathJSON, r.observerID, tx, pm, graphRef) pks := extractResolvedPubkeys(obsResolvedPath) // Single point of truth — see indexResolvedPathHops doc + #1558. - s.indexResolvedPathHops(tx, pks, hopsSeen) + s.indexResolvedPathHops(tx, pks, hopsSeen, pm) } } // Stash for broadcast/persist @@ -5438,6 +5457,7 @@ func (s *PacketStore) evictStaleInternal(rpBatch map[int][]string) int { } if len(filtered) == 0 { delete(s.byNode, nodeKey) + delete(s.certainHeard, nodeKey) } else { s.byNode[nodeKey] = filtered } @@ -7293,6 +7313,11 @@ type prefixMap struct { // candidates without them; markNonRelay builds it so relayCandidates, // called per hop of every observation resolved, never copies. relay map[string][]nodeInfo + // retired holds the prefixes of path-capable nodes that retention moved + // to inactive_nodes. They are no resolver candidates, but such a + // repeater may still relay, so a hop on its prefix is not certain + // evidence for anyone (#2260, uniqueHopPrefix). + retired map[string]struct{} } // markNonRelay registers a set of lowercase pubkeys as listener-only. @@ -7415,6 +7440,9 @@ func (s *PacketStore) rebuildNodeCache() ([]nodeInfo, *prefixMap) { } else { s.logSchemaDegradationOnce("observers.can_relay read failed; path-hop disambiguator will not filter listener-only observers: " + err.Error()) } + if err := pm.markRetired(s.db); err != nil { + s.logSchemaDegradationOnce("inactive_nodes read failed; certain liveness will not see retired namesake prefixes: " + err.Error()) + } } s.cacheMu.Lock() @@ -9984,7 +10012,9 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string var packetsToday int var snrSum float64 var snrCount int - var lastHeard string + // Certain evidence only (#2260): packets merely attributed to the node + // (an ambiguous 1-byte hop) still count in the stats below. + lastHeard := s.certainHeard[n.pk] // See GetNodeHealth: this set is "saw traffic involving the node", // which is not "heard the node". Only the direct-RF rows below carry // signal numbers. @@ -10003,9 +10033,6 @@ func (s *PacketStore) GetBulkHealth(limit int, region, area string) []map[string snrSum += *pkt.SNR snrCount++ } - if lastHeard == "" || pkt.FirstSeen > lastHeard { - lastHeard = pkt.FirstSeen - } if pkt.ObserverID != "" { relayObservers[pkt.ObserverID] = struct{}{} } @@ -10086,7 +10113,8 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro var snrSum float64 var snrCount int var totalHops, hopCount int - var lastHeard string + // Certain evidence only (#2260): see GetBulkHealth. + lastHeard := s.certainHeard[pubkey] totalObservations := 0 // Observers that saw traffic involving this node — as originator, as a @@ -10105,9 +10133,6 @@ func (s *PacketStore) GetNodeHealth(pubkey string) (map[string]interface{}, erro snrSum += *pkt.SNR snrCount++ } - if lastHeard == "" || pkt.FirstSeen > lastHeard { - lastHeard = pkt.FirstSeen - } // Hop counting hops := txGetParsedPath(pkt) if len(hops) > 0 { diff --git a/docs/specs/2026-10-07-node-notifications-design.md b/docs/specs/2026-10-07-node-notifications-design.md index 31900ea70..e45aa822f 100644 --- a/docs/specs/2026-10-07-node-notifications-design.md +++ b/docs/specs/2026-10-07-node-notifications-design.md @@ -202,6 +202,9 @@ except where a ruling below changes one. 1. "Per day" is a rolling 24 hours counted from `mail_log`, like the proposal limit. 2. Offline uses the node page's three timestamps: `nodes.last_seen`, the packet store's newest packet involving the node, and for repeaters and rooms `last_relayed`. + Superseded by #2260: `nodes.last_seen` and the packet store's newest packet that can + only be this node. `last_relayed` and packets merely attributed through a shared + 1-byte hop prefix kept a silent repeater online for days. 3. No evaluation runs while the packet store is still loading after a restart; the first one runs one interval after startup. 4. `foreign.new` keeps a per-admin baseline row (subject `*`): the first evaluation after diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md index 73bdc77c8..b991dfda6 100644 --- a/docs/user-guide/accounts.md +++ b/docs/user-guide/accounts.md @@ -207,8 +207,11 @@ watched node changes state. Admins can also watch the instance. - Events: a watched node goes offline or comes back, using the node page's thresholds: offline once it has not been heard for the `healthThresholds` silent hours of its role - (heard is the latest of its last advert, the packet store's newest packet involving - it, and for repeaters and rooms its last relay hop). A watched node also reports a + (heard is the latest of its last advert and the newest packet that can only be this + node: its own packets, and relay hops whose prefix no other repeater shares. A 1-byte + hop shared with other repeaters is not counted, so a silent repeater is not kept + online by a namesake prefix; the node page shows such a repeater as *Uncertain*, and + its offline mail says that only its shared prefix was still seen). A watched node also reports a battery below `batteryThresholds.lowMv`, and recovers at `lowMv + 100` mV. Admins can add a new foreign node (once per node) and an observer going offline (`observerStaleMinutes`) or back (`observerOnlineMinutes`). diff --git a/public/live.js b/public/live.js index 8a35e393a..f2fa69554 100644 --- a/public/live.js +++ b/public/live.js @@ -3168,8 +3168,8 @@ // #1598: infra nodes (repeater/room) are ALWAYS dimmed, never deleted — // a dimmed marker still tells operators "infrastructure exists here, // advert stale", whereas deletion silently rewrites the map. Staleness - // itself is relay-aware via getNodeStatus(node) (max of advert-based - // freshness and last_relayed for infra). + // itself comes from getNodeStatus(node), whose last_heard includes the + // relays that can only be this node (#2260). // Remaining WS-only non-infra nodes are removed to prevent memory leaks. function pruneStaleNodes() { var now = Date.now(); @@ -3179,7 +3179,10 @@ if (!n) continue; var lastSeen = n._liveSeen || (n.last_heard ? new Date(n.last_heard).getTime() : null) || (n.last_seen ? new Date(n.last_seen).getTime() : null); if (lastSeen == null) continue; + // #2260: 'uncertain' (only a shared hop prefix seen) dims like stale; + // it only occurs for repeaters and rooms, which are never removed. var status = window.getNodeStatus ? getNodeStatus(n) : 'active'; + if (status === 'uncertain') status = 'stale'; var isInfra = n.role === 'repeater' || n.role === 'room'; var marker = nodeMarkers[key]; if (status === 'stale') { diff --git a/public/map.js b/public/map.js index be3c3cfed..a8d5e8451 100644 --- a/public/map.js +++ b/public/map.js @@ -530,6 +530,7 @@
| Status | ${statusLabel} ${statusExplanation} |
| Last Heard | ${renderNodeTimestampHtml(lastHeard || n.last_seen)} |
| Last Relayed | ${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? ' actively relaying' : ' alive (idle)') : 'never observed as relay hop alive (idle)'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` (${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)` : ''} |
| Last Relayed | ${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? ' actively relaying' : ' alive (idle)') : 'never observed as relay hop alive (idle)'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` (${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)` : ''} |