mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 12:22:41 +00:00
Closes #1816. Closes #1818 (confirmed duplicate of #1816 by the triage bot). ## Root cause `byNode` is an involvement index (`indexResolvedPathHops`, `store.go:1696-1705`, #1558/#1352): a transmission is indexed under every relay-hop pubkey found in an observation's `resolved_path`, not just its originator. `getNodeClockSkewLocked` (`clock_skew.go:489`) iterated every ADVERT transaction under a pubkey without checking who actually signed it, so a relay inherited the clock skew of every broken-clock node it forwarded as if it were its own. This produced: - Fleet-wide false `no_clock`/`bimodal_clock` classifications on healthy relays whose only "bad" samples were adverts they merely relayed. - Bit-identical `RecentMedianSkewSec` "clusters" across unrelated relays that all forwarded the same broken-clock originator. - Single relays showing a multi-day skew even though their own self-adverts are healthy, because 1-2 relayed adverts from a broken originator landed in the tail of their small recent-window sample (the #1818 "island" repro from @cwichura). ## Fix Add `txOriginatedBy(tx, pubkey)`: ADVERTs are self-signed, so `decoded["pubKey"]` is the originator per protocol (case-insensitive compare as a defensive measure). Apply it as a guard in both the main skew-aggregation loop and the per-hash evidence loop in `getNodeClockSkewLocked`. `byNode` itself is untouched — #1558/#1352 still rely on the broader involvement index for other consumers. ## Tests - Existing `clock_skew_test.go` / `clock_skew_issue1094_test.go` / `clock_skew_issue1285_test.go` fixtures built synthetic ADVERT transactions without a `pubKey` field and seeded `s.byNode` directly, bypassing the normal `indexByNode` path where every real ADVERT carries `pubKey`. Added `pubKey` to each fixture so it reflects a self-originated advert, which is what these tests already intended to represent. All pre-existing tests pass unchanged in behavior. - New `clock_skew_issue1816_test.go`: - `TestTxOriginatedBy` — unit coverage of the new guard (self, foreign, missing pubKey, case-insensitivity). - `TestIssue1816_RelayDoesNotInheritOriginatorSkew` — a relay with healthy self-adverts plus relayed adverts from a broken-clock originator (matching the report's +100.5k s band) must report `ok` severity based only on its own adverts. - `TestIssue1816_PureRelaysReportNoSkew_NoBitIdenticalCluster` — five relay pubkeys that only ever forward a broken originator's advert (never self-advert) must report `nil`, not a bit-identical copy of the originator's skew. - `TestIssue1818_TwoForeignAdvertsDoNotPoisonIslandNode` — reproduces the cwichura island scenario: 8 healthy self-adverts + 2 foreign adverts at ~10 days skew must not flip severity or pollute `RecentMedianSkewSec`. Full suite: `go test ./...` passes (one pre-existing, unrelated flaky test — `TestHandleNodePaths_PrefixCollision_1352`, an index-loading race — reproduces intermittently on unmodified `master` too). Operator context: running CoreScope for SaarMesh (SaarLorLux, DE/FR/LU, 800+ nodes); this bug was surfacing as fleet-wide clock-skew false positives on our infra nodes. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
288 lines
11 KiB
Go
288 lines
11 KiB
Go
package main
|
|
|
|
// Regression tests for #1816 / #1818:
|
|
//
|
|
// getNodeClockSkewLocked() iterated every ADVERT transaction indexed under
|
|
// a pubkey in s.byNode WITHOUT checking whether the node actually
|
|
// originated (self-signed) that advert. byNode is an involvement index
|
|
// (store.go:1696-1705, indexResolvedPathHops, #1558/#1352): a transmission
|
|
// is also indexed under every relay-hop pubkey extracted from an
|
|
// observation's resolved_path. So a relay that merely forwarded a
|
|
// broken-clock node's advert inherited that node's skew as if it were its
|
|
// own — producing:
|
|
//
|
|
// - Fleet-wide false no_clock / bimodal_clock classifications on healthy
|
|
// relays whose only "bad" samples are adverts they relayed (#1816).
|
|
// - Bit-identical RecentMedianSkewSec "clusters" across unrelated relays
|
|
// that all forwarded the same broken-clock originator (#1816).
|
|
// - A single relay showing a nonsense multi-day skew even though its own
|
|
// self-adverts are perfectly healthy, because 1-2 relayed adverts from
|
|
// a broken originator landed in the tail of its small recent-window
|
|
// sample (#1818 — the "island" repro from cwichura).
|
|
//
|
|
// The fix (txOriginatedBy in clock_skew.go) restricts the ADVERT filter in
|
|
// getNodeClockSkewLocked to self-originated adverts only (decoded pubKey
|
|
// == pubkey), leaving byNode itself untouched (#1558/#1352 still depend on
|
|
// the broader involvement index for other consumers).
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ── txOriginatedBy ───────────────────────────────────────────────────────────
|
|
|
|
func TestTxOriginatedBy(t *testing.T) {
|
|
pt := 4 // ADVERT
|
|
selfTx := &StoreTx{
|
|
Hash: "self-1",
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":1700000000},"pubKey":"AABBCC"}`,
|
|
}
|
|
foreignTx := &StoreTx{
|
|
Hash: "foreign-1",
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":1700000000},"pubKey":"DDEEFF"}`,
|
|
}
|
|
noPubkeyTx := &StoreTx{
|
|
Hash: "nopk-1",
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":1700000000}}`,
|
|
}
|
|
mixedCaseTx := &StoreTx{
|
|
Hash: "case-1",
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":1700000000},"pubKey":"aabbcc"}`,
|
|
}
|
|
|
|
if !txOriginatedBy(selfTx, "AABBCC") {
|
|
t.Error("expected self-signed advert to match its own pubkey")
|
|
}
|
|
if txOriginatedBy(foreignTx, "AABBCC") {
|
|
t.Error("expected advert from a different originator NOT to match")
|
|
}
|
|
if txOriginatedBy(noPubkeyTx, "AABBCC") {
|
|
t.Error("expected advert with no decoded pubKey NOT to match anything")
|
|
}
|
|
if !txOriginatedBy(mixedCaseTx, "AABBCC") {
|
|
t.Error("expected case-insensitive pubkey match to succeed")
|
|
}
|
|
}
|
|
|
|
// ── #1816: relay must not inherit the originator's skew ───────────────────────
|
|
|
|
// A relay ("RELAY001") only ever transmits healthy self-adverts (skew ~0s).
|
|
// It also forwards adverts from a broken-clock originator ("BROKENORIG",
|
|
// skew ~+100,500s, matching the #1816 report's +100.5k band) — those
|
|
// transmissions are indexed under RELAY001 in byNode too, exactly as
|
|
// indexResolvedPathHops does for real relay-hop traffic. Before the fix,
|
|
// these foreign adverts polluted RELAY001's skew stream and could flip its
|
|
// severity to no_clock/bimodal even though every advert RELAY001 itself
|
|
// sent is fine.
|
|
func TestIssue1816_RelayDoesNotInheritOriginatorSkew(t *testing.T) {
|
|
ps := NewPacketStore(nil, nil)
|
|
pt := 4 // ADVERT
|
|
baseObs := int64(1700000000)
|
|
|
|
var relayTxs []*StoreTx
|
|
|
|
// RELAY001's own healthy adverts: skew ~0s.
|
|
for i := 0; i < 5; i++ {
|
|
obsTS := baseObs + int64(i)*300
|
|
advTS := obsTS - 2 // 2s behind, trivially healthy
|
|
tx := &StoreTx{
|
|
Hash: "relay-self-" + formatInt64(int64(i)),
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `},"pubKey":"RELAY001"}`,
|
|
Observations: []*StoreObs{
|
|
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
|
},
|
|
}
|
|
relayTxs = append(relayTxs, tx)
|
|
}
|
|
|
|
// BROKENORIG's adverts: skew ~+100,500s (matches the #1816 band).
|
|
// These are indexed under RELAY001 too (simulating indexResolvedPathHops
|
|
// adding the transmission under the relay-hop pubkey), but their
|
|
// decoded pubKey is BROKENORIG, not RELAY001.
|
|
for i := 0; i < 5; i++ {
|
|
obsTS := baseObs + int64(100+i)*300
|
|
advTS := obsTS + 100500
|
|
tx := &StoreTx{
|
|
Hash: "relayed-foreign-" + formatInt64(int64(i)),
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `},"pubKey":"BROKENORIG"}`,
|
|
Observations: []*StoreObs{
|
|
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
|
},
|
|
}
|
|
relayTxs = append(relayTxs, tx)
|
|
}
|
|
|
|
ps.mu.Lock()
|
|
ps.byNode["RELAY001"] = relayTxs
|
|
for _, tx := range relayTxs {
|
|
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
|
}
|
|
ps.clockSkew.computeInterval = 0
|
|
ps.mu.Unlock()
|
|
|
|
r := ps.GetNodeClockSkew("RELAY001")
|
|
if r == nil {
|
|
t.Fatal("expected clock skew result for RELAY001")
|
|
}
|
|
if r.Severity != SkewOK {
|
|
t.Errorf("severity = %v, want ok — relay's own adverts are healthy; "+
|
|
"relayed foreign adverts must not be attributed to it", r.Severity)
|
|
}
|
|
if abs(r.RecentMedianSkewSec) > 5 {
|
|
t.Errorf("recentMedianSkewSec = %v, want ~0 (only RELAY001's own adverts "+
|
|
"should count, not the relayed +100.5k s originator adverts)", r.RecentMedianSkewSec)
|
|
}
|
|
// Sample count must reflect only the 5 self-originated adverts.
|
|
if r.RecentSampleCount != 5 {
|
|
t.Errorf("recentSampleCount = %d, want 5 (relayed adverts must be excluded)", r.RecentSampleCount)
|
|
}
|
|
}
|
|
|
|
// ── #1816: bit-identical clusters across relays disappear ─────────────────────
|
|
|
|
// Five distinct relay pubkeys never self-advert at all — they only forward
|
|
// a single broken-clock originator's advert. Before the fix, each relay's
|
|
// GetNodeClockSkew would report the SAME bit-identical skew as the
|
|
// originator (a false "cluster"). After the fix, a node with zero
|
|
// self-originated adverts must report no clock-skew data at all (nil),
|
|
// since it never actually said anything about its own clock.
|
|
func TestIssue1816_PureRelaysReportNoSkew_NoBitIdenticalCluster(t *testing.T) {
|
|
ps := NewPacketStore(nil, nil)
|
|
pt := 4 // ADVERT
|
|
baseObs := int64(1700000000)
|
|
|
|
const originator = "BROKENORIG2"
|
|
obsTS := baseObs
|
|
advTS := obsTS + 201000 // matches the #1816 report's +201k s band
|
|
|
|
originatorTx := &StoreTx{
|
|
Hash: "originator-advert-1",
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `},"pubKey":"` + originator + `"}`,
|
|
Observations: []*StoreObs{
|
|
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
|
},
|
|
}
|
|
|
|
relayKeys := []string{"RLY-A", "RLY-B", "RLY-C", "RLY-D", "RLY-E"}
|
|
|
|
ps.mu.Lock()
|
|
// The originator's own byNode entry (legitimate — it self-signed this advert).
|
|
ps.byNode[originator] = []*StoreTx{originatorTx}
|
|
// Each relay is ALSO indexed under this same transmission (as
|
|
// indexResolvedPathHops does for every relay hop in the resolved path),
|
|
// but none of them ever self-advert.
|
|
for _, rk := range relayKeys {
|
|
ps.byNode[rk] = []*StoreTx{originatorTx}
|
|
}
|
|
ps.byPayloadType[4] = []*StoreTx{originatorTx}
|
|
ps.clockSkew.computeInterval = 0
|
|
ps.mu.Unlock()
|
|
|
|
// The originator itself should still report its own (bad) skew.
|
|
origResult := ps.GetNodeClockSkew(originator)
|
|
if origResult == nil {
|
|
t.Fatal("expected clock skew result for the originator")
|
|
}
|
|
if abs(origResult.RecentMedianSkewSec-201000) > 5 {
|
|
t.Errorf("originator recentMedianSkewSec = %v, want ~201000", origResult.RecentMedianSkewSec)
|
|
}
|
|
|
|
// None of the pure relays ever said anything about their own clock —
|
|
// they must report nil, not a copy of the originator's skew.
|
|
for _, rk := range relayKeys {
|
|
r := ps.GetNodeClockSkew(rk)
|
|
if r != nil {
|
|
t.Errorf("relay %s: expected nil (no self-originated adverts), "+
|
|
"got skew=%v — this is the bit-identical false cluster from #1816",
|
|
rk, r.RecentMedianSkewSec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── #1818: two foreign adverts must not poison an otherwise-healthy node ──────
|
|
|
|
// Reproduces the cwichura "island" report: a node's own adverts are all
|
|
// healthy (~0s skew), but two adverts from a broken-clock originator
|
|
// (skew ~10 days) are present in its byNode entry — exactly the volume
|
|
// described in the issue ("Only 2 adverts out of all the adverts seen are
|
|
// from other nodes"). Because getNodeClockSkewLocked sorts by time and
|
|
// takes only the last recentSkewWindowCount samples, even 2 catastrophic
|
|
// foreign samples can dominate the tail of a small window. After the fix,
|
|
// they must be excluded entirely since they aren't self-originated.
|
|
func TestIssue1818_TwoForeignAdvertsDoNotPoisonIslandNode(t *testing.T) {
|
|
ps := NewPacketStore(nil, nil)
|
|
pt := 4 // ADVERT
|
|
baseObs := int64(1700000000)
|
|
|
|
const island = "ISLAND001"
|
|
var txs []*StoreTx
|
|
|
|
// Plenty of healthy self-adverts (island node mostly talks to itself).
|
|
for i := 0; i < 8; i++ {
|
|
obsTS := baseObs + int64(i)*300
|
|
advTS := obsTS - 1 // ~0s skew
|
|
tx := &StoreTx{
|
|
Hash: "island-self-" + formatInt64(int64(i)),
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `},"pubKey":"` + island + `"}`,
|
|
Observations: []*StoreObs{
|
|
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
|
},
|
|
}
|
|
txs = append(txs, tx)
|
|
}
|
|
|
|
// Two foreign adverts from a broken-clock originator, landing at the
|
|
// tail of the recent window (most recent timestamps), skew ~10 days —
|
|
// matching the report's "off by nearly ten days".
|
|
for i := 0; i < 2; i++ {
|
|
obsTS := baseObs + int64(100+i)*300
|
|
advTS := obsTS + 10*86400
|
|
tx := &StoreTx{
|
|
Hash: "island-foreign-" + formatInt64(int64(i)),
|
|
PayloadType: &pt,
|
|
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `},"pubKey":"OFFCLOCKORIG"}`,
|
|
Observations: []*StoreObs{
|
|
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
|
|
},
|
|
}
|
|
txs = append(txs, tx)
|
|
}
|
|
|
|
ps.mu.Lock()
|
|
ps.byNode[island] = txs
|
|
for _, tx := range txs {
|
|
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
|
|
}
|
|
ps.clockSkew.computeInterval = 0
|
|
ps.mu.Unlock()
|
|
|
|
r := ps.GetNodeClockSkew(island)
|
|
if r == nil {
|
|
t.Fatal("expected clock skew result for island node")
|
|
}
|
|
if r.Severity == SkewNoClock || r.Severity == SkewBimodalClock {
|
|
t.Errorf("severity = %v, want ok/warning — the node's own adverts are "+
|
|
"healthy; the two foreign adverts must not be attributed to it (#1818)",
|
|
r.Severity)
|
|
}
|
|
if abs(r.RecentMedianSkewSec) > 5 {
|
|
t.Errorf("recentMedianSkewSec = %v, want ~0 (foreign adverts excluded)", r.RecentMedianSkewSec)
|
|
}
|
|
// recentSkewWindowCount caps the window at 5 most-recent samples; with
|
|
// the 2 foreign adverts excluded, those 5 must all come from the
|
|
// island node's own healthy stream.
|
|
if r.RecentSampleCount != recentSkewWindowCount {
|
|
t.Errorf("recentSampleCount = %d, want %d (only self-originated adverts, "+
|
|
"windowed)", r.RecentSampleCount, recentSkewWindowCount)
|
|
}
|
|
}
|