diff --git a/cmd/ingestor/client_names.go b/cmd/ingestor/client_names.go new file mode 100644 index 000000000..170c2f0f5 --- /dev/null +++ b/cmd/ingestor/client_names.go @@ -0,0 +1,144 @@ +package main + +import ( + "database/sql" + "log" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +// maxNodeNameRunes bounds a reported node name. The firmware keeps the name in +// NodePrefs::node_name[32] (src/helpers/CommonCLI.h), so a real name is at most +// 31 bytes. The bound is in runes rather than bytes because the client decodes +// the reply as UTF-8: a name the firmware cut mid-character decodes to a +// U+FFFD, which is 3 bytes for what was 1-2 on air. +const maxNodeNameRunes = 31 + +// cleanNodeName validates a node name reported over /names. Returns the trimmed +// name and true, or "" and false when it is empty, too long, not valid UTF-8, +// or contains a control character. Trailing NULs are trimmed first: the reply +// is NUL-padded to the AES block size (see handleClientRegions), and a client +// build that did not trim it must not store the padding as part of the name. +func cleanNodeName(s string) (string, bool) { + s = strings.TrimSpace(strings.TrimRight(s, "\x00")) + if s == "" || !utf8.ValidString(s) || utf8.RuneCountInString(s) > maxNodeNameRunes { + return "", false + } + for _, r := range s { + if unicode.IsControl(r) { + return "", false + } + } + return s, true +} + +// handleClientNodeNames processes one name answer from +// meshcore/client/{PUBLIC_KEY}/names: a mobile companion asked a repeater +// (target) for its own name (ANON_REQ_TYPE_OWNER) because this instance had +// none for it, and relays the answer. The topic pubkey (rxPubkey) is the +// reporting companion's identity (ACL-bound by the broker); `target` is +// payload data and must be a full 64-hex pubkey, for the same reason as on +// /regions (targetPubkeyRe): the client can only ask a full pubkey. GPS is +// optional; the name is meaningful without a position. +func handleClientNodeNames(store *Store, tag, rxPubkey string, msg map[string]interface{}) { + rxPubkey = strings.ToLower(strings.TrimSpace(rxPubkey)) + if !clientPubkeyRe.MatchString(rxPubkey) { + log.Printf("MQTT [%s] names: invalid pubkey %.8q, dropping", tag, rxPubkey) + return + } + target, _ := msg["target"].(string) + target = strings.ToLower(strings.TrimSpace(target)) + if !targetPubkeyRe.MatchString(target) { + log.Printf("MQTT [%s] names: invalid target %.8q, dropping", tag, target) + return + } + raw, _ := msg["name"].(string) + name, ok := cleanNodeName(raw) + if !ok { + log.Printf("MQTT [%s] names: missing/invalid name for %.8s, dropping", tag, target) + return + } + + rxTime, _ := resolveRxTimeCore(msg, tag) + + var latPtr, lonPtr, posAccPtr *float64 + if gps, ok := msg["gps"].(map[string]interface{}); ok { + lat, latOK := toFloat64(gps["lat"]) + lon, lonOK := toFloat64(gps["lon"]) + if latOK && lonOK && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 { + latPtr, lonPtr = &lat, &lon + if acc, ok := toFloat64(gps["acc_m"]); ok { + posAccPtr = &acc + } + } + } + + o := &ClientNodeName{ + Target: target, + RxPubkey: rxPubkey, + ObservedAt: rxTime.Format(rxTimeMillisLayout), + IngestedAt: time.Now().UTC().Format(time.RFC3339), + Name: name, + Lat: latPtr, + Lon: lonPtr, + PosAccM: posAccPtr, + RepeaterClock: optInt64(msg, "repeater_clock"), + } + if _, err := store.InsertClientNodeName(o); err != nil { + log.Printf("MQTT [%s] client_node_names insert: %v", tag, err) + } +} + +// ClientNodeName is one repeater's own name, as it answered an +// ANON_REQ_TYPE_OWNER request relayed by a mobile companion. Observations, +// never state, like node_declared_regions: the current name for a target is +// the one with the greatest observed_at. +type ClientNodeName struct { + Target string + RxPubkey string + ObservedAt string + IngestedAt string + Name string + Lat, Lon *float64 + PosAccM *float64 + RepeaterClock *int64 +} + +// InsertClientNodeName writes one name observation. Idempotent via +// UNIQUE(target, rx_pubkey, observed_at); returns ins=false when the row +// already existed. +func (s *Store) InsertClientNodeName(o *ClientNodeName) (bool, error) { + res, err := s.db.Exec(` + INSERT INTO client_node_names + (target, rx_pubkey, observed_at, ingested_at, name, lat, lon, pos_acc_m, repeater_clock) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(target, rx_pubkey, observed_at) DO NOTHING`, + o.Target, o.RxPubkey, o.ObservedAt, o.IngestedAt, o.Name, o.Lat, o.Lon, o.PosAccM, o.RepeaterClock) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// CurrentClientNodeName returns the most recent name observation for a target, +// or nil if none exist. Ordered by observed_at, not ingested_at: a drive +// buffered offline must not overwrite a fresher reading. +func (s *Store) CurrentClientNodeName(target string) (*ClientNodeName, error) { + target = strings.ToLower(strings.TrimSpace(target)) + row := s.db.QueryRow(` + SELECT target, rx_pubkey, observed_at, ingested_at, name, lat, lon, pos_acc_m, repeater_clock + FROM client_node_names + WHERE target = ? + ORDER BY observed_at DESC LIMIT 1`, target) + var o ClientNodeName + if err := row.Scan(&o.Target, &o.RxPubkey, &o.ObservedAt, &o.IngestedAt, &o.Name, &o.Lat, &o.Lon, &o.PosAccM, &o.RepeaterClock); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &o, nil +} diff --git a/cmd/ingestor/client_names_test.go b/cmd/ingestor/client_names_test.go new file mode 100644 index 000000000..e6062b629 --- /dev/null +++ b/cmd/ingestor/client_names_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// clientNodeNamesCount returns the row count in client_node_names. +func clientNodeNamesCount(t *testing.T, s *Store) int { + t.Helper() + var n int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM client_node_names`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// nodeNameMsg is a well-formed /names payload as coredrive-rx v1.20.0 sends it +// (src/publisher.js buildNamesPayload). The timestamp is an hour in the past: +// resolveRxTimeCore replaces a future one with the ingest time, which would +// make two deliveries of the same answer look like two observations. +func nodeNameMsg(name string) map[string]interface{} { + return map[string]interface{}{ + "type": "NODE_NAME", "timestamp": time.Now().UTC().Add(-time.Hour).Format(rxTimeMillisLayout), + "target": testTargetPK, + "name": name, + "repeater_clock": float64(1789000000), + "gps": map[string]interface{}{"lat": 51.05, "lon": 3.72, "acc_m": 8.0}, + } +} + +func TestHandleClientNodeNamesWritesRow(t *testing.T) { + s := newTestStore(t) + handleClientNodeNames(s, "test", testCompanionPK, nodeNameMsg("ON8AR Rptr")) + + if n := clientNodeNamesCount(t, s); n != 1 { + t.Fatalf("rows = %d, want 1", n) + } + cur, err := s.CurrentClientNodeName(testTargetPK) + if err != nil { + t.Fatal(err) + } + if cur == nil { + t.Fatal("current = nil, want a row") + } + if cur.Name != "ON8AR Rptr" { + t.Errorf("Name = %q, want %q", cur.Name, "ON8AR Rptr") + } + if cur.RxPubkey != testCompanionPK { + t.Errorf("RxPubkey = %q, want the topic pubkey %q", cur.RxPubkey, testCompanionPK) + } + if cur.Lat == nil || cur.Lon == nil || *cur.Lat != 51.05 || *cur.Lon != 3.72 { + t.Errorf("lat/lon = %v/%v, want 51.05/3.72", cur.Lat, cur.Lon) + } + if cur.RepeaterClock == nil || *cur.RepeaterClock != 1789000000 { + t.Errorf("RepeaterClock = %v, want 1789000000", cur.RepeaterClock) + } + if _, err := time.Parse(rxTimeMillisLayout, cur.ObservedAt); err != nil { + t.Errorf("observed_at = %q, want rxTimeMillisLayout format: %v", cur.ObservedAt, err) + } +} + +// A name is meaningful without a position, so a missing fix stores NULLs +// instead of dropping the row. +func TestHandleClientNodeNamesWithoutGPS(t *testing.T) { + s := newTestStore(t) + msg := nodeNameMsg("Aegis") + delete(msg, "gps") + handleClientNodeNames(s, "test", testCompanionPK, msg) + + cur, err := s.CurrentClientNodeName(testTargetPK) + if err != nil || cur == nil { + t.Fatalf("current = %v, %v; want a row", cur, err) + } + if cur.Lat != nil || cur.Lon != nil { + t.Errorf("lat/lon = %v/%v, want NULL", cur.Lat, cur.Lon) + } +} + +func TestHandleClientNodeNamesRejects(t *testing.T) { + cases := []struct { + label, rx string + msg map[string]interface{} + }{ + {"invalid topic pubkey", "NOT-HEX!", nodeNameMsg("Aegis")}, + {"short target", testCompanionPK, func() map[string]interface{} { + m := nodeNameMsg("Aegis") + m["target"] = testTargetPK[:16] + return m + }()}, + {"missing name", testCompanionPK, func() map[string]interface{} { + m := nodeNameMsg("") + delete(m, "name") + return m + }()}, + {"empty name", testCompanionPK, nodeNameMsg(" ")}, + {"padding only", testCompanionPK, nodeNameMsg("\x00\x00\x00")}, + {"control character", testCompanionPK, nodeNameMsg("Aegis\nowner")}, + {"too long", testCompanionPK, nodeNameMsg(strings.Repeat("x", maxNodeNameRunes+1))}, + {"invalid utf-8", testCompanionPK, nodeNameMsg("Ae\xffgis")}, + } + for _, c := range cases { + t.Run(c.label, func(t *testing.T) { + s := newTestStore(t) + handleClientNodeNames(s, "test", c.rx, c.msg) + if n := clientNodeNamesCount(t, s); n != 0 { + t.Fatalf("rows = %d, want 0", n) + } + }) + } +} + +func TestCleanNodeName(t *testing.T) { + cases := map[string]string{ + "Aegis": "Aegis", + " Aegis ": "Aegis", + "Aegis\x00\x00\x00": "Aegis", + "Gent Zuid ✈": "Gent Zuid ✈", + strings.Repeat("x", maxNodeNameRunes): strings.Repeat("x", maxNodeNameRunes), + } + for in, want := range cases { + if got, ok := cleanNodeName(in); !ok || got != want { + t.Errorf("cleanNodeName(%q) = %q, %v; want %q, true", in, got, ok, want) + } + } +} + +// The same answer delivered twice (QoS 1 redelivery, or a client re-sending +// its queue) is stored once. +func TestHandleClientNodeNamesIdempotent(t *testing.T) { + s := newTestStore(t) + // One message delivered twice: nodeNameMsg reads the clock, so building it + // twice would give two observations a millisecond apart on a slow runner. + msg := nodeNameMsg("Aegis") + handleClientNodeNames(s, "test", testCompanionPK, msg) + handleClientNodeNames(s, "test", testCompanionPK, msg) + if n := clientNodeNamesCount(t, s); n != 1 { + t.Fatalf("rows = %d, want 1", n) + } +} + +// Current is the newest observed_at, not the latest arrival: a drive buffered +// offline must not overwrite a fresher name. +func TestCurrentClientNodeNameByObservedAt(t *testing.T) { + s := newTestStore(t) + // Relative to now: resolveRxTimeCore replaces a timestamp older than 30 + // days, so fixed dates would stop testing the ordering once they age out. + now := time.Now().UTC() + newer := nodeNameMsg("Renamed") + newer["timestamp"] = now.Add(-time.Hour).Format(rxTimeMillisLayout) + older := nodeNameMsg("Old name") + older["timestamp"] = now.Add(-5 * 24 * time.Hour).Format(rxTimeMillisLayout) + handleClientNodeNames(s, "test", testCompanionPK, newer) + handleClientNodeNames(s, "test", testCompanionPK, older) + + cur, err := s.CurrentClientNodeName(testTargetPK) + if err != nil || cur == nil { + t.Fatalf("current = %v, %v; want a row", cur, err) + } + if cur.Name != "Renamed" { + t.Errorf("Name = %q, want the newest observation %q", cur.Name, "Renamed") + } +} + +func TestPruneOldClientNodeNames(t *testing.T) { + s := newTestStore(t) + // Inserted directly: the handler would replace a timestamp this old + // (resolveRxTimeCore), and the prune must be tested against stored rows. + mk := func(observedAt, name string) *ClientNodeName { + return &ClientNodeName{Target: testTargetPK, RxPubkey: testCompanionPK, ObservedAt: observedAt, IngestedAt: observedAt, Name: name} + } + for _, o := range []*ClientNodeName{mk("2026-01-01T00:00:00.000Z", "Old"), mk("2026-10-16T00:00:00.000Z", "Recent")} { + if _, err := s.InsertClientNodeName(o); err != nil { + t.Fatal(err) + } + } + + n, err := s.pruneOldClientNodeNamesAt(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("pruned = %d, want 1", n) + } + cur, _ := s.CurrentClientNodeName(testTargetPK) + if cur == nil || cur.Name != "Recent" { + t.Fatalf("current = %+v, want the recent row to survive", cur) + } + if n, _ := s.PruneOldClientNodeNames(0); n != 0 { + t.Fatalf("days=0 pruned %d, want 0 (disabled)", n) + } +} + +func clientNodeNamesMsg() *mockMessage { + payload := []byte(`{"raw":"11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172","direction":"rx","type":"NODE_NAME","timestamp":"2026-10-16T10:00:00.000Z","target":"` + testTargetPK + `","name":"Aegis","repeater_clock":1789000000}`) + return &mockMessage{topic: "meshcore/client/" + testCompanionPK + "/names", payload: payload} +} + +func TestClientNodeNamesEnabledDefault(t *testing.T) { + if (&Config{}).ClientNodeNamesEnabled() { + t.Fatal("nil ClientNodeNames must report disabled") + } + if !(&Config{ClientNodeNames: &ClientNodeNamesConfig{Enabled: true}}).ClientNodeNamesEnabled() { + t.Fatal("Enabled:true must report enabled") + } +} + +func TestClientNodeNamesGateOn(t *testing.T) { + store := newTestStore(t) + cfg := &Config{ClientNodeNames: &ClientNodeNamesConfig{Enabled: true}} + handleMessage(store, "test", MQTTSource{Name: "test"}, clientNodeNamesMsg(), nil, nil, cfg) + if n := clientNodeNamesCount(t, store); n != 1 { + t.Fatalf("feature ON: rows = %d, want 1", n) + } +} + +// With the feature off the message is dropped outright, never decoded on the +// observer path (the payload carries an incidental "raw" to prove that). +func TestClientNodeNamesGateOffDoesNotFallThroughToObserverPath(t *testing.T) { + store := newTestStore(t) + handleMessage(store, "test", MQTTSource{Name: "test"}, clientNodeNamesMsg(), nil, nil, &Config{}) + if n := clientNodeNamesCount(t, store); n != 0 { + t.Fatalf("feature OFF: rows = %d, want 0", n) + } + var observerRows int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM observers WHERE id = ?`, testCompanionPK).Scan(&observerRows); err != nil { + t.Fatal(err) + } + if observerRows != 0 { + t.Fatalf("feature OFF: the companion pubkey must not be registered as an observer, got %d rows", observerRows) + } +} diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index 4448546a0..f1c0409ec 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -64,6 +64,7 @@ type Config struct { ClientRxObservations *ClientRxObservationsConfig `json:"clientRxObservations,omitempty"` ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"` ClientRegions *ClientRegionsConfig `json:"clientRegions,omitempty"` + ClientNodeNames *ClientNodeNamesConfig `json:"clientNodeNames,omitempty"` GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"` // PathTrust configures the minimum path-hash prefix length trusted as // mapping/topology evidence (issue #1784). @@ -223,6 +224,18 @@ func (c *Config) ClientRegionsEnabled() bool { return c.ClientRegions != nil && c.ClientRegions.Enabled } +// ClientNodeNamesConfig controls the opt-in node-name answer stream +// (meshcore/client//names). +type ClientNodeNamesConfig struct { + Enabled bool `json:"enabled"` +} + +// ClientNodeNamesEnabled reports whether node-name answers are recorded. +// Default false. +func (c *Config) ClientNodeNamesEnabled() bool { + return c.ClientNodeNames != nil && c.ClientNodeNames.Enabled +} + // AutoRegionKeysConfig controls the opt-in derivation of region keys from the // names repeaters declare over RF (node_declared_regions), on top of the // explicit hashRegions list. @@ -309,6 +322,9 @@ type RetentionConfig struct { // region-discovery stream would otherwise grow without limit. Consumed by // Task 6's pruner. ClientRegionsDays int `json:"clientRegionsDays"` + // ClientNodeNamesDays is the retention window (by observed_at) for the + // client_node_names table; 0 disables. + ClientNodeNamesDays int `json:"clientNodeNamesDays"` } // PacketDaysOrZero returns the configured retention.packetDays or 0 @@ -363,6 +379,15 @@ func (c *Config) ClientRegionsDaysOrZero() int { return 0 } +// ClientNodeNamesDaysOrZero returns retention.clientNodeNamesDays or 0 +// (disabled) if not set. +func (c *Config) ClientNodeNamesDaysOrZero() int { + if c.Retention != nil && c.Retention.ClientNodeNamesDays > 0 { + return c.Retention.ClientNodeNamesDays + } + return 0 +} + // MetricsConfig controls observer metrics collection. type MetricsConfig struct { SampleIntervalSec int `json:"sampleIntervalSec"` diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index f95988456..7e896b131 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -473,6 +473,27 @@ func applySchema(db *sql.DB) error { ); CREATE INDEX IF NOT EXISTS idx_ndr_target ON node_declared_regions(target, observed_at); CREATE INDEX IF NOT EXISTS idx_ndr_prune ON node_declared_regions(observed_at); + + -- Node names reported by repeaters themselves via ANON_REQ_TYPE_OWNER, + -- asked by a mobile companion for a node this instance had no name for. + -- Observations, never state, like node_declared_regions: "current" is the + -- greatest observed_at for a target. The server falls back to it when + -- nodes has no name (cmd/server/client_names.go). + CREATE TABLE IF NOT EXISTS client_node_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target TEXT NOT NULL, + rx_pubkey TEXT NOT NULL, + observed_at TEXT NOT NULL, + ingested_at TEXT NOT NULL, + name TEXT NOT NULL, + lat REAL, + lon REAL, + pos_acc_m REAL, + repeater_clock INTEGER, + UNIQUE(target, rx_pubkey, observed_at) + ); + CREATE INDEX IF NOT EXISTS idx_cnn_target ON client_node_names(target, observed_at); + CREATE INDEX IF NOT EXISTS idx_cnn_prune ON client_node_names(observed_at); ` if _, err := db.Exec(schema); err != nil { return fmt.Errorf("base schema: %w", err) diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 90edcd9d8..d84812066 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -362,6 +362,17 @@ func main() { } } + // Node-name retention: bounds the opt-in client_node_names table. + // 0 = disabled. + clientNodeNamesDays := cfg.ClientNodeNamesDaysOrZero() + if clientNodeNamesDays > 0 { + if n, err := store.PruneOldClientNodeNames(clientNodeNamesDays); err != nil { + log.Printf("[prune] client_node_names: %v", err) + } else if n > 0 { + log.Printf("[prune] startup pruned %d client_node_names older than %d days", n, clientNodeNamesDays) + } + } + vacuumPages := cfg.IncrementalVacuumPages() store.RunIncrementalVacuum(vacuumPages) @@ -444,7 +455,7 @@ func main() { // node_declared_regions retention (Task 6) rather than starting a second // ticker β€” the four flags are independent (0 disables each separately), // so the ticker itself must run when any is set. - if clientRxDays > 0 || clientRxObsDays > 0 || clientRfDays > 0 || clientRegionsDays > 0 { + if clientRxDays > 0 || clientRxObsDays > 0 || clientRfDays > 0 || clientRegionsDays > 0 || clientNodeNamesDays > 0 { clientRxRetentionTicker := time.NewTicker(24 * time.Hour) go func() { for range clientRxRetentionTicker.C { @@ -476,6 +487,13 @@ func main() { store.RunIncrementalVacuum(vacuumPages) } } + if clientNodeNamesDays > 0 { + if n, err := store.PruneOldClientNodeNames(clientNodeNamesDays); err != nil { + log.Printf("[prune] client_node_names: %v", err) + } else if n > 0 { + store.RunIncrementalVacuum(vacuumPages) + } + } } }() if clientRxDays > 0 { @@ -490,6 +508,9 @@ func main() { if clientRegionsDays > 0 { log.Printf("[prune] auto-prune enabled: node_declared_regions older than %d days will be removed daily", clientRegionsDays) } + if clientNodeNamesDays > 0 { + log.Printf("[prune] auto-prune enabled: client_node_names older than %d days will be removed daily", clientNodeNamesDays) + } } // Derived region keys refresh on their own ticker rather than the daily @@ -862,7 +883,8 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, // Mobile client topics: meshcore/client/{PUBLIC_KEY}/packets (RX coverage), // meshcore/client/{PUBLIC_KEY}/rf (RF environment samples), and - // meshcore/client/{PUBLIC_KEY}/regions (declared-region answers). A + // meshcore/client/{PUBLIC_KEY}/regions (declared-region answers), and + // meshcore/client/{PUBLIC_KEY}/names (a repeater's own name). A // roaming companion reports where it directly heard a node, its own // radio's counters, or a repeater's declared region list; all three are // handled in isolation from the observer/observations path. EMQX ACL @@ -903,6 +925,10 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message, if cfg.ClientRegionsEnabled() { handleClientRegions(store, cfg, tag, parts[2], msg) } + case "names": + if cfg.ClientNodeNamesEnabled() { + handleClientNodeNames(store, tag, parts[2], msg) + } } return } diff --git a/cmd/ingestor/maintenance.go b/cmd/ingestor/maintenance.go index 6ee0fe77d..376bb4490 100644 --- a/cmd/ingestor/maintenance.go +++ b/cmd/ingestor/maintenance.go @@ -408,3 +408,32 @@ func extractPubkeyFromAnonReqJSON(s string) string { } return "" } + +// PruneOldClientNodeNames deletes node-name observation rows +// (client_node_names) older than `days` (by observed_at). 0 disables. Same +// millisecond cutoff format as PruneOldClientDeclaredRegions. +func (s *Store) PruneOldClientNodeNames(days int) (int64, error) { + if days <= 0 { + return 0, nil + } + n, err := s.pruneOldClientNodeNamesAt(time.Now().UTC().AddDate(0, 0, -days)) + if err != nil { + return 0, err + } + if n > 0 { + log.Printf("[prune] deleted %d client_node_names older than %d days", n, days) + } + return n, nil +} + +// pruneOldClientNodeNamesAt is the cutoff-instant seam behind +// PruneOldClientNodeNames, so tests can pin the cutoff. +func (s *Store) pruneOldClientNodeNamesAt(cutoffInstant time.Time) (int64, error) { + cutoff := cutoffInstant.Format(rxTimeMillisLayout) + res, err := s.db.Exec(`DELETE FROM client_node_names WHERE observed_at < ?`, cutoff) + if err != nil { + return 0, fmt.Errorf("prune client_node_names: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} diff --git a/cmd/server/client_names.go b/cmd/server/client_names.go new file mode 100644 index 000000000..4a0ef193e --- /dev/null +++ b/cmd/server/client_names.go @@ -0,0 +1,139 @@ +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 +} + +// 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.clientNodeNamesPresent() + // 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 { + 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 WHERE target LIKE ? GROUP BY target LIMIT 2)") + args = append(args, k, k+"%") + } + } + 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) +} diff --git a/cmd/server/client_names_test.go b/cmd/server/client_names_test.go new file mode 100644 index 000000000..fc6112bce --- /dev/null +++ b/cmd/server/client_names_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// createClientNodeNames creates client_node_names the way the ingestor does +// (cmd/ingestor/db.go); the server never creates it. +func createClientNodeNames(t *testing.T, db *DB) { + t.Helper() + mustExecDB(t, db, `CREATE TABLE client_node_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, target TEXT NOT NULL, rx_pubkey TEXT NOT NULL, + observed_at TEXT NOT NULL, ingested_at TEXT NOT NULL, name TEXT NOT NULL, + lat REAL, lon REAL, pos_acc_m REAL, repeater_clock INTEGER, + UNIQUE(target, rx_pubkey, observed_at))`) + mustExecDB(t, db, `CREATE INDEX idx_cnn_target ON client_node_names(target, observed_at)`) +} + +func insClientName(t *testing.T, db *DB, target, name, observedAt string) { + t.Helper() + mustExecDB(t, db, `INSERT INTO client_node_names (target, rx_pubkey, observed_at, ingested_at, name) + VALUES ('`+target+`','aa11','`+observedAt+`','`+observedAt+`','`+name+`')`) +} + +const ( + cnnOnlyPK = "c0cd110000000000000000000000000000000000000000000000000000000000" // no nodes row + cnnNoNamePK = "c1c1220000000000000000000000000000000000000000000000000000000000" // nodes row, empty name + cnnAdvertPK = "c2c2330000000000000000000000000000000000000000000000000000000000" // nodes row with a name + cnnClashNode = "e470110000000000000000000000000000000000000000000000000000000000" // nodes, shares e470 + cnnClashCli = "e470220000000000000000000000000000000000000000000000000000000000" // client only, shares e470 + cnnHiddenPK = "c3c3440000000000000000000000000000000000000000000000000000000000" + cnnBlackPK = "c4c4550000000000000000000000000000000000000000000000000000000000" +) + +func seedClientNames(t *testing.T) *Server { + t.Helper() + db := setupTestDBv2(t) + createClientNodeNames(t, db) + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+cnnNoNamePK+`','','repeater')`) + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+cnnAdvertPK+`','FromAdvert','repeater')`) + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+cnnClashNode+`','Aegis','repeater')`) + insClientName(t, db, cnnOnlyPK, "Old name", "2026-10-01T10:00:00.000Z") + insClientName(t, db, cnnOnlyPK, "Self Named", "2026-10-16T10:00:00.000Z") + insClientName(t, db, cnnNoNamePK, "Filled In", "2026-10-16T10:00:00.000Z") + insClientName(t, db, cnnAdvertPK, "Self Report", "2026-10-16T10:00:00.000Z") + insClientName(t, db, cnnClashCli, "Other", "2026-10-16T10:00:00.000Z") + insClientName(t, db, cnnHiddenPK, "🚫Secret", "2026-10-16T10:00:00.000Z") + insClientName(t, db, cnnBlackPK, "Blacklisted", "2026-10-16T10:00:00.000Z") + return &Server{db: db, cfg: &Config{ + NodeBlacklist: []string{cnnBlackPK}, + HiddenNamePrefixes: []string{"🚫"}, + }} +} + +func TestBatchResolveHeardKeysClientNames(t *testing.T) { + srv := seedClientNames(t) + got := srv.batchResolveHeardKeys([]string{"c0cd", "c1c122", "c2c2", "e470", "c3c3", "c4c4", cnnOnlyPK}) + cases := map[string][2]string{ + "c0cd": {cnnOnlyPK, "Self Named"}, // only known from client_node_names, newest observation + "c1c122": {cnnNoNamePK, "Filled In"}, // nodes row without a name: client name fills it + "c2c2": {cnnAdvertPK, "FromAdvert"}, // the advert name wins over a client report + "e470": {"e470", ""}, // one candidate per source: still ambiguous + "c3c3": {"c3c3", ""}, // hidden-prefix client name + "c4c4": {"c4c4", ""}, // blacklisted pubkey + cnnOnlyPK: {cnnOnlyPK, "Self Named"}, // full pubkey + } + for k, want := range cases { + if got[k] != want { + t.Errorf("batchResolveHeardKeys[%q] = %v, want %v", k, got[k], want) + } + } +} + +func TestResolvePrefixClientNames(t *testing.T) { + srv := seedClientNames(t) + resolve := func(pfx string) ResolvePrefixResp { + var r ResolvePrefixResp + json.Unmarshal(serveResolve(srv, "/api/nodes/resolve?prefix="+pfx).Body.Bytes(), &r) + return r + } + if r := resolve("c0cd"); r.Pubkey != cnnOnlyPK || r.Name != "Self Named" || r.Ambiguous { + t.Errorf("client-only node: %+v", r) + } + if r := resolve("c1c1"); r.Pubkey != cnnNoNamePK || r.Name != "Filled In" { + t.Errorf("nameless nodes row: %+v", r) + } + if r := resolve("c2c2"); r.Name != "FromAdvert" { + t.Errorf("advert name must win: %+v", r) + } + if r := resolve("e470"); !r.Ambiguous || r.Name != "" || r.Pubkey != "" { + t.Errorf("collision across sources must be ambiguous: %+v", r) + } + if r := resolve("c3c3"); r.Name != "" || r.Pubkey != "" || r.Ambiguous { + t.Errorf("hidden client name must resolve as not-found: %+v", r) + } + if r := resolve("c4c4"); r.Name != "" || r.Pubkey != "" || r.Ambiguous { + t.Errorf("blacklisted client-named node must resolve as not-found: %+v", r) + } +} + +// A database without the table (an older ingestor) resolves from nodes alone, +// and picks the table up once the ingestor creates it, without a restart. +func TestClientNodeNamesTableAbsentThenCreated(t *testing.T) { + db := setupTestDBv2(t) + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role) VALUES ('`+cnnAdvertPK+`','FromAdvert','repeater')`) + srv := &Server{db: db, cfg: &Config{}} + + got := srv.batchResolveHeardKeys([]string{"c2c2", "c0cd"}) + if got["c2c2"] != [2]string{cnnAdvertPK, "FromAdvert"} || got["c0cd"] != [2]string{"c0cd", ""} { + t.Fatalf("without the table: %v", got) + } + + createClientNodeNames(t, db) + insClientName(t, db, cnnOnlyPK, "Self Named", "2026-10-16T10:00:00.000Z") + if got := srv.batchResolveHeardKeys([]string{"c0cd"}); got["c0cd"] != [2]string{cnnOnlyPK, "Self Named"} { + t.Fatalf("after the ingestor created the table: %v", got) + } +} + +// Every key costs one query per chunk, whatever the number of keys: 250 keys +// span two chunks of at most 200, each within SQLite's 500-term compound limit. +func TestLookupPrefixesManyKeys(t *testing.T) { + srv := seedClientNames(t) + keys := make([]string, 0, 250) + for i := 0; i < 249; i++ { + keys = append(keys, "ffff"+string("0123456789abcdef"[i%16])+string("0123456789abcdef"[(i/16)%16])) + } + keys = append(keys, "c0cd") + hits := srv.lookupPrefixes(keys) + if len(hits) != len(keys) { + t.Fatalf("hits = %d, want %d (a chunk failed)", len(hits), len(keys)) + } + if h := hits["c0cd"]; h.pubkey != cnnOnlyPK || h.name != "Self Named" { + t.Fatalf("c0cd in the second chunk: %+v", h) + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 68ff61550..494fda679 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -40,6 +40,7 @@ const routeTypeNonTransportSQL = "route_type IN (1, 2)" type DB struct { advertEvidenceTable atomic.Bool txRewriteFeedTable atomic.Bool // tx_rewrite_feed present (#2107); re-probed while absent + clientNodeNamesTable atomic.Bool // client_node_names present; re-probed while absent (clientNodeNamesPresent) advertEvidenceReadHook func() // test-only: immediately before a bulk mask query conn *sql.DB path string // filesystem path to the database file diff --git a/cmd/server/node_resolve.go b/cmd/server/node_resolve.go index bf5bd5ff6..5d5827db7 100644 --- a/cmd/server/node_resolve.go +++ b/cmd/server/node_resolve.go @@ -39,37 +39,26 @@ func (s *Server) handleResolvePrefix(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusServiceUnavailable) return } - // LIMIT 2: we only need to know unique vs ambiguous. nodes.public_key is the - // PK and stored lowercase; pfx is validated hex so the LIKE pattern is safe. - rows, err := s.db.conn.Query(`SELECT public_key, COALESCE(name,'') FROM nodes WHERE public_key LIKE ? LIMIT 2`, pfx+"%") - if err != nil { + // lookupPrefixes counts candidates in nodes and in client_node_names (a + // repeater's own reported name), so a node known only from that table + // resolves too, and one that collides across the two stays ambiguous. + h, ok := s.lookupPrefixes([]string{pfx})[pfx] + if !ok { http.Error(w, "query failed", http.StatusInternalServerError) return } - defer rows.Close() - var pks, names []string - for rows.Next() { - var pk, nm string - if err := rows.Scan(&pk, &nm); err != nil { - http.Error(w, "scan failed", http.StatusInternalServerError) - return - } - pks = append(pks, pk) - names = append(names, nm) - } - resp := ResolvePrefixResp{Prefix: pfx} - switch len(pks) { - case 1: + switch { + case h.matches == 1: // Parity with /api/nodes/search and /api/resolve-hops: never reveal the // identity of a blacklisted or hidden-prefix node (#1181). Report it as // not-found rather than leaking the name the rest of the API hides. - if !s.cfg.IsBlacklisted(pks[0]) && !s.cfg.IsNameHidden(names[0]) { - resp.Pubkey = pks[0] - resp.Name = names[0] + if s.visible(h) { + resp.Pubkey = h.pubkey + resp.Name = h.name } default: - resp.Ambiguous = len(pks) > 1 // 0 β†’ not found (name empty), >1 β†’ ambiguous + resp.Ambiguous = h.matches > 1 // 0 β†’ not found (name empty), >1 β†’ ambiguous } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) diff --git a/cmd/server/rx_dashboard.go b/cmd/server/rx_dashboard.go index b5c04a942..eaef831dc 100644 --- a/cmd/server/rx_dashboard.go +++ b/cmd/server/rx_dashboard.go @@ -64,11 +64,11 @@ func (s *Server) heardKeyResolverFor(rows []coverageRow) nodeResolver { } // batchResolveHeardKeys resolves many heard_keys (2-3 byte prefixes or full -// pubkeys) to their canonical (pubkey, name) in a single round-trip per chunk: a -// UNION ALL of one LIMIT-2 prefix lookup each, so per-key work stays bounded -// (2 rows) and the whole set costs one query, not N. A unique match returns -// [pubkey, name]; unknown / ambiguous / blacklisted / hidden-prefix keys (#15, -// #1181) return [heardKey, ""]. +// pubkeys) to their canonical (pubkey, name) in a single round-trip per chunk +// (lookupPrefixes), so the whole set costs one query, not N. A unique match +// returns [pubkey, name], where name falls back to the repeater's own reported +// name (client_node_names) when nodes has none; unknown / ambiguous / +// blacklisted / hidden-prefix keys (#15, #1181) return [heardKey, ""]. func (s *Server) batchResolveHeardKeys(keys []string) map[string][2]string { res := make(map[string][2]string, len(keys)) valid := make([]string, 0, len(keys)) @@ -84,60 +84,12 @@ func (s *Server) batchResolveHeardKeys(keys []string) map[string][2]string { } valid = append(valid, k) } - // SQLITE_MAX_COMPOUND_SELECT is 500 by default; chunk well under it. - const chunk = 200 - for i := 0; i < len(valid); i += chunk { - end := i + chunk - if end > len(valid) { - end = len(valid) - } - batch := valid[i:end] - parts := make([]string, len(batch)) - args := make([]interface{}, 0, len(batch)*2) - for j, k := range batch { - // Parameterized: the prefix flows in as bound args, never interpolated, - // so this stays injection-safe regardless of how hexPrefixRe later - // evolves. The per-prefix LIMIT 2 lives in a subquery because a bare - // LIMIT on a UNION ALL term is a SQLite syntax error. - parts[j] = "SELECT * FROM (SELECT ? AS pfx, public_key, COALESCE(name,'') AS nm FROM nodes WHERE public_key LIKE ? LIMIT 2)" - args = append(args, k, k+"%") - } - 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 batchResolveHeardKeys: %v", err) - for _, k := range batch { - res[k] = [2]string{k, ""} - } - continue - } - type agg struct { - pk, name string - cnt int - } - acc := map[string]*agg{} - for rows.Next() { - var pfx, pk, nm string - if err := rows.Scan(&pfx, &pk, &nm); err != nil { - continue - } - a := acc[pfx] - if a == nil { - a = &agg{} - acc[pfx] = a - } - a.cnt++ - a.pk, a.name = pk, nm - } - rows.Close() - for _, k := range batch { - a := acc[k] - if a != nil && a.cnt == 1 && !s.cfg.IsBlacklisted(a.pk) && !s.cfg.IsNameHidden(a.name) { - res[k] = [2]string{a.pk, a.name} - } else { - res[k] = [2]string{k, ""} - } + hits := s.lookupPrefixes(valid) + for _, k := range valid { + if h, ok := hits[k]; ok && s.visible(h) { + res[k] = [2]string{h.pubkey, h.name} + } else { + res[k] = [2]string{k, ""} } } return res diff --git a/config.example.json b/config.example.json index 74809dee6..ddc6abff2 100644 --- a/config.example.json +++ b/config.example.json @@ -16,7 +16,8 @@ "clientRxObsDays": 30, "clientRfDays": 30, "clientRegionsDays": 30, - "_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). observerPurgeDays: observers already marked inactive AND not seen in N days are hard-deleted, but only when no observation, observer_metrics or dropped_packets row still references them (0 = disabled, the default). Set it above both observerDays and packetDays β€” below those the reference guards keep every row anyway. packetDays: transmissions older than N days are deleted (0 = disabled). clientRxDays: mobile client-RX coverage rows (client_receptions/client_observers) older than N days are deleted (0 = disabled) β€” bounds the opt-in coverage tables (#1727). clientRxObsDays: diagnostic client_rx_observations rows (by rx_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRxObservations table. clientRfDays: RF environment sample rows (client_rf_samples, by sampled_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRfSamples table; unset means unbounded growth at roughly 240 rows/hour per driver. clientRegionsDays: region-discovery answer rows (node_declared_regions, by observed_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRegions table. NOTE (#1283): all retention fields are consumed by the INGESTOR process. The server is read-only and never prunes." + "clientNodeNamesDays": 0, + "_comment": "nodeDays: nodes not seen in N days moved to inactive_nodes (default 7). observerDays: observers not sending data in N days are removed (-1 = keep forever, default 14). observerPurgeDays: observers already marked inactive AND not seen in N days are hard-deleted, but only when no observation, observer_metrics or dropped_packets row still references them (0 = disabled, the default). Set it above both observerDays and packetDays β€” below those the reference guards keep every row anyway. packetDays: transmissions older than N days are deleted (0 = disabled). clientRxDays: mobile client-RX coverage rows (client_receptions/client_observers) older than N days are deleted (0 = disabled) β€” bounds the opt-in coverage tables (#1727). clientRxObsDays: diagnostic client_rx_observations rows (by rx_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRxObservations table. clientRfDays: RF environment sample rows (client_rf_samples, by sampled_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRfSamples table; unset means unbounded growth at roughly 240 rows/hour per driver. clientRegionsDays: region-discovery answer rows (node_declared_regions, by observed_at) older than N days are deleted (0 = disabled) β€” bounds the opt-in clientRegions table. clientNodeNamesDays: repeater-reported node names (client_node_names, by observed_at) older than N days are deleted (0 = disabled, keep them): one row per answer, and a pruned name is asked for again by the next companion that hears the repeater. NOTE (#1283): all retention fields are consumed by the INGESTOR process. The server is read-only and never prunes." }, "db": { "vacuumOnStartup": false, @@ -389,6 +390,8 @@ }, "_comment_compression": "Opt-in HTTP gzip middleware + WebSocket permessage-deflate. Both default to false β€” enable ONLY when your upstream reverse proxy is NOT already compressing. gzip: enables the gzipMiddleware wrapper around the HTTP handler. websocket: sets gorilla websocket Upgrader.EnableCompression. level: gzip compression level 1..9 (1=BestSpeed, 9=BestCompression, default 6). minSizeBytes: advisory minimum response size below which compression would not pay off. contentTypes: MIME allow-list β€” only responses with these Content-Type values are compressed. Already-compressed types (image/*, video/*, audio/*, application/zip, application/x-gzip, application/pdf, application/octet-stream) are always skipped, as are responses whose handler already set Content-Encoding. Omit contentTypes to use the built-in default allow-list.", "clientRegions": { "enabled": false }, + "clientNodeNames": { "enabled": false }, + "_comment_clientNodeNames": "Opt-in repeater names: when this instance has no name for a repeater a mobile companion hears directly, the companion asks the repeater for its own name (ANON_REQ_TYPE_OWNER) and relays it on meshcore/client//names, stored in client_node_names. /api/nodes/resolve and the coverage tooltips fall back to it when nodes has no name; an advert name always wins. Default OFF. TOP-LEVEL FLAG, same as clientRegions. GPS is optional. Requires the broker ACL to allow meshcore/client/{pubkey}/names for that publisher (see docs/client-rx-coverage.md). Retention: see retention.clientNodeNamesDays.", "_comment_clientRegions": "Opt-in region-discovery answers: a mobile companion asks each repeater in direct RF range which regions it declares flood-allowed, and relays the answer on meshcore/client//regions, stored in node_declared_regions β€” the declared side of a comparison against the OBSERVED transported_scopes already surfaced elsewhere. Default OFF, independent of clientRxCoverage/clientRxObservations/clientRfSamples: a deployment can run any combination. TOP-LEVEL FLAG, same as clientRfSamples. An empty regions list is a valid, stored answer ('nothing flood-allowed'); a repeater that never answers produces no row at all β€” silence carries no meaning, since the repeater ignores non-DIRECT requests. GPS is optional here (unlike the coverage/RF streams): a declared-regions answer is meaningful even without a position, so a missing fix is stored as NULL lat/lon rather than dropping the row. Requires an ACL-capable broker binding meshcore/client/{pubkey}/regions to that publisher β€” see docs/client-regions.md. Retention: see retention.clientRegionsDays; leaving it unset means unbounded growth.", "clientRxCoverage": { "enabled": false }, "_comment_clientRxCoverage": "Opt-in mobile client-RX coverage (corescope-rx companions publishing GPS-tagged receptions to meshcore/client//packets). Default OFF: when disabled the ingestor writes no client_receptions, the /api/rx-coverage|rx-leaderboard|nodes/{pubkey}/rx-coverage endpoints 404, and the UI hides the Coverage dashboard + Reach overlay. Set enabled=true to turn it on. SINGLE FLAG, BOTH PROCESSES: the ingestor and server each parse this same config.json, so this one clientRxCoverage.enabled entry gates both the ingest write path and the read endpoints β€” set it once, not per-process. TRUST: the feature requires an ACL-capable broker binding meshcore/client/{pubkey}/packets to that publisher; without ACLs the companion GPS is spoofable (see docs/client-rx-coverage.md). Retention: see retention.clientRxDays. Companion app + setup: https://github.com/efiten/corescope-rx.", diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 2200203ad..5ee2b727a 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -122,7 +122,7 @@ Payload β€” meshcoretomqtt-compatible packet, plus a `gps` object: ### Region answers β€” `meshcore/client/{PUBLIC_KEY}/regions` CoreDrive RX can also ask a repeater it hears directly which regions it is configured to flood, and -publishes the answer here. It is accepted under the same `clientRxCoverage.enabled` switch and lands +publishes the answer here. It is accepted only when `clientRegions.enabled` is set (`cmd/ingestor/main.go`) and lands in `node_declared_regions`, which the Scope Audit page and the `autoRegionKeys` tier read alongside the observer `/neighbors` source (`nodes.configured_scope`). Newest answer per repeater wins across both, by the answer's own `timestamp`, so a buffered upload arriving late cannot overwrite a fresher @@ -147,6 +147,38 @@ one. - The broker ACL must allow the client to publish to `/regions` as well as `/packets` and `/rf`. Explicit topic lists need `meshcore/client/+/regions` (or `meshcore/client/#`). +### Node names: `meshcore/client/{PUBLIC_KEY}/names` + +When this instance has no name for a repeater the companion hears directly (`/api/nodes/resolve` +answers without a `name`), CoreDrive RX v1.20.0 and later ask the repeater itself +(`ANON_REQ_TYPE_OWNER`) and publish its answer here. Accepted only when `clientNodeNames.enabled` is +set; it lands in `client_node_names`. + +```json +{ + "origin": "", + "origin_id": "", + "timestamp": "2026-10-16T09:12:04.512Z", + "type": "NODE_NAME", + "target": "", + "name": "", + "repeater_clock": 1789000000, + "gps": { "lat": 51.05, "lon": 3.72, "acc_m": 8 } +} +``` + +- `target` must be a full 64-hex pubkey. `name` is trimmed (spaces and NUL padding) and dropped when + it is empty, longer than 31 characters (the firmware's `node_name[32]`), not valid UTF-8, or holds + a control character. `gps` is optional. The owner info the repeater also sends is not published. +- `/api/nodes/resolve` and the coverage tooltips use the newest name per repeater (by `timestamp`) + when `nodes` has no name for it, including a repeater with no `nodes` row at all. An advert name + always wins. A prefix that matches different pubkeys across `nodes` and `client_node_names` stays + ambiguous. The node blacklist and hidden name prefixes apply to these names too. +- Any companion the broker lets publish can report a name for any repeater; the newest report wins. + `clientRxCoverage.requireLinkedCompanion` limits that to linked companions. +- The broker ACL must allow `/names`. Explicit topic lists need `meshcore/client/+/names` (or + `meshcore/client/#`). + ## Capture HARD RULE β€” only what was heard directly The app and ingestor record **only the node the companion physically received**, never upstream