Files
meshcore-analyzer/cmd/server/obs_dedup.go
T
n30nex abad8a26b3 fix(liveness): require evidence from each observed relay frame (#2298)
Red commit: `46e3b082` ([regression
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40203680455)).

Fixes #2266 and #2267. Certain relay activity now requires an eligible
path from the observation that supplied it. A representative
transmission header, destination attribution or catalogue uniqueness
alone cannot supply that evidence. Later valid FLOOD evidence survives
observer/path deduplication.

The fix preserves valid own packets and excludes failed signatures,
planned DIRECT/TRACE paths, ambiguous/retired prefixes and missing or
mismatched headers. Firmware reference: meshcore-dev/MeshCore
`81dbae59`.

- One eligibility byte replaces an unused raw-frame field. StoreObs
remains 152 bytes in the protected 160-byte allocation class; no wire
frames are retained. Existing dedup maps gain one pointer per slot only
at 64+ observations.
- Processing is linear in retained paths, bounded by 64 wire path bytes.
No new SQL, schema, configuration or maps.
- Tests cover five load/ingest paths, late duplicates,
analytics/adaptive thresholds, positive controls and concurrent readers.
Review correction `4ad6c5ad` preserves lazy decoded caches while
retaining escaped/legacy signature rejection.
- Full server suite, focused/race checks and frontend checks pass.
Browser verified on http://127.0.0.1:18905: both upstream false-positive
cases disappear and a genuine later FLOOD is recognized.

Independent review findings are addressed. Local preflight checks
passed; external home-directory bundle unavailable. [Final
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40205015381)
passed on `4ad6c5ad`, including all three browser shards and both
architecture builds.
2026-10-26 15:42:01 +01:00

86 lines
3.0 KiB
Go

package main
// obsDedupKey identifies an observation within its transmission: the same
// observer reporting the same path is a duplicate.
type obsDedupKey struct{ observerID, pathJSON string }
// obsIndexThreshold is the observation count at which a transmission starts
// keeping maps for its duplicate and distinct-observer checks. Below it the
// checks scan tx.Observations, which compares string headers and costs
// nothing to keep; the maps cost a few hundred bytes per transmission, and
// most transmissions are heard a handful of times (a week of a busy regional
// network peaked at 40). A miss scans in about 150ns at 40 observations and
// 240ns at 63; past the threshold a map lookup is about 15ns. The maps' keys
// share the observations' strings, so building them allocates no key bytes.
const obsIndexThreshold = 64
// hasObservation reports whether tx already holds an observation from
// observerID with pathJSON.
func (tx *StoreTx) hasObservation(observerID, pathJSON string) bool {
return tx.observation(observerID, pathJSON) != nil
}
func (tx *StoreTx) observation(observerID, pathJSON string) *StoreObs {
if tx.obsKeys != nil {
return tx.obsKeys[obsDedupKey{observerID, pathJSON}]
}
for _, o := range tx.Observations {
if o.ObserverID == observerID && o.PathJSON == pathJSON {
return o
}
}
return nil
}
// mergeObservationEvidence preserves an eligible flood when observer/path
// dedup merges frames with different headers. The validated path fixes its
// width, so known evidence can only upgrade unknown/ineligible evidence.
// Caller owns tx privately during load or holds s.mu for writing.
func (tx *StoreTx) mergeObservationEvidence(observerID, pathJSON string, n uint8) *StoreObs {
obs := tx.observation(observerID, pathJSON)
if obs != nil && n != 0 {
obs.relayHopHexLen = n
}
return obs
}
// hasObserver reports whether any observation on tx came from observerID.
func (tx *StoreTx) hasObserver(observerID string) bool {
if tx.observerSet != nil {
_, ok := tx.observerSet[observerID]
return ok
}
for _, o := range tx.Observations {
if o.ObserverID == observerID {
return true
}
}
return false
}
// addObservation appends obs to tx and keeps UniqueObserverCount and
// ObservationCount in step. Callers check hasObservation first.
func (tx *StoreTx) addObservation(obs *StoreObs) {
if obs.ObserverID != "" && !tx.hasObserver(obs.ObserverID) {
tx.UniqueObserverCount++
}
tx.Observations = append(tx.Observations, obs)
tx.ObservationCount++
switch {
case tx.obsKeys != nil:
tx.obsKeys[obsDedupKey{obs.ObserverID, obs.PathJSON}] = obs
if obs.ObserverID != "" {
tx.observerSet[obs.ObserverID] = struct{}{}
}
case len(tx.Observations) >= obsIndexThreshold:
tx.obsKeys = make(map[obsDedupKey]*StoreObs, 2*len(tx.Observations))
tx.observerSet = make(map[string]struct{}, len(tx.Observations))
for _, o := range tx.Observations {
tx.obsKeys[obsDedupKey{o.ObserverID, o.PathJSON}] = o
if o.ObserverID != "" {
tx.observerSet[o.ObserverID] = struct{}{}
}
}
}
}