mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 12:37:33 +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>
195 lines
7.2 KiB
Go
195 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"strings"
|
|
)
|
|
|
|
// Prefix resolution over two name sources: nodes (adverts this instance heard)
|
|
// and client_node_names (a repeater's own answer to ANON_REQ_TYPE_OWNER,
|
|
// relayed by a mobile companion because this instance had no name for it; the
|
|
// ingestor writes it, cmd/ingestor/client_names.go). A repeater asked that way
|
|
// usually has no nodes row at all, so the second table is a source of
|
|
// candidates, not only of names.
|
|
|
|
// clientNodeNamesPresent reports whether client_node_names exists. The ingestor
|
|
// creates it, and on the first run of a build that adds it the server can start
|
|
// before the ingestor has, so an absent table is re-probed until it appears
|
|
// (same reason as declaredRegionsTablePresent).
|
|
func (db *DB) clientNodeNamesPresent() bool {
|
|
if db.clientNodeNamesTable.Load() {
|
|
return true
|
|
}
|
|
var n int
|
|
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'client_node_names'`).Scan(&n); err != nil || n == 0 {
|
|
return false
|
|
}
|
|
db.clientNodeNamesTable.Store(true)
|
|
return true
|
|
}
|
|
|
|
// clientNameHeard is the SQL condition (alias c for client_node_names) that a
|
|
// reported target was heard directly at least once: a discover or advert
|
|
// reception keyed by its full pubkey, or by its 8-byte prefix when no other
|
|
// known pubkey (nodes, inactive_nodes, client_node_names) starts with that
|
|
// prefix. Any companion the broker accepts can report a name for any pubkey,
|
|
// so without this one message could invent a repeater, or make a real node's
|
|
// short prefix ambiguous. Without the uniqueness check, a made-up target that
|
|
// shares a heard repeater's 8-byte prefix would borrow that repeater's
|
|
// receptions and pass. The condition needs client_receptions; see
|
|
// clientNamesUsable.
|
|
//
|
|
// The prefix ranges rely on lowercase hex keys, as in lookupPrefixes: "g"
|
|
// sorts after "f", so [p, p+"g") is exactly the keys starting with p.
|
|
func (db *DB) clientNameHeard() string {
|
|
sharedWith := func(table, col string) string {
|
|
return `EXISTS (SELECT 1 FROM ` + table + ` o WHERE o.` + col + ` >= substr(c.target, 1, 16)
|
|
AND o.` + col + ` < substr(c.target, 1, 16) || 'g' AND o.` + col + ` <> c.target)`
|
|
}
|
|
shared := sharedWith("nodes", "public_key") + ` OR ` + sharedWith("client_node_names", "target")
|
|
if db.inactiveNodesPresent() {
|
|
shared += ` OR ` + sharedWith("inactive_nodes", "public_key")
|
|
}
|
|
return `EXISTS (SELECT 1 FROM client_receptions r
|
|
WHERE r.src IN ('discover','advert') AND (r.heard_key = c.target
|
|
OR (r.heard_key = substr(c.target, 1, 16) AND NOT (` + shared + `))))`
|
|
}
|
|
|
|
// inactiveNodesPresent reports whether inactive_nodes exists; an older or
|
|
// partial database may lack it. Re-probed while absent.
|
|
func (db *DB) inactiveNodesPresent() bool {
|
|
if db.inactiveNodesTable.Load() {
|
|
return true
|
|
}
|
|
if !db.tablePresent("inactive_nodes") {
|
|
return false
|
|
}
|
|
db.inactiveNodesTable.Store(true)
|
|
return true
|
|
}
|
|
|
|
// clientNamesUsable reports whether client_node_names can contribute: the
|
|
// table exists, and so does client_receptions, which clientNameHeard reads.
|
|
func (db *DB) clientNamesUsable() bool {
|
|
return db.clientNodeNamesPresent() && db.tablePresent("client_receptions")
|
|
}
|
|
|
|
// prefixHit is what one prefix matched. matches counts distinct pubkeys across
|
|
// both sources, capped at 2 by the per-source LIMIT 2: unique when it is 1.
|
|
// pubkey and name are set only when unique. name is the nodes name when that
|
|
// is non-empty, otherwise the newest client-reported name.
|
|
type prefixHit struct {
|
|
matches int
|
|
pubkey string
|
|
name string
|
|
}
|
|
|
|
// lookupPrefixes resolves validated hex prefixes (or full pubkeys) to their
|
|
// candidates, one query per chunk. Each key contributes a LIMIT 2 lookup per
|
|
// source, so per-key work stays bounded. A key whose chunk failed is absent
|
|
// from the result (logged), and callers treat it as unresolved.
|
|
//
|
|
// client_node_names: the bare `name` beside MAX(observed_at) is SQLite's
|
|
// documented bare-column rule, so it is the name of the newest observation
|
|
// for that target. Ordering by observed_at, not ingested_at, keeps a drive
|
|
// buffered offline from overwriting a fresher answer.
|
|
func (s *Server) lookupPrefixes(keys []string) map[string]prefixHit {
|
|
res := make(map[string]prefixHit, len(keys))
|
|
withClient := s.db.clientNamesUsable()
|
|
var heard string
|
|
if withClient {
|
|
heard = s.db.clientNameHeard()
|
|
}
|
|
// SQLITE_MAX_COMPOUND_SELECT is 500 by default; two terms per key keeps a
|
|
// chunk of 200 at 400.
|
|
const chunk = 200
|
|
for i := 0; i < len(keys); i += chunk {
|
|
end := i + chunk
|
|
if end > len(keys) {
|
|
end = len(keys)
|
|
}
|
|
batch := keys[i:end]
|
|
parts := make([]string, 0, len(batch)*2)
|
|
args := make([]interface{}, 0, len(batch)*4)
|
|
for _, k := range batch {
|
|
// Parameterized: the prefix flows in as bound args, never
|
|
// interpolated. The per-prefix LIMIT 2 lives in a subquery because a
|
|
// bare LIMIT on a UNION ALL term is a SQLite syntax error.
|
|
parts = append(parts, "SELECT * FROM (SELECT ? AS pfx, public_key AS pk, COALESCE(name,'') AS nm, 0 AS src FROM nodes WHERE public_key LIKE ? LIMIT 2)")
|
|
args = append(args, k, k+"%")
|
|
if withClient {
|
|
// A range, not LIKE: LIKE on a case-sensitive column cannot use
|
|
// idx_cnn_target and scans it per key. target is lowercase hex
|
|
// (the ingestor lowercases it), and "g" sorts after "f", so
|
|
// [k, k+"g") is exactly the keys starting with k.
|
|
lk := strings.ToLower(k)
|
|
parts = append(parts, "SELECT pfx, pk, nm, src FROM (SELECT ? AS pfx, target AS pk, name AS nm, 1 AS src, MAX(observed_at) FROM client_node_names c WHERE target >= ? AND target < ? AND "+heard+" GROUP BY target LIMIT 2)")
|
|
args = append(args, k, lk, lk+"g")
|
|
}
|
|
}
|
|
rows, err := s.db.conn.Query(strings.Join(parts, " UNION ALL "), args...)
|
|
if err != nil {
|
|
// Don't fail the request, but don't fail silently either: a swallowed
|
|
// error here presents as "every name is ambiguous" with no signal.
|
|
log.Printf("WARN lookupPrefixes: %v", err)
|
|
continue
|
|
}
|
|
type agg struct {
|
|
nodeName map[string]string // pubkey -> nodes name
|
|
clientName map[string]string // pubkey -> newest client name
|
|
}
|
|
acc := map[string]*agg{}
|
|
for rows.Next() {
|
|
var pfx, pk, nm string
|
|
var src int
|
|
if err := rows.Scan(&pfx, &pk, &nm, &src); err != nil {
|
|
continue
|
|
}
|
|
a := acc[pfx]
|
|
if a == nil {
|
|
a = &agg{nodeName: map[string]string{}, clientName: map[string]string{}}
|
|
acc[pfx] = a
|
|
}
|
|
if src == 0 {
|
|
a.nodeName[pk] = nm
|
|
} else {
|
|
a.clientName[pk] = nm
|
|
}
|
|
}
|
|
rows.Close()
|
|
for _, k := range batch {
|
|
a := acc[k]
|
|
if a == nil {
|
|
res[k] = prefixHit{}
|
|
continue
|
|
}
|
|
pks := map[string]bool{}
|
|
for pk := range a.nodeName {
|
|
pks[pk] = true
|
|
}
|
|
for pk := range a.clientName {
|
|
pks[pk] = true
|
|
}
|
|
hit := prefixHit{matches: len(pks)}
|
|
if len(pks) == 1 {
|
|
for pk := range pks {
|
|
hit.pubkey = pk
|
|
}
|
|
hit.name = a.nodeName[hit.pubkey]
|
|
if hit.name == "" {
|
|
hit.name = a.clientName[hit.pubkey]
|
|
}
|
|
}
|
|
res[k] = hit
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// visible applies the #15 / #1181 parity rule to a unique hit: a blacklisted
|
|
// node or a hidden-prefix name is reported as not found, whichever source the
|
|
// name came from.
|
|
func (s *Server) visible(h prefixHit) bool {
|
|
return h.matches == 1 && !s.cfg.IsBlacklisted(h.pubkey) && !s.cfg.IsNameHidden(h.name)
|
|
}
|