diff --git a/cmd/server/resolve_non_relay_1290_test.go b/cmd/server/resolve_non_relay_1290_test.go new file mode 100644 index 00000000..d7d8aac2 --- /dev/null +++ b/cmd/server/resolve_non_relay_1290_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +// Issue #1290 — exclude observers that advertised `repeat:off` (listener-only) +// from the path-hop disambiguator candidate set. Three cases: +// 1. repeat:off pubkey → NOT a candidate +// 2. repeat:on pubkey → IS a candidate (regression guard) +// 3. legacy / no field → IS a candidate (back-compat preserve current behavior) +func TestResolveWithContext_ExcludesNonRelayObservers_Issue1290(t *testing.T) { + nodes := []nodeInfo{ + {Role: "repeater", PublicKey: "a1aaaaaa", Name: "RealRepeater"}, + {Role: "repeater", PublicKey: "a1bbbbbb", Name: "ListenerOnly"}, + } + + // Case 1: marked non-relay → excluded from candidate set. + pm := buildPrefixMap(nodes) + pm.markNonRelay([]string{"a1bbbbbb"}) + ni, conf, _ := pm.resolveWithContext("a1bbbbbb", nil, nil) + if ni != nil { + t.Fatalf("case repeat:off — expected nil (listener-only excluded), got name=%q confidence=%q", ni.Name, conf) + } + if conf != "no_match" { + t.Fatalf("case repeat:off — expected no_match confidence after exclusion, got %q", conf) + } + + // Case 2: repeat:on (i.e. not in nonRelay set) → still resolves. + pm2 := buildPrefixMap(nodes) + pm2.markNonRelay([]string{"a1bbbbbb"}) + ni2, _, _ := pm2.resolveWithContext("a1aaaaaa", nil, nil) + if ni2 == nil || ni2.Name != "RealRepeater" { + t.Fatalf("case repeat:on — expected RealRepeater, got %+v", ni2) + } + + // Case 3: legacy back-compat — no markNonRelay call → behavior unchanged. + pm3 := buildPrefixMap(nodes) + ni3, _, _ := pm3.resolveWithContext("a1bbbbbb", nil, nil) + if ni3 == nil || ni3.Name != "ListenerOnly" { + t.Fatalf("case legacy — expected ListenerOnly (back-compat), got %+v", ni3) + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index 8233191d..3edfd33a 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -6121,6 +6121,28 @@ func (s *PacketStore) getAllNodes() []nodeInfo { type prefixMap struct { m map[string][]nodeInfo + // nonRelay holds lowercase pubkeys of observer-known nodes that have + // advertised `repeat:off` in their MQTT /status message (issue #1290). + // Such nodes are pure listeners and must never be selected as a + // path-hop candidate by resolveWithContext, since by firmware + // contract they do not forward packets. nil/empty preserves the + // pre-#1290 behavior (every prefix-matching node is a candidate). + nonRelay map[string]struct{} +} + +// markNonRelay registers a set of lowercase pubkeys as listener-only. +// Called by the server when wiring the prefix map after reading the +// observers table's can_relay column. Issue #1290. +func (pm *prefixMap) markNonRelay(pubkeys []string) { + if pm == nil { + return + } + if pm.nonRelay == nil { + pm.nonRelay = make(map[string]struct{}, len(pubkeys)) + } + for _, pk := range pubkeys { + pm.nonRelay[strings.ToLower(pk)] = struct{}{} + } } // maxPrefixLen caps prefix map entries. MeshCore path hops use 2–6 char