diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b1b05a2e..cbeb12e7 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -589,6 +589,7 @@ jobs:
suite 1 test-a11y-axe-1668.js BASE_URL=$B AXE_SCREENSHOT_DIR=/tmp/axe-1668
suite 2 test-filter-ux-e2e.js BASE_URL=$B
suite 2 test-radio-setting-1869-e2e.js BASE_URL=$B
+ suite 2 test-observer-neighbors-2270-e2e.js BASE_URL=$B
suite 1 test-channel-issue-1087-e2e.js BASE_URL=$B
suite 1 test-channel-issue-1111-e2e.js BASE_URL=$B
suite 2 test-map-modal-fluid-e2e.js BASE_URL=$B
diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go
index e6438b35..f3847dce 100644
--- a/cmd/ingestor/db.go
+++ b/cmd/ingestor/db.go
@@ -2265,6 +2265,11 @@ func (s *Store) PurgeStaleObservers(purgeDays int) (int64, error) {
`DELETE FROM observer_radios WHERE observer_id NOT IN (SELECT id FROM observers)`); err != nil {
log.Printf("[purge] observer radio history: %v", err)
}
+ // And so do their /neighbors reports (#2330).
+ if _, err := s.instrumentedExec("purge_observers",
+ `DELETE FROM observer_neighbor_reports WHERE observer_id NOT IN (SELECT id FROM observers)`); err != nil {
+ log.Printf("[purge] observer neighbor reports: %v", err)
+ }
}
return purged, nil
}
diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go
index 461c7990..be0250a8 100644
--- a/cmd/ingestor/main.go
+++ b/cmd/ingestor/main.go
@@ -1007,6 +1007,11 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
// does not apply.
if len(parts) >= 4 && parts[3] == "neighbors" {
handleNeighborsReport(store, tag, parts[2], msg)
+ // #2270/#2271: keep the report itself for the observer pages. A
+ // retained replay must not pose as a fresh report.
+ if err := store.UpsertNeighborReport(clampObserverField(parts[2], maxObserverIDLen), msg, m.Retained(), time.Now()); err != nil {
+ log.Printf("MQTT [%s] neighbors report store error: %v", tag, err)
+ }
return
}
diff --git a/cmd/ingestor/neighbor_reports.go b/cmd/ingestor/neighbor_reports.go
new file mode 100644
index 00000000..8b6d67ad
--- /dev/null
+++ b/cmd/ingestor/neighbor_reports.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "encoding/json"
+ "math"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Latest /neighbors report per observer (#2270, #2271); schema in
+// internal/dbschema/observer_neighbors.go.
+//
+// The report is unauthenticated, so what is stored is a sanitised copy:
+// neighbors with a 64-hex key only, numbers finite and in range, strings
+// clamped, at most maxStoredNeighbors rows.
+
+const maxStoredNeighbors = 500
+
+var neighborStatusRe = regexp.MustCompile(`^[a-z_]{1,32}$`)
+
+// StoredNeighbor is one neighbors[] entry as kept in neighbors_json.
+type StoredNeighbor struct {
+ Pubkey string `json:"pubkey"`
+ SNR *float64 `json:"snr,omitempty"`
+ HeardSecsAgo *int64 `json:"heard_secs_ago,omitempty"`
+ Status string `json:"status,omitempty"`
+ Scopes string `json:"scopes,omitempty"`
+}
+
+// sanitizeNeighbors keeps the well-formed entries of a report's neighbors[].
+func sanitizeNeighbors(raw []interface{}) []StoredNeighbor {
+ out := make([]StoredNeighbor, 0, len(raw))
+ for _, item := range raw {
+ if len(out) >= maxStoredNeighbors {
+ break
+ }
+ n, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ pk, _ := n["pubkey"].(string)
+ pk = strings.ToLower(strings.TrimSpace(pk))
+ if !targetPubkeyRe.MatchString(pk) {
+ continue
+ }
+ e := StoredNeighbor{Pubkey: pk}
+ if f, ok := toFloat64(n["snr"]); ok && !math.IsNaN(f) && f >= -64 && f <= 64 {
+ f = math.Round(f*100) / 100
+ e.SNR = &f
+ }
+ if f, ok := toFloat64(n["heard_secs_ago"]); ok && f >= 0 && f <= 10*365*86400 {
+ v := int64(f)
+ e.HeardSecsAgo = &v
+ }
+ if s, _ := n["status"].(string); neighborStatusRe.MatchString(s) {
+ e.Status = s
+ }
+ if s, _ := n["scopes"].(string); s != "" {
+ e.Scopes = clampObserverField(strings.TrimSpace(s), 256)
+ }
+ out = append(out, e)
+ }
+ return out
+}
+
+// UpsertNeighborReport stores the observer's latest report.
+//
+// Only a live report is dated "now". A replay is dated by the observer's own
+// timestamp and replaces the stored row only when it is newer, so it can fill
+// a gap but never make a long-dead observer look fresh. What counts as a
+// replay is judged like status liveness (#2330): the retain flag, or a
+// payload timestamp older than statusLivenessMaxAge. The flag alone is not
+// enough, because a mosquitto bridge republishes retained messages with
+// RETAIN cleared (statusIsLiveness explains how).
+func (s *Store) UpsertNeighborReport(observerID string, msg map[string]interface{}, retained bool, now time.Time) error {
+ if observerID == "" {
+ return nil
+ }
+ rawList, _ := msg["neighbors"].([]interface{})
+ neighbors := sanitizeNeighbors(rawList)
+ count := len(neighbors)
+ // total_neighbors is the observer's own count (firmware may truncate the
+ // list); trust it only when it is at least what arrived.
+ if f, ok := toFloat64(msg["total_neighbors"]); ok && f >= float64(len(rawList)) && f <= 100000 {
+ count = int(f)
+ }
+ var queried interface{}
+ if f, ok := toFloat64(msg["queried_neighbors"]); ok && f >= 0 && f <= 100000 {
+ queried = int(f)
+ }
+ var selfScopes, selfDefault interface{}
+ if self, ok := msg["self"].(map[string]interface{}); ok {
+ if v, ok := self["scopes"].(string); ok {
+ selfScopes = clampObserverField(strings.TrimSpace(v), 256)
+ }
+ if v, ok := self["default_scope"].(string); ok && v != "" {
+ selfDefault = clampObserverField(strings.TrimSpace(v), 64)
+ }
+ }
+ reportedAt, _ := msg["timestamp"].(string)
+ body, err := json.Marshal(neighbors)
+ if err != nil {
+ return err
+ }
+ receivedAt := now.UTC()
+ // A live report replaces the row; a replay only when it is newer.
+ onConflict := ""
+ if t, _, perr := parseEnvelopeTime(reportedAt); perr == nil && (retained || t.Before(now.Add(-statusLivenessMaxAge))) {
+ if t.Before(receivedAt) {
+ receivedAt = t.UTC()
+ }
+ onConflict = " WHERE excluded.received_at > observer_neighbor_reports.received_at"
+ } else if retained {
+ // A replay we cannot date says nothing about when it was made.
+ return nil
+ }
+ _, err = s.db.Exec(`INSERT INTO observer_neighbor_reports
+ (observer_id, received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(observer_id) DO UPDATE SET
+ received_at = excluded.received_at, reported_at = excluded.reported_at,
+ neighbor_count = excluded.neighbor_count, queried_neighbors = excluded.queried_neighbors,
+ self_scopes = excluded.self_scopes, self_default_scope = excluded.self_default_scope,
+ neighbors_json = excluded.neighbors_json`+onConflict,
+ observerID, receivedAt.Format(time.RFC3339), nilIfEmpty(clampObserverField(reportedAt, 40)),
+ count, queried, selfScopes, selfDefault, string(body))
+ if err != nil {
+ s.Stats.WriteErrors.Add(1)
+ }
+ return err
+}
diff --git a/cmd/ingestor/neighbor_reports_test.go b/cmd/ingestor/neighbor_reports_test.go
new file mode 100644
index 00000000..3c610155
--- /dev/null
+++ b/cmd/ingestor/neighbor_reports_test.go
@@ -0,0 +1,245 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+// #2270 / #2271: the latest /neighbors report per observer.
+
+const (
+ nbKeyA = "aa00000000000000000000000000000000000000000000000000000000000001"
+ nbKeyB = "bb00000000000000000000000000000000000000000000000000000000000002"
+)
+
+func neighborsMsg(t *testing.T, payload string) map[string]interface{} {
+ t.Helper()
+ var msg map[string]interface{}
+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
+ t.Fatal(err)
+ }
+ return msg
+}
+
+func TestSanitizeNeighbors(t *testing.T) {
+ msg := neighborsMsg(t, `{"neighbors":[
+ {"pubkey":"`+strings.ToUpper(nbKeyA)+`","snr":7.256,"heard_secs_ago":42,"status":"responded","scopes":"#cz,#cz-ulk"},
+ {"pubkey":"not-a-key","snr":1},
+ {"pubkey":"`+nbKeyB+`","snr":"NaN","heard_secs_ago":-5,"status":"","scopes":"`+strings.Repeat("x", 400)+`"},
+ "garbage"]}`)
+ got := sanitizeNeighbors(msg["neighbors"].([]interface{}))
+ if len(got) != 2 {
+ t.Fatalf("kept %d entries, want 2: %+v", len(got), got)
+ }
+ a := got[0]
+ if a.Pubkey != nbKeyA || a.SNR == nil || *a.SNR != 7.26 || a.HeardSecsAgo == nil || *a.HeardSecsAgo != 42 ||
+ a.Status != "responded" || a.Scopes != "#cz,#cz-ulk" {
+ t.Errorf("first entry = %+v", a)
+ }
+ b := got[1]
+ if b.SNR != nil || b.HeardSecsAgo != nil || b.Status != "" || len(b.Scopes) > 256 {
+ t.Errorf("out-of-range fields kept: %+v (scopes %d chars)", b, len(b.Scopes))
+ }
+}
+
+func TestSanitizeNeighborsCap(t *testing.T) {
+ raw := make([]interface{}, maxStoredNeighbors+50)
+ for i := range raw {
+ raw[i] = map[string]interface{}{"pubkey": nbKeyA}
+ }
+ if n := len(sanitizeNeighbors(raw)); n != maxStoredNeighbors {
+ t.Errorf("kept %d, want %d", n, maxStoredNeighbors)
+ }
+}
+
+type storedReport struct {
+ receivedAt, reportedAt string
+ count int
+ neighbors []StoredNeighbor
+}
+
+func loadReport(t *testing.T, s *Store, observer string) *storedReport {
+ t.Helper()
+ var r storedReport
+ var reported *string
+ var body string
+ err := s.db.QueryRow(`SELECT received_at, reported_at, neighbor_count, neighbors_json
+ FROM observer_neighbor_reports WHERE observer_id = ?`, observer).Scan(&r.receivedAt, &reported, &r.count, &body)
+ if err != nil {
+ return nil
+ }
+ if reported != nil {
+ r.reportedAt = *reported
+ }
+ if err := json.Unmarshal([]byte(body), &r.neighbors); err != nil {
+ t.Fatal(err)
+ }
+ return &r
+}
+
+func TestUpsertNeighborReportLiveReplaces(t *testing.T) {
+ s := newTestStore(t)
+ t0 := time.Date(2026, 10, 25, 10, 0, 0, 0, time.UTC)
+ first := neighborsMsg(t, `{"timestamp":"2026-10-25T09:59:58Z","total_neighbors":2,
+ "neighbors":[{"pubkey":"`+nbKeyA+`"},{"pubkey":"`+nbKeyB+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS1", first, false, t0); err != nil {
+ t.Fatal(err)
+ }
+ second := neighborsMsg(t, `{"timestamp":"2026-10-25T16:00:00Z","total_neighbors":40,"neighbors":[{"pubkey":"`+nbKeyB+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS1", second, false, t0.Add(6*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ r := loadReport(t, s, "OBS1")
+ if r == nil || r.receivedAt != "2026-10-25T16:00:00Z" || r.count != 40 || len(r.neighbors) != 1 || r.neighbors[0].Pubkey != nbKeyB {
+ t.Errorf("after second report: %+v", r)
+ }
+}
+
+// total_neighbors below what arrived is not believed.
+func TestUpsertNeighborReportCountNotBelowList(t *testing.T) {
+ s := newTestStore(t)
+ msg := neighborsMsg(t, `{"total_neighbors":0,"neighbors":[{"pubkey":"`+nbKeyA+`"},{"pubkey":"`+nbKeyB+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS1", msg, false, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS1"); r == nil || r.count != 2 {
+ t.Errorf("count = %+v, want 2", r)
+ }
+}
+
+// A retained replay fills a missing row, dated by the report, and never
+// overwrites a live one.
+func TestUpsertNeighborReportRetained(t *testing.T) {
+ s := newTestStore(t)
+ now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
+ old := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00.123Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS1", old, true, now); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" {
+ t.Errorf("retained into empty: %+v, want dated 2026-10-20T08:00:00Z", r)
+ }
+
+ live := neighborsMsg(t, `{"timestamp":"2026-10-25T11:59:59Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS2", live, false, now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.UpsertNeighborReport("OBS2", old, true, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS2"); r == nil || r.receivedAt != "2026-10-25T12:00:00Z" || r.neighbors[0].Pubkey != nbKeyB {
+ t.Errorf("retained replaced a live report: %+v", r)
+ }
+
+ undated := neighborsMsg(t, `{"neighbors":[{"pubkey":"`+nbKeyA+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS3", undated, true, now); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS3"); r != nil {
+ t.Errorf("undated retained report stored: %+v", r)
+ }
+}
+
+// End to end through the MQTT handler; the scope side of the report (#1865)
+// keeps working.
+func TestNeighborsTopicStoresReport(t *testing.T) {
+ s := newTestStore(t)
+ msg := &mockMessage{topic: "meshcore/TST/OBS9/neighbors",
+ payload: []byte(`{"timestamp":"2026-10-25T10:00:00Z","origin":"o","origin_id":"OBS9",
+ "total_neighbors":1,"queried_neighbors":1,"self":{"scopes":"#tst","default_scope":"*"},
+ "neighbors":[{"pubkey":"` + nbKeyA + `","snr":5.5,"heard_secs_ago":30,"status":"responded","scopes":"#tst"}]}`)}
+ handleMessage(s, "test", MQTTSource{Name: "test"}, msg, nil, nil, &Config{})
+ r := loadReport(t, s, "OBS9")
+ if r == nil || r.count != 1 || len(r.neighbors) != 1 || r.neighbors[0].Status != "responded" {
+ t.Fatalf("stored report = %+v", r)
+ }
+ var selfScopes, def string
+ if err := s.db.QueryRow(`SELECT self_scopes, self_default_scope FROM observer_neighbor_reports WHERE observer_id='OBS9'`).
+ Scan(&selfScopes, &def); err != nil || selfScopes != "#tst" || def != "*" {
+ t.Errorf("self = %q %q (%v)", selfScopes, def, err)
+ }
+}
+
+// #2330: a bridge republishes retained reports with RETAIN cleared, so an
+// old payload is a replay whatever the flag says. It never refreshes or
+// replaces a live row, and into an empty row it goes dated by itself.
+func TestUpsertNeighborReportOldUnretainedIsReplay(t *testing.T) {
+ s := newTestStore(t)
+ now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
+ old := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`)
+
+ if err := s.UpsertNeighborReport("OBS1", old, false, now); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" {
+ t.Errorf("old report into empty row: %+v, want dated 2026-10-20T08:00:00Z", r)
+ }
+
+ live := neighborsMsg(t, `{"timestamp":"2026-10-25T11:59:59Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`)
+ if err := s.UpsertNeighborReport("OBS2", live, false, now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.UpsertNeighborReport("OBS2", old, false, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, "OBS2"); r == nil || r.receivedAt != "2026-10-25T12:00:00Z" || r.neighbors[0].Pubkey != nbKeyB {
+ t.Errorf("old unretained report replaced a live one: %+v", r)
+ }
+}
+
+// #2330: a replay newer than the stored row still replaces it; one older
+// than the stored row does not.
+func TestUpsertNeighborReportReplayKeepsNewest(t *testing.T) {
+ s := newTestStore(t)
+ now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
+ older := neighborsMsg(t, `{"timestamp":"2026-10-18T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`)
+ newer := neighborsMsg(t, `{"timestamp":"2026-10-20T08:00:00Z","neighbors":[{"pubkey":"`+nbKeyB+`"}]}`)
+
+ for _, m := range []map[string]interface{}{older, newer, older} {
+ if err := s.UpsertNeighborReport("OBS1", m, true, now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if r := loadReport(t, s, "OBS1"); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" || r.neighbors[0].Pubkey != nbKeyB {
+ t.Errorf("after older, newer, older replay: %+v, want the 2026-10-20 report", r)
+ }
+}
+
+// #2330: a retained report with a zone-less timestamp (#1478) is stored and
+// keeps its own age instead of being dropped.
+func TestUpsertNeighborReportRetainedNaiveTimestamp(t *testing.T) {
+ s := newTestStore(t)
+ now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
+ for id, ts := range map[string]string{"OBS1": "2026-10-20T08:00:00", "OBS2": "2026-10-20T08:00:00.123456"} {
+ msg := neighborsMsg(t, `{"timestamp":"`+ts+`","neighbors":[{"pubkey":"`+nbKeyA+`"}]}`)
+ if err := s.UpsertNeighborReport(id, msg, true, now); err != nil {
+ t.Fatal(err)
+ }
+ if r := loadReport(t, s, id); r == nil || r.receivedAt != "2026-10-20T08:00:00Z" {
+ t.Errorf("%s: retained %q stored as %+v, want dated 2026-10-20T08:00:00Z", id, ts, r)
+ }
+ }
+}
+
+// #2330 end to end: an old report arriving without RETAIN (as through a
+// bridge) does not refresh the observer's report.
+func TestNeighborsTopicOldReportDoesNotRefresh(t *testing.T) {
+ s := newTestStore(t)
+ payload := func(ts string) []byte {
+ return []byte(`{"timestamp":"` + ts + `","neighbors":[{"pubkey":"` + nbKeyA + `"}]}`)
+ }
+ live := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
+ handleMessage(s, "test", MQTTSource{Name: "test"},
+ &mockMessage{topic: "meshcore/TST/OBS9/neighbors", payload: payload(live)}, nil, nil, &Config{})
+ before := loadReport(t, s, "OBS9")
+ if before == nil {
+ t.Fatal("live report not stored")
+ }
+ handleMessage(s, "test", MQTTSource{Name: "test"},
+ &mockMessage{topic: "meshcore/TST/OBS9/neighbors", payload: payload("2026-01-01T00:00:00Z")}, nil, nil, &Config{})
+ if after := loadReport(t, s, "OBS9"); after == nil || after.receivedAt != before.receivedAt || after.reportedAt != live {
+ t.Errorf("old report changed the row: before %+v, after %+v", before, after)
+ }
+}
diff --git a/cmd/ingestor/observer_purge_test.go b/cmd/ingestor/observer_purge_test.go
index 8a3f6a6a..5a8f672f 100644
--- a/cmd/ingestor/observer_purge_test.go
+++ b/cmd/ingestor/observer_purge_test.go
@@ -220,3 +220,32 @@ func TestObserverPurgeDaysOrZero(t *testing.T) {
})
}
}
+
+// #2330: a purged observer's /neighbors report goes with it; a kept
+// observer's report stays.
+func TestPurgeStaleObserversDeletesNeighborReport(t *testing.T) {
+ store := newTestStore(t)
+ staleObserver(t, store, "obs-gone")
+ if err := store.UpsertObserver("obs-live", "obs-live", "LAX", nil); err != nil {
+ t.Fatal(err)
+ }
+ msg := map[string]interface{}{"neighbors": []interface{}{}}
+ for _, id := range []string{"obs-gone", "obs-live"} {
+ if err := store.UpsertNeighborReport(id, msg, false, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if purged, err := store.PurgeStaleObservers(30); err != nil || purged != 1 {
+ t.Fatalf("purged=%d err=%v, want 1", purged, err)
+ }
+ for id, want := range map[string]int{"obs-gone": 0, "obs-live": 1} {
+ var n int
+ if err := store.db.QueryRow(`SELECT COUNT(*) FROM observer_neighbor_reports WHERE observer_id = ?`, id).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != want {
+ t.Errorf("%s: %d neighbor report rows, want %d", id, n, want)
+ }
+ }
+}
diff --git a/cmd/server/observer_neighbors.go b/cmd/server/observer_neighbors.go
new file mode 100644
index 00000000..f92732be
--- /dev/null
+++ b/cmd/server/observer_neighbors.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "strings"
+)
+
+// Latest /neighbors report per observer (#2270, #2271), written by the
+// ingestor (cmd/ingestor/neighbor_reports.go). The table is optional for the
+// server: absent, it reads as "no report" and is probed again next time.
+
+// ObserverNeighbor is one neighbor of the latest report, with the node's name
+// when the analyzer knows it.
+type ObserverNeighbor struct {
+ Pubkey string `json:"pubkey"`
+ Name *string `json:"name"`
+ Role *string `json:"role"`
+ SNR *float64 `json:"snr"`
+ HeardSecsAgo *int64 `json:"heard_secs_ago"`
+ Status string `json:"status,omitempty"`
+ Scopes string `json:"scopes,omitempty"`
+}
+
+// ObserverNeighborReport is the latest report of one observer.
+type ObserverNeighborReport struct {
+ ReceivedAt string `json:"received_at"`
+ ReportedAt *string `json:"reported_at"`
+ NeighborCount int `json:"neighbor_count"`
+ QueriedNeighbors *int `json:"queried_neighbors"`
+ SelfScopes *string `json:"self_scopes"`
+ SelfDefaultScope *string `json:"self_default_scope"`
+ Neighbors []ObserverNeighbor `json:"neighbors"`
+}
+
+// neighborSummary is what the observers list shows: when the last report
+// arrived and how many neighbors it named.
+type neighborSummary struct {
+ receivedAt string
+ count int
+}
+
+func (db *DB) hasNeighborReports() bool {
+ var n int
+ return db.conn.QueryRow(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='observer_neighbor_reports'`).Scan(&n) == nil
+}
+
+// GetObserverNeighborSummaries returns observer id -> latest report summary.
+func (db *DB) GetObserverNeighborSummaries() map[string]neighborSummary {
+ out := map[string]neighborSummary{}
+ if !db.hasNeighborReports() {
+ return out
+ }
+ rows, err := db.conn.Query(`SELECT observer_id, received_at, neighbor_count FROM observer_neighbor_reports`)
+ if err != nil {
+ return out
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id string
+ var s neighborSummary
+ if rows.Scan(&id, &s.receivedAt, &s.count) == nil {
+ out[id] = s
+ }
+ }
+ return out
+}
+
+// GetObserverNeighborReport returns the observer's latest report, nil when it
+// never sent one.
+func (db *DB) GetObserverNeighborReport(observerID string) (*ObserverNeighborReport, error) {
+ if !db.hasNeighborReports() {
+ return nil, nil
+ }
+ var rep ObserverNeighborReport
+ var reportedAt, selfScopes, selfDefault sql.NullString
+ var queried sql.NullInt64
+ var body string
+ err := db.conn.QueryRow(`SELECT received_at, reported_at, neighbor_count, queried_neighbors,
+ self_scopes, self_default_scope, neighbors_json
+ FROM observer_neighbor_reports WHERE observer_id = ?`, observerID).
+ Scan(&rep.ReceivedAt, &reportedAt, &rep.NeighborCount, &queried, &selfScopes, &selfDefault, &body)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if reportedAt.Valid {
+ rep.ReportedAt = &reportedAt.String
+ }
+ if queried.Valid {
+ q := int(queried.Int64)
+ rep.QueriedNeighbors = &q
+ }
+ if selfScopes.Valid {
+ rep.SelfScopes = &selfScopes.String
+ }
+ if selfDefault.Valid {
+ rep.SelfDefaultScope = &selfDefault.String
+ }
+ rep.Neighbors = []ObserverNeighbor{}
+ if err := json.Unmarshal([]byte(body), &rep.Neighbors); err != nil {
+ return nil, err
+ }
+ db.nameNeighbors(rep.Neighbors)
+ return &rep, nil
+}
+
+// nameNeighbors fills name and role from nodes, one query for the report.
+func (db *DB) nameNeighbors(neighbors []ObserverNeighbor) {
+ if len(neighbors) == 0 {
+ return
+ }
+ args := make([]interface{}, len(neighbors))
+ for i, n := range neighbors {
+ args[i] = strings.ToLower(n.Pubkey)
+ }
+ rows, err := db.conn.Query(`SELECT public_key, name, role FROM nodes WHERE public_key IN (?`+
+ strings.Repeat(",?", len(args)-1)+`)`, args...)
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+ type nodeInfo struct{ name, role sql.NullString }
+ byKey := map[string]nodeInfo{}
+ for rows.Next() {
+ var pk string
+ var ni nodeInfo
+ if rows.Scan(&pk, &ni.name, &ni.role) == nil {
+ byKey[strings.ToLower(pk)] = ni
+ }
+ }
+ for i := range neighbors {
+ ni, ok := byKey[strings.ToLower(neighbors[i].Pubkey)]
+ if !ok {
+ continue
+ }
+ if ni.name.Valid && ni.name.String != "" {
+ name := ni.name.String
+ neighbors[i].Name = &name
+ }
+ if ni.role.Valid && ni.role.String != "" {
+ role := ni.role.String
+ neighbors[i].Role = &role
+ }
+ }
+}
diff --git a/cmd/server/observer_neighbors_test.go b/cmd/server/observer_neighbors_test.go
new file mode 100644
index 00000000..b83fd2c0
--- /dev/null
+++ b/cmd/server/observer_neighbors_test.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// #2270 / #2271: the latest /neighbors report on the observer endpoints.
+
+func seedNeighborReport(t *testing.T, srv *Server) {
+ t.Helper()
+ for _, s := range []string{
+ `CREATE TABLE IF NOT EXISTS observer_neighbor_reports (observer_id TEXT PRIMARY KEY, received_at TEXT NOT NULL,
+ reported_at TEXT, neighbor_count INTEGER NOT NULL, queried_neighbors INTEGER, self_scopes TEXT,
+ self_default_scope TEXT, neighbors_json TEXT NOT NULL)`,
+ // aabbccdd11223344 is the fixture's TestRepeater; the second key is unknown.
+ `INSERT INTO observer_neighbor_reports VALUES ('obs1', '2026-10-25T10:00:00Z', '2026-10-25T09:59:58Z', 2, 2,
+ '#tst', '*', '[{"pubkey":"aabbccdd11223344","snr":6.5,"heard_secs_ago":30,"status":"responded","scopes":"#tst"},
+ {"pubkey":"ff00ff00ff00ff00","status":"timeout"}]')`,
+ } {
+ if _, err := srv.db.conn.Exec(s); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func getObservers(t *testing.T, srv *Server, router http.Handler, url string, into interface{}) {
+ t.Helper()
+ srv.observersCacheV2.ptr.Store(nil) // the list is cached for 30 s
+ req := httptest.NewRequest("GET", url, nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatalf("%s: %d %s", url, w.Code, w.Body.String())
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), into); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestObserversListNeighborSummary(t *testing.T) {
+ srv, router := setupTestServer(t)
+ seedNeighborReport(t, srv)
+ var list ObserverListResponse
+ getObservers(t, srv, router, "/api/observers", &list)
+ got := map[string]ObserverResp{}
+ for _, o := range list.Observers {
+ got[o.ID] = o
+ }
+ o1 := got["obs1"]
+ if o1.NeighborsReportAt == nil || *o1.NeighborsReportAt != "2026-10-25T10:00:00Z" || o1.NeighborsCount == nil || *o1.NeighborsCount != 2 {
+ t.Errorf("obs1 = at %v count %v", o1.NeighborsReportAt, o1.NeighborsCount)
+ }
+ if o1.NeighborReport != nil {
+ t.Error("the list must not carry the full report")
+ }
+ if o2 := got["obs2"]; o2.NeighborsReportAt != nil || o2.NeighborsCount != nil {
+ t.Errorf("obs2 never reported but has %v / %v", o2.NeighborsReportAt, o2.NeighborsCount)
+ }
+}
+
+func TestObserverDetailNeighborReport(t *testing.T) {
+ srv, router := setupTestServer(t)
+ seedNeighborReport(t, srv)
+ var detail ObserverResp
+ getObservers(t, srv, router, "/api/observers/obs1", &detail)
+ rep := detail.NeighborReport
+ if rep == nil || rep.NeighborCount != 2 || len(rep.Neighbors) != 2 || rep.SelfScopes == nil || *rep.SelfScopes != "#tst" {
+ t.Fatalf("report = %+v", rep)
+ }
+ known, unknown := rep.Neighbors[0], rep.Neighbors[1]
+ if known.Name == nil || *known.Name != "TestRepeater" || known.Role == nil || *known.Role != "repeater" ||
+ known.SNR == nil || *known.SNR != 6.5 || known.Status != "responded" {
+ t.Errorf("known neighbor = %+v", known)
+ }
+ if unknown.Name != nil || unknown.Status != "timeout" {
+ t.Errorf("unknown neighbor = %+v", unknown)
+ }
+ var none ObserverResp
+ getObservers(t, srv, router, "/api/observers/obs2", &none)
+ if none.NeighborReport != nil || none.NeighborsReportAt != nil {
+ t.Errorf("obs2 detail has a report: %+v", none.NeighborReport)
+ }
+}
+
+// Without the table (a server ahead of its ingestor) the endpoints still
+// answer, just without neighbor data.
+func TestObserversWithoutNeighborTable(t *testing.T) {
+ srv, router := setupTestServer(t)
+ var list ObserverListResponse
+ getObservers(t, srv, router, "/api/observers", &list)
+ for _, o := range list.Observers {
+ if o.NeighborsReportAt != nil {
+ t.Errorf("%v has a report without the table", o.ID)
+ }
+ }
+ var detail ObserverResp
+ getObservers(t, srv, router, "/api/observers/obs1", &detail)
+ if detail.NeighborReport != nil {
+ t.Error("detail has a report without the table")
+ }
+}
diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go
index b3bc8e62..b3ea471e 100644
--- a/cmd/server/openapi.go
+++ b/cmd/server/openapi.go
@@ -238,9 +238,9 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"},
// Observers
- "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
+ "GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways. neighbors_report_at (when the latest /neighbors report arrived) and neighbors_count (how many neighbors it named) are present for observers that ever sent one (#2270).", Tag: "observers"},
"GET /api/observers/clock-skew": {Summary: "Observer clock calibrations", Description: "Returns an array of observer clock calibrations: observerID, offsetSec (seconds; positive means the observer clock is ahead) and samples (number of multi-observer packet samples used). Returns an empty array when the store or calibration data is unavailable.", Tag: "observers", Response: schemaRef("ObserverClockSkewResponse")},
- "GET /api/observers/{id}": {Summary: "Get observer detail", Description: "radio_history lists every radio setting the observer reported, oldest first: {radio_id, radio, first_seen, last_seen}. radio_id is empty for the main radio and the node's own id for an entry of a multi-radio status radios map (#1869).", Tag: "observers"},
+ "GET /api/observers/{id}": {Summary: "Get observer detail", Description: "radio_history lists every radio setting the observer reported, oldest first: {radio_id, radio, first_seen, last_seen}. radio_id is empty for the main radio and the node's own id for an entry of a multi-radio status radios map (#1869). neighbor_report is the latest /neighbors report (#2271): {received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr, heard_secs_ago, status, scopes}]}; name and role come from the node table when known. Absent when the observer never sent one.", Tag: "observers"},
"GET /api/radios": {Summary: "Radio settings heard on", Description: "Every radio setting (canonical \"MHz,kHz,SF,CR\") some observer has listened on, with those observers and the span of time, for the radio filter of /api/packets (#1869).", Tag: "observers"},
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index 3507274d..d89ada5b 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -3098,6 +3098,7 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
observerIDs[i] = o.ID
}
nodeLocations := s.db.GetNodeLocationsByKeys(observerIDs)
+ neighborSummaries := s.db.GetObserverNeighborSummaries()
result := make([]ObserverResp, 0, len(observers))
nowTime := time.Now().UTC()
@@ -3131,6 +3132,10 @@ func (s *Server) buildObserversDefaultResponse() (ObserverListResponse, error) {
Lat: lat, Lon: lon, NodeRole: nodeRole,
CanRelay: o.CanRelay,
}
+ if ns, ok := neighborSummaries[o.ID]; ok {
+ at, count := ns.receivedAt, ns.count
+ resp.NeighborsReportAt, resp.NeighborsCount = &at, &count
+ }
applyObserverNaiveClock(&resp, o, nowTime)
result = append(result, resp)
}
@@ -3176,6 +3181,10 @@ func (s *Server) handleObserverDetail(w http.ResponseWriter, r *http.Request) {
PacketsLastHour: plh,
CanRelay: obs.CanRelay,
}
+ if rep, err := s.db.GetObserverNeighborReport(obs.ID); err == nil && rep != nil {
+ at, count := rep.ReceivedAt, rep.NeighborCount
+ resp.NeighborsReportAt, resp.NeighborsCount, resp.NeighborReport = &at, &count, rep
+ }
applyObserverNaiveClock(&resp, obs, time.Now().UTC())
resp.RadioHistory = observerRadioStints(obs.ID)
return resp
diff --git a/cmd/server/types.go b/cmd/server/types.go
index f02adf07..40588660 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -1004,6 +1004,12 @@ type ObserverResp struct {
// RadioHistory is every setting the observer reported, oldest first
// (#1869). Only on GET /api/observers/{id}.
RadioHistory []RadioStint `json:"radio_history,omitempty"`
+ // Latest /neighbors report (#2270): when it arrived and how many
+ // neighbors it named; absent when the observer never sent one.
+ NeighborsReportAt *string `json:"neighbors_report_at,omitempty"`
+ NeighborsCount *int `json:"neighbors_count,omitempty"`
+ // The report itself, GET /api/observers/{id} only (#2271).
+ NeighborReport *ObserverNeighborReport `json:"neighbor_report,omitempty"`
}
type ObserverListResponse struct {
diff --git a/internal/dbschema/dbschema.go b/internal/dbschema/dbschema.go
index e463a367..c3dc6b3c 100644
--- a/internal/dbschema/dbschema.go
+++ b/internal/dbschema/dbschema.go
@@ -117,6 +117,9 @@ func Apply(rw *sql.DB, logf Logger) error {
if err := ensureObserverRadios(rw, logf); err != nil {
return fmt.Errorf("ensure observer radios: %w", err)
}
+ if err := ensureObserverNeighborReports(rw); err != nil {
+ return fmt.Errorf("ensure observer neighbor reports: %w", err)
+ }
// Advert evidence is intentionally not required by AssertReady: a legacy
// read-only server reports unknown until this table/backfill is available,
// and re-probes absence instead of caching the startup race permanently.
diff --git a/internal/dbschema/observer_neighbors.go b/internal/dbschema/observer_neighbors.go
new file mode 100644
index 00000000..f823ac75
--- /dev/null
+++ b/internal/dbschema/observer_neighbors.go
@@ -0,0 +1,38 @@
+package dbschema
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+// observer_neighbor_reports keeps the latest /neighbors report of each
+// observer (#2270, #2271). Until now the ingestor only took the confirmed
+// region scopes out of it (#1865, nodes.configured_scope) and dropped the
+// rest, so nothing could say when an observer last reported or whom it
+// heard.
+//
+// One row per observer, replaced by every live report. neighbors_json is the
+// ingestor's sanitised copy of the report's neighbors[] (the report is
+// unauthenticated input), not the raw payload.
+//
+// Not required by AssertReady: a server that comes up before the ingestor
+// created it shows no neighbor data and probes again on the next request.
+//
+// PREFLIGHT: async=false reason="creates one empty table keyed by observer id; no scan of existing data"
+func ensureObserverNeighborReports(rw *sql.DB) error {
+ if _, err := rw.Exec(`
+ CREATE TABLE IF NOT EXISTS observer_neighbor_reports (
+ observer_id TEXT PRIMARY KEY,
+ received_at TEXT NOT NULL,
+ reported_at TEXT,
+ neighbor_count INTEGER NOT NULL,
+ queried_neighbors INTEGER,
+ self_scopes TEXT,
+ self_default_scope TEXT,
+ neighbors_json TEXT NOT NULL
+ )
+ `); err != nil {
+ return fmt.Errorf("create observer_neighbor_reports: %w", err)
+ }
+ return nil
+}
diff --git a/public/index.html b/public/index.html
index b31c7704..a920edc7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -249,6 +249,7 @@
+
diff --git a/public/observer-detail.js b/public/observer-detail.js
index 5c6e9435..c1b370dd 100644
--- a/public/observer-detail.js
+++ b/public/observer-detail.js
@@ -331,6 +331,14 @@ window.ObserverDetailNaiveBanner = {
| Status | Name | Region | Last Status | Last Packet | +Status | Name | Region | Last Status | Last Packet | Neighbors | Packet Health | Total Packets | Packets/Hour | TX 1h | Clock Offset | Uptime | Firmware | Client | Select for compare | ${o.iata ? `${escapeHtml(o.iata)}` : '—'} | ${timeAgo(o.last_seen)} | ${o.last_packet_at ? timeAgo(o.last_packet_at) : '—'} | +${neighborsCell(o)} | ${packetBadge(o)} | ${(o.packet_count || 0).toLocaleString()} | ${sparkBar(o.packetsLastHour || 0, maxPktsHr)} | @@ -540,6 +547,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo ']*>([^<]+)<\/th>/g; let m; while ((m = re.exec(thead)) !== null) labels.push(m[1].trim()); - const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', + const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', 'Neighbors', 'Packet Health', 'Total Packets', 'Packets/Hour', 'TX 1h', 'Clock Offset', 'Uptime', 'Firmware', 'Client']; assert.deepStrictEqual(labels, expected, diff --git a/tests/unit/test-xss-escape-sinks.js b/tests/unit/test-xss-escape-sinks.js index 291c76b5..91ff9778 100644 --- a/tests/unit/test-xss-escape-sinks.js +++ b/tests/unit/test-xss-escape-sinks.js @@ -530,6 +530,21 @@ test('OBS-1 radio history: radio, radio_id and timestamps are escaped', () => { assertNoXss(html, 'OBS-1 radio history'); }); +// #2270 / #2271: the neighbors report renders observer- and mesh-published +// strings (node names, scopes, statuses, timestamps). +test('ObserverNeighbors: cell and detail table escape every field', () => { + const window = {}; + new Function('window', fs.readFileSync('public/observer-neighbors.js', 'utf8'))(window); + const evil = TAG_PAYLOAD + ATTR_PAYLOAD; + assertNoXss(window.ObserverNeighbors.cell({ neighbors_report_at: evil, neighbors_count: evil }), 'neighbors cell'); + const html = window.ObserverNeighbors.detailHtml({ + neighbor_count: evil, queried_neighbors: evil, self_scopes: evil, self_default_scope: evil, reported_at: evil, + neighbors: [{ pubkey: evil, name: evil, role: evil, snr: evil, heard_secs_ago: evil, status: evil, scopes: evil }, + { pubkey: evil, status: evil }], + }); + assertNoXss(html, 'neighbors table'); +}); + test('OBS-1 expanded: obs.iata payload is escaped', () => { const html = renderObsDetail({ id: 'obs-1', iata: TAG_PAYLOAD + ATTR_PAYLOAD }); assertNoXss(html, 'OBS-1 obs.iata'); |
|---|