mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
Part of #2238. Path hops through such a repeater (`/api/nodes`, `/api/resolve-hops`, the frontend hop resolver) still show a bare prefix; that part of the issue stays open. ## Summary A repeater that never sends an advert can still tell a nearby companion its name. CoreDrive RX v1.20.0 asks for it (`ANON_REQ_TYPE_OWNER`) and publishes the answer as `NODE_NAME` on `meshcore/client/{pk}/names`. This PR stores those answers, resolves names with them, and shows these repeaters on the map, in the nodes list and in search, marked as self-reported. ### Ingestor: store the names - `367ecde1` adds a new `names` subtopic. - It is gated on `clientNodeNames.enabled`, default off. - Answers go into a new table, `client_node_names`: one row per observation, `UNIQUE(target, rx_pubkey, observed_at)`. - **Validation.** - `target` must be 64 hex. - The name is trimmed and rejected when it is empty, over 31 runes (firmware `node_name[32]`, `src/helpers/CommonCLI.h:29`), invalid UTF-8, or contains a control character. - **Retention.** `retention.clientNodeNamesDays` (0 = keep). `config.example.json` sets 30, like the other client tables: a companion only asks a repeater for its name when CoreScope has none (`nameMissing` in CoreDrive's `src/names.js`), so pruning is also how a renamed repeater gets its new name. - **Compatibility.** An ingestor without this change drops `/names` silently, so CoreDrive v1.20.0 works against either version. ### Server: resolve by the reported name - `11d97b36` makes `lookupPrefixes` count candidates in both `nodes` and `client_node_names`. `/api/nodes/resolve` and `batchResolveHeardKeys` (the coverage tooltips) both use it. - **Which name wins.** The advert name, otherwise the newest reported name by `observed_at`. A prefix that matches different pubkeys across the two tables stays ambiguous. - **Only repeaters heard directly.** A reported name counts only once its target has at least one `discover` or `advert` reception in `client_receptions`, keyed by the full pubkey, or by its 8-byte prefix when no other pubkey in `nodes`, `inactive_nodes` or `client_node_names` starts with that prefix (`clientNameHeard`). A name for a pubkey nobody heard adds no candidate, so it cannot make a real node's prefix ambiguous. Without the uniqueness check, a made-up pubkey that shares a heard repeater's first 8 bytes borrowed that repeater's receptions and passed (fixed in `4c569f91`). - **Filters.** The blacklist and the hidden name prefixes apply to both sources. - **Missing table.** A database without the table resolves from `nodes` alone, and the server checks for the table again until the ingestor has created it. - **Response shape.** `/api/nodes/resolve` keeps `{prefix, pubkey?, name?, ambiguous}`. ### Server: estimated position - `98c76957` adds `GET /api/nodes/reported`, documented in `openapi.go`. It lists every repeater in `client_node_names` that never sent an advert (no row in `nodes` or `inactive_nodes`) and was heard directly at least once (see above). A node with an advert but no position stays out: it is already in `/api/nodes` under its advert name. - **Gated** like the other endpoints on `client_receptions`: 404 when `clientRxCoverage.enabled` is off, because the counts and the estimate come from companion GPS fixes. - **Which receptions count.** Only direct ones: - `discover` and `advert` rows, keyed by the full pubkey, or by its 8-byte prefix when no other known pubkey shares it; - `rxlog` rows whose 2-3 byte hash resolves to this repeater alone. `geo` rows are left out. - **How the estimate is made.** - Receptions within 100 m form one spot, with the best SNR of that spot. - An estimate needs at least 3 spots, at least 200 m apart. - The position is the SNR-weighted centroid (`10^(snr/20)`). The radius is the distance to the farthest spot, at least 500 m. - The response is cached for 5 minutes, and the compute time is logged. ### Map - `5fcde554` adds `public/map-estimated.js`, which draws each estimate on its own layer: - a hollow, dashed "≈" marker and a dashed uncertainty circle; - a popup with an "Estimated position" badge, the method, the caveat that one-sided driving skews the estimate, and a link to the coverage map; - a Display toggle, "Estimated positions": on by default, kept in localStorage, and also hidden by the repeater role filter and in route view. ### Nodes page and search - `12e6b345` adds a "Self-reported repeaters" section under the nodes table, on the All and Repeaters tabs, filtered by the search box. - **`#/nodes/<pubkey>`.** Where `/api/nodes/<pubkey>` answers 404, a self-reported detail view replaces "Node not found": - who reported the name; - the receptions; - the estimate, or why there is none; - links to the map and the coverage map. An unknown key still shows "Node not found". - **Header search** adds matching self-reported repeaters, tagged "self-reported". - **Unchanged on purpose.** `/api/nodes` and `/api/nodes/search` stay as they are. These repeaters have no packets, so the packet filters, the live view and the channel sender lookup must not match them. ### Docs - `03580437` corrects the `/regions` section of `docs/client-rx-coverage.md`: it named `clientRxCoverage.enabled` as the switch, but the code uses `clientRegions.enabled`. - The new subtopic, endpoint and config keys are documented in the same file. ## Trust Any companion the broker lets publish can report a name for any repeater, and the newest report wins. This is the same model as `/regions`. `clientRxCoverage.requireLinkedCompanion` limits it to linked companions. A report for a repeater nobody heard directly is stored but not used: it is not listed and adds no prefix candidate. A companion can still report a wrong name for a repeater that was heard. The same companion can also publish a fake `discover` reception for a made-up pubkey, so the "heard directly" check filters junk, not a malicious companion; `requireLinkedCompanion` is the control for that (its gate runs before the `/names` handler too). ## Performance - **`lookupPrefixes`** adds one term per key on `client_node_names`. - It matches the prefix as a range, `target >= ? AND target < ?` (upper bound prefix + `"g"`), not with `LIKE`: `LIKE` on this case-sensitive column cannot use the index. `EXPLAIN QUERY PLAN`: `SEARCH c USING INDEX idx_cnn_target (target>? AND target<?)`, and the reception check is `SEARCH r USING INDEX idx_client_recept_heard_geo (heard_key=?)`. - It is still one query per chunk of 200 keys: 400 compound terms, under SQLite's default limit of 500. - `TestLookupPrefixesManyKeys` covers 250 keys over two chunks. - **`/api/nodes/reported`** makes three kinds of query: - one grouped query on `client_node_names`; - one `lookupPrefixes` call for the short hashes; - one `IN` query per 400 keys on `client_receptions`, served by `idx_client_recept_heard_geo`. The spread check is O(spots²) per repeater, with at most one spot per 100 m visited. The result is cached for 5 minutes; concurrent misses wait for one computation, and the response is written after the lock is released. - **The map does not wait for it.** It draws its markers first and adds the estimates when the list arrives. The map and the nodes page share one cached fetch (`ReportedNodes.load()`). - **Not benchmarked.** On the instance where this runs, `client_node_names` holds one row today. I expect at most tens of such repeaters per instance, but have not measured that. ## Notes - **`map[string]interface{}`.** `cmd/ingestor/client_names.go` has 2 occurrences: the handler's `msg` parameter and its `gps` field. That is the decoded MQTT message, passed in the same shape as `handleClientPacket` and `handleClientRegions` already take it. No API response or internal structure uses it. - **Thresholds are hardcoded:** 100 m, 3 spots / 200 m, 500 m, 5 min TTL. They should move to the customizer later (rule 8). - **Map filters.** The estimates follow the "Estimated positions" toggle and the repeater role filter, not the area, region or last-heard filters. The toggle's tooltip says so. - **Name characters.** Names are rejected for control characters but not for Unicode format characters (bidi overrides, zero-width joiners), because emoji sequences in node names use the zero-width joiner. All output goes through `escapeHtml`. - **Not done:** the home-page search box (`home.js`, `/api/nodes/search` with a Claim button) does not list these repeaters. Claiming a node without adverts would have nothing to show. ## Tests On this branch (based on `550829af`): - `go vet ./...` and `go test -count=1 ./...` pass in `cmd/server` and `cmd/ingestor`. - **New Go tests:** - handler and validation cases; - idempotent redelivery; - newest `observed_at` wins; - prune; - the gate on and off, without falling through to the observer path; - client-only node, nameless `nodes` row, advert name wins, ambiguity across the two sources; - hidden and blacklisted nodes; - absent-then-created table; - spot merging, threshold, weighted centroid and radius floor; - `discover` / `rxlog` / `geo` handling, an ambiguous path hash, an advert position and a missing table; - an advert without a position, a never-heard name, a name heard only in `rxlog` paths, and the `clientRxCoverage` gate (404). These fail on `12e6b345` and pass at head. - a made-up pubkey sharing a heard repeater's 8-byte prefix, and two reported targets sharing one (`4c569f91`). Both fail without that commit. - **New frontend unit suites:** `tests/unit/test-map-estimated.js` and `tests/unit/test-reported-nodes.js` pass. Both load the real `escapeHtml` from `app.js`. - **`test-all.sh`** passes, all suites (with Python available for the XSS gate self-tests). `scripts/check-xss-sinks.sh --file` passes on the four changed frontend files. - **E2E**, against a static server with a mocked API: - `test-map-estimated-e2e.js`: 6/6, including the repeater role filter and route view hiding the estimates. Mutating either hide makes its step fail. - `test-reported-nodes-e2e.js`: 6/6. - `test-issue-1150-404-state-e2e.js`: 5/5. - **Real data, API only.** On the fork's staging, `/api/nodes/reported` lists one repeater: 5 spots, 17 receptions, an estimate with a radius of 2,305 m. It has no `nodes` row and 24 `discover` receptions, so it stays listed under the stricter rules. Not re-checked after `4c569f91`. - **Not done:** a check of that repeater in a browser. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
12 KiB
Go
300 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
func snrp(v float64) *float64 { return &v }
|
|
|
|
// Offsets in metres around a reference point, small enough for a flat-earth
|
|
// conversion to be exact to well under a metre.
|
|
func offset(dNorthM, dEastM float64) (float64, float64) {
|
|
const lat0, lon0 = 51.0, 5.38
|
|
return lat0 + dNorthM/110540, lon0 + dEastM/(111320*math.Cos(lat0*math.Pi/180))
|
|
}
|
|
|
|
func pt(dN, dE float64, snr *float64) rxPoint {
|
|
lat, lon := offset(dN, dE)
|
|
return rxPoint{Lat: lat, Lon: lon, SNR: snr}
|
|
}
|
|
|
|
func TestEstimatePositionNeedsThreeSpreadSpots(t *testing.T) {
|
|
// Fifteen receptions from one driveway are one spot: no estimate.
|
|
var parked []rxPoint
|
|
for i := 0; i < 15; i++ {
|
|
parked = append(parked, pt(float64(i), 0, snrp(-5)))
|
|
}
|
|
if est, spots := estimatePosition(parked); est != nil || spots != 1 {
|
|
t.Fatalf("parked: est=%v spots=%d, want nil and 1", est, spots)
|
|
}
|
|
// Two spots: still nothing.
|
|
if est, spots := estimatePosition([]rxPoint{pt(0, 0, snrp(-5)), pt(0, 1000, snrp(-5))}); est != nil || spots != 2 {
|
|
t.Fatalf("two spots: est=%v spots=%d", est, spots)
|
|
}
|
|
// Three spots, but all within 200 m of each other: nothing.
|
|
close3 := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 120, snrp(-5)), pt(120, 0, snrp(-5))}
|
|
if est, spots := estimatePosition(close3); est != nil || spots != 3 {
|
|
t.Fatalf("tight cluster: est=%v spots=%d", est, spots)
|
|
}
|
|
}
|
|
|
|
func TestEstimatePositionWeightedCentroid(t *testing.T) {
|
|
// Equal SNR: the plain centroid of the three spots.
|
|
eq := []rxPoint{pt(0, 0, snrp(-10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))}
|
|
est, spots := estimatePosition(eq)
|
|
if est == nil || spots != 3 {
|
|
t.Fatalf("est=%v spots=%d", est, spots)
|
|
}
|
|
wantLat, wantLon := offset(300, 300)
|
|
if d := distanceM(est.Lat, est.Lon, wantLat, wantLon); d > 2 {
|
|
t.Errorf("centroid off by %.1f m", d)
|
|
}
|
|
// One spot 20 dB stronger pulls the estimate towards it.
|
|
strong := []rxPoint{pt(0, 0, snrp(10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))}
|
|
est2, _ := estimatePosition(strong)
|
|
lat0, lon0 := offset(0, 0)
|
|
if distanceM(est2.Lat, est2.Lon, lat0, lon0) >= distanceM(est.Lat, est.Lon, lat0, lon0) {
|
|
t.Errorf("the strong spot did not pull the estimate towards it: %+v vs %+v", est2, est)
|
|
}
|
|
}
|
|
|
|
func TestEstimatePositionRadius(t *testing.T) {
|
|
// Spots far apart: the radius reaches the farthest spot.
|
|
wide := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 3000, snrp(-5)), pt(3000, 0, snrp(-5))}
|
|
est, _ := estimatePosition(wide)
|
|
if est == nil {
|
|
t.Fatal("no estimate")
|
|
}
|
|
far := 0.0
|
|
for _, p := range wide {
|
|
far = math.Max(far, distanceM(est.Lat, est.Lon, p.Lat, p.Lon))
|
|
}
|
|
if math.Abs(est.RadiusM-far) > 1 {
|
|
t.Errorf("radius %.0f, want the farthest spot at %.0f", est.RadiusM, far)
|
|
}
|
|
// Spots close together: the radius never drops below the floor.
|
|
near := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 250, snrp(-5)), pt(250, 0, snrp(-5))}
|
|
if est, _ := estimatePosition(near); est == nil || est.RadiusM != estimateMinRadiusM {
|
|
t.Errorf("near: %+v, want radius %v", est, estimateMinRadiusM)
|
|
}
|
|
}
|
|
|
|
// A spot keeps its best SNR, and a reception without SNR still counts as a spot.
|
|
func TestSpotsOfKeepsBestSNR(t *testing.T) {
|
|
spots := spotsOf([]rxPoint{pt(0, 0, snrp(-12)), pt(30, 0, snrp(-3)), pt(60, 0, nil), pt(0, 500, nil)})
|
|
if len(spots) != 2 {
|
|
t.Fatalf("spots = %d, want 2", len(spots))
|
|
}
|
|
if spots[0].SNR == nil || *spots[0].SNR != -3 {
|
|
t.Errorf("first spot SNR = %v, want -3", spots[0].SNR)
|
|
}
|
|
if spots[1].SNR != nil {
|
|
t.Errorf("second spot SNR = %v, want nil", spots[1].SNR)
|
|
}
|
|
}
|
|
|
|
// --- endpoint ---
|
|
|
|
const (
|
|
repA = "c0cdee59e0db981b00000000000000000000000000000000000000000000000a" // reported, spread receptions
|
|
repB = "b1b1220000000000000000000000000000000000000000000000000000000000" // reported, one spot
|
|
repC = "c2c2330000000000000000000000000000000000000000000000000000000000" // reported, but has an advert position
|
|
repH = "c3c3440000000000000000000000000000000000000000000000000000000000" // hidden name
|
|
repD = "d4d4550000000000000000000000000000000000000000000000000000000000" // reported, has an advert without a position
|
|
repF = "f6f6770000000000000000000000000000000000000000000000000000000000" // reported, never heard directly
|
|
repR = "a7a7880000000000000000000000000000000000000000000000000000000000" // reported, heard only in rxlog paths
|
|
)
|
|
|
|
func insRec(t *testing.T, db *DB, hk, src string, dN, dE, snr float64, at string) {
|
|
t.Helper()
|
|
lat, lon := offset(dN, dE)
|
|
mustExecDB(t, db, fmt.Sprintf(`INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src)
|
|
VALUES ('comp','%s',%d,%f,%f,%f,'%s','x','%s')`, hk, len(hk)/2, snr, lat, lon, at, src))
|
|
}
|
|
|
|
func seedReported(t *testing.T) *Server {
|
|
t.Helper()
|
|
db := seedCoverageDB(t)
|
|
createClientNodeNames(t, db)
|
|
insClientName(t, db, repA, "Self Named", "2026-10-16T10:00:00.000Z")
|
|
insClientName(t, db, repB, "Lonely", "2026-10-16T09:00:00.000Z")
|
|
insClientName(t, db, repC, "Has Advert", "2026-10-16T08:00:00.000Z")
|
|
insClientName(t, db, repH, "🚫Secret", "2026-10-16T07:00:00.000Z")
|
|
insClientName(t, db, repD, "No GPS", "2026-10-16T06:00:00.000Z")
|
|
insClientName(t, db, repF, "Invented", "2026-10-16T05:00:00.000Z")
|
|
insClientName(t, db, repR, "Path Only", "2026-10-16T04:00:00.000Z")
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('`+repC+`','Has Advert','repeater',51.0,5.38)`)
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+repD+`','Advert Name','repeater')`)
|
|
// Heard directly, so only the advert (repC, repD) or the hidden name (repH)
|
|
// keeps them out.
|
|
for _, pk := range []string{repC, repD, repH} {
|
|
insRec(t, db, pk[:16], "discover", 0, 0, -5, "2026-10-07T10:00:00Z")
|
|
}
|
|
insRec(t, db, repR[:4], "rxlog", 0, 0, -5, "2026-10-07T10:00:00Z")
|
|
|
|
// repA: an 8-byte discover key at three spread spots, plus a 2-byte rxlog
|
|
// hash that resolves uniquely to it.
|
|
insRec(t, db, repA[:16], "discover", 0, 0, -5, "2026-10-01T10:00:00Z")
|
|
insRec(t, db, repA[:16], "discover", 0, 900, -10, "2026-10-02T10:00:00Z")
|
|
insRec(t, db, repA, "discover", 900, 0, -10, "2026-10-03T10:00:00Z")
|
|
insRec(t, db, repA[:4], "rxlog", 10, 10, -6, "2026-10-04T10:00:00Z")
|
|
// A geo row is not a direct reception of this node and must be ignored.
|
|
insRec(t, db, repA[:4], "geo", 5000, 5000, -1, "2026-10-05T10:00:00Z")
|
|
// repB: heard from one place only.
|
|
insRec(t, db, repB[:16], "discover", 0, 0, -4, "2026-10-06T10:00:00Z")
|
|
insRec(t, db, repB[:16], "discover", 20, 0, -6, "2026-10-06T10:01:00Z")
|
|
return &Server{db: db, cfg: &Config{HiddenNamePrefixes: []string{"🚫"}, ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}}
|
|
}
|
|
|
|
func getReported(t *testing.T, srv *Server) ReportedNodesResponse {
|
|
t.Helper()
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/api/nodes/reported", srv.handleReportedNodes).Methods("GET")
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/reported", nil))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp ReportedNodesResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func TestReportedNodesEndpoint(t *testing.T) {
|
|
resp := getReported(t, seedReported(t))
|
|
if resp.Method.MinSpots != estimateMinSpots || resp.Method.MinRadiusM != estimateMinRadiusM {
|
|
t.Errorf("method = %+v", resp.Method)
|
|
}
|
|
got := map[string]ReportedNode{}
|
|
for _, n := range resp.Nodes {
|
|
got[n.Pubkey] = n
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("nodes = %v, want repA and repB only (repC and repD sent an advert, repH is hidden, repF was never heard, repR only in rxlog paths)", resp.Nodes)
|
|
}
|
|
a := got[repA]
|
|
if a.Name != "Self Named" || a.Receptions != 4 || a.Spots != 3 || a.Estimate == nil {
|
|
t.Fatalf("repA = %+v", a)
|
|
}
|
|
if a.FirstHeard != "2026-10-01T10:00:00Z" || a.LastHeard != "2026-10-04T10:00:00Z" {
|
|
t.Errorf("repA heard %s..%s", a.FirstHeard, a.LastHeard)
|
|
}
|
|
far, _ := offset(5000, 5000)
|
|
if a.Estimate.Lat > far-0.01 {
|
|
t.Errorf("the geo row moved the estimate: %+v", a.Estimate)
|
|
}
|
|
b := got[repB]
|
|
if b.Receptions != 2 || b.Spots != 1 || b.Estimate != nil {
|
|
t.Fatalf("repB = %+v, want 2 receptions in 1 spot and no estimate", b)
|
|
}
|
|
// Newest name report first.
|
|
if resp.Nodes[0].Pubkey != repA {
|
|
t.Errorf("order: first = %s, want repA", resp.Nodes[0].Pubkey)
|
|
}
|
|
}
|
|
|
|
// A short rxlog hash shared with a node in nodes is ambiguous and must not be
|
|
// attributed to the reported repeater.
|
|
func TestReportedNodesSkipsAmbiguousPathHash(t *testing.T) {
|
|
srv := seedReported(t)
|
|
mustExecDB(t, srv.db, `INSERT INTO nodes (public_key, name, role) VALUES ('c0cd990000000000000000000000000000000000000000000000000000000000','Other','repeater')`)
|
|
resp := getReported(t, srv)
|
|
for _, n := range resp.Nodes {
|
|
if n.Pubkey == repA && n.Receptions != 3 {
|
|
t.Fatalf("repA receptions = %d, want 3 (the ambiguous c0cd rxlog row dropped)", n.Receptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReportedNodesBlacklisted(t *testing.T) {
|
|
srv := seedReported(t)
|
|
srv.cfg.NodeBlacklist = []string{repA}
|
|
for _, n := range getReported(t, srv).Nodes {
|
|
if n.Pubkey == repA {
|
|
t.Fatal("blacklisted repeater listed")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Without client_node_names (an older ingestor) the endpoint answers an empty
|
|
// list, not an error.
|
|
func TestReportedNodesWithoutTable(t *testing.T) {
|
|
srv := &Server{db: setupTestDBv2(t), cfg: &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}}
|
|
if resp := getReported(t, srv); len(resp.Nodes) != 0 {
|
|
t.Fatalf("nodes = %v, want none", resp.Nodes)
|
|
}
|
|
}
|
|
|
|
// The list and its estimate come from companion GPS fixes, so the endpoint is
|
|
// gated like the other client_receptions endpoints.
|
|
func TestReportedNodesGatedByClientRxCoverage(t *testing.T) {
|
|
srv := seedReported(t)
|
|
srv.cfg.ClientRxCoverage = nil
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/api/nodes/reported", srv.handleReportedNodes).Methods("GET")
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/reported", nil))
|
|
if rr.Code != 404 {
|
|
t.Fatalf("status %d with clientRxCoverage off, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// A /names report for a made-up pubkey that shares the first 8 bytes of a
|
|
// heard repeater must not count as heard: the 8-byte discover rows belong to
|
|
// the repeater in nodes. Before the uniqueness check in clientNameHeard, one
|
|
// such message made the repeater's short prefix ambiguous and listed the
|
|
// made-up target with the repeater's receptions.
|
|
func TestReportedNameSharingEightBytePrefixIsNotHeard(t *testing.T) {
|
|
const real = "c5c5660011223344000000000000000000000000000000000000000000000001"
|
|
const fake = "c5c5660011223344ffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
srv := seedReported(t)
|
|
mustExecDB(t, srv.db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+real+`','Real','repeater')`)
|
|
insRec(t, srv.db, real[:16], "discover", 0, 0, -5, "2026-10-08T10:00:00Z")
|
|
insRec(t, srv.db, real[:16], "discover", 0, 900, -5, "2026-10-08T11:00:00Z")
|
|
insRec(t, srv.db, real[:16], "discover", 900, 0, -5, "2026-10-08T12:00:00Z")
|
|
insClientName(t, srv.db, fake, "Spoof", "2026-10-17T10:00:00.000Z")
|
|
|
|
var r ResolvePrefixResp
|
|
json.Unmarshal(serveResolve(srv, "/api/nodes/resolve?prefix=c5c5").Body.Bytes(), &r)
|
|
if r.Ambiguous || r.Pubkey != real || r.Name != "Real" {
|
|
t.Errorf("resolve c5c5 = %+v, want the real repeater, not ambiguous", r)
|
|
}
|
|
for _, n := range getReported(t, srv).Nodes {
|
|
if n.Pubkey == fake {
|
|
t.Fatalf("made-up target listed with borrowed receptions: %+v", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two reported targets that share an 8-byte prefix: an 8-byte reception could
|
|
// be either, so it counts for neither. A full-key reception still counts.
|
|
func TestReportedTargetsSharingEightBytePrefix(t *testing.T) {
|
|
const t1 = "e5e5770011223344000000000000000000000000000000000000000000000001"
|
|
const t2 = "e5e5770011223344000000000000000000000000000000000000000000000002"
|
|
srv := seedReported(t)
|
|
insClientName(t, srv.db, t1, "First", "2026-10-17T10:00:00.000Z")
|
|
insClientName(t, srv.db, t2, "Second", "2026-10-17T10:00:00.000Z")
|
|
insRec(t, srv.db, t1, "discover", 0, 0, -5, "2026-10-08T10:00:00Z")
|
|
insRec(t, srv.db, t1[:16], "discover", 0, 900, -5, "2026-10-08T11:00:00Z")
|
|
insRec(t, srv.db, t1[:16], "discover", 900, 0, -5, "2026-10-08T12:00:00Z")
|
|
|
|
got := map[string]ReportedNode{}
|
|
for _, n := range getReported(t, srv).Nodes {
|
|
got[n.Pubkey] = n
|
|
}
|
|
if n, ok := got[t1]; !ok || n.Receptions != 1 {
|
|
t.Errorf("t1 = %+v (listed %v), want listed with only its full-key reception", n, ok)
|
|
}
|
|
if _, ok := got[t2]; ok {
|
|
t.Errorf("t2 listed, but it was only heard by a shared 8-byte prefix")
|
|
}
|
|
}
|