chanfitness: seed peer uptime from actual online state
What changed, and why it matters
This commit fixes a bug in LND's channel-fitness subsystem that made offline peers falsely appear to have 100% uptime. The fix seeds the initial uptime state from the peer's real connection status instead of always assuming online. It is a correctness/telemetry bug, not an exploitable security vulnerability.
Treat as a routine bug-fix commit. No security response required. Operators relying on uptime metrics should upgrade to obtain accurate peer uptime data.
Security signals we found
Incorrect telemetry/metric initialization leading to misleading uptime data
No input validation, authentication, or memory-safety changes
No cryptographic, networking, or privilege-boundary changes
Evidence from the diff
The chanfitness store previously called peerMonitor.onlineEvent(true) unconditionally when first tracking a peer, so ListChannels reported full uptime even if the peer was disconnected at startup. The patch adds an IsPeerOnline(route.Vertex) bool callback to Config and wires it in server.go via FindPeerByPubStr. getOrCreatePeerMonitor now seeds the first event with the actual online/offline state. A regression test verifies an offline peer starts with zero uptime and begins accruing uptime only after an online event.
Changed components
chanfitness/ChannelEventStorechanfitness/Config.IsPeerOnline callbackserver.go newServer channel-fitness wiringListChannels uptime reportingInspect captured patch +77 / −2
diff --git a/chanfitness/chaneventstore.go b/chanfitness/chaneventstore.go
index 881e9a3..a8014a8 100644
--- a/chanfitness/chaneventstore.go
+++ b/chanfitness/chaneventstore.go
@@ -86,6 +86,13 @@ type Config struct {
// startup.
GetOpenChannels func() ([]*channeldb.OpenChannel, error)
+ // IsPeerOnline returns whether the peer with the given pubkey is
+ // currently connected. It is used to seed the initial online state of a
+ // peer when we first start tracking it, so that uptime is calculated
+ // from the peer's actual connectivity rather than assuming it is
+ // online.
+ IsPeerOnline func(route.Vertex) bool
+
// Clock is the time source that the subsystem uses, provided here
// for ease of testing.
Clock clock.Clock
@@ -291,8 +298,8 @@ func (c *ChannelEventStore) getOrCreatePeerMonitor(
peerMonitor = newPeerLog(c.cfg.Clock, flapCount, lastFlap)
c.peers[peer] = peerMonitor
- // Send an online event given it's the first time we see this peer.
- peerMonitor.onlineEvent(true)
+ // Send an liveness event given it's the first time we see this peer.
+ peerMonitor.onlineEvent(c.cfg.IsPeerOnline(peer))
return peerMonitor, nil
}
diff --git a/chanfitness/chaneventstore_test.go b/chanfitness/chaneventstore_test.go
index ecec3ea..6867802 100644
--- a/chanfitness/chaneventstore_test.go
+++ b/chanfitness/chaneventstore_test.go
@@ -288,6 +288,59 @@ func TestGetChanInfo(t *testing.T) {
ctx.stop()
}
+// TestGetChanInfoOfflinePeer tests that a channel whose peer is offline when we
+// start tracking it reports zero uptime, rather than assuming the peer is
+// online (which would incorrectly report 100% uptime).
+func TestGetChanInfoOfflinePeer(t *testing.T) {
+ ctx := newChanEventStoreTestCtx(t)
+
+ // Report the peer as offline so that the channel open seeds an offline
+ // event instead of assuming the peer is connected.
+ ctx.peerOnline = func(route.Vertex) bool { return false }
+
+ ctx.start()
+
+ now := ctx.clock.Now()
+
+ peer, pk, channel := ctx.newChannel()
+ ctx.sendChannelOpenedUpdate(pk, channel)
+
+ // Wait for our channel to be recognized by our store.
+ require.Eventually(t, func() bool {
+ _, err := ctx.store.GetChanInfo(channel, peer)
+ return err == nil
+ }, timeout, time.Millisecond*20)
+
+ // Advance our clock by an hour. Since the peer has been offline the
+ // whole time, we expect the channel to have a full hour of lifetime but
+ // zero uptime.
+ now = now.Add(time.Hour)
+ ctx.clock.SetTime(now)
+
+ info, err := ctx.store.GetChanInfo(channel, peer)
+ require.NoError(t, err)
+ require.Equal(t, time.Hour, info.Lifetime)
+ require.Equal(t, time.Duration(0), info.Uptime)
+
+ // Once the peer comes online, uptime should start accruing from that
+ // point. We issue a blocking GetChanInfo afterwards to ensure the
+ // online event has been fully processed (and timestamped at the current
+ // time) by the store's main loop before we advance the clock.
+ ctx.peerEvent(peer, true)
+ _, err = ctx.store.GetChanInfo(channel, peer)
+ require.NoError(t, err)
+
+ now = now.Add(time.Hour)
+ ctx.clock.SetTime(now)
+
+ info, err = ctx.store.GetChanInfo(channel, peer)
+ require.NoError(t, err)
+ require.Equal(t, time.Hour*2, info.Lifetime)
+ require.Equal(t, time.Hour, info.Uptime)
+
+ ctx.stop()
+}
+
// TestFlapCount tests querying the store for peer flap counts, covering the
// case where the peer is tracked in memory, and the case where we need to
// lookup the peer on disk.
diff --git a/chanfitness/chaneventstore_testctx_test.go b/chanfitness/chaneventstore_testctx_test.go
index aff4c5f..6906ebb 100644
--- a/chanfitness/chaneventstore_testctx_test.go
+++ b/chanfitness/chaneventstore_testctx_test.go
@@ -49,6 +49,13 @@ type chanEventStoreTestCtx struct {
// used to prevent calling of functions which can only be called after
// shutdown.
stopped chan struct{}
+
+ // peerOnline determines what the store's IsPeerOnline config returns
+ // for a peer. It defaults to reporting peers as online so that the
+ // channel open seeds an online event, matching the historical test
+ // assumption. Tests that exercise offline peers may override it before
+ // starting the store.
+ peerOnline func(route.Vertex) bool
}
// newChanEventStoreTestCtx creates a test context which can be used to test
@@ -62,10 +69,14 @@ func newChanEventStoreTestCtx(t *testing.T) *chanEventStoreTestCtx {
flapUpdates: make(peerFlapCountMap),
flapCountUpdates: make(chan peerFlapCountMap),
stopped: make(chan struct{}),
+ peerOnline: func(route.Vertex) bool { return true },
}
cfg := &Config{
Clock: testCtx.clock,
+ IsPeerOnline: func(peer route.Vertex) bool {
+ return testCtx.peerOnline(peer)
+ },
SubscribeChannelEvents: func() (subscribe.Subscription, error) {
return testCtx.channelSubscription, nil
},
diff --git a/server.go b/server.go
index 5aad971..9eb2b2a 100644
--- a/server.go
+++ b/server.go
@@ -1772,6 +1772,10 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr,
return s.peerNotifier.SubscribePeerEvents()
},
GetOpenChannels: s.chanStateDB.FetchAllOpenChannels,
+ IsPeerOnline: func(peer route.Vertex) bool {
+ _, err := s.FindPeerByPubStr(string(peer[:]))
+ return err == nil
+ },
Clock: clock.NewDefaultClock(),
ReadFlapCount: s.miscDB.ReadFlapCount,
WriteFlapCount: s.miscDB.WriteFlapCounts,
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.