From ef81280b34290638764a7cbb86161af8d3e98cbb Mon Sep 17 00:00:00 2001 From: ZZiigguurraatt <187441685+ZZiigguurraatt@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:54:26 -0400 Subject: [PATCH 1/2] multi: expose peer address sources and offline peers in ListPeers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListPeers previously returned only currently-connected peers with a single `address` field, leaving no way to see: 1. Why lnd is maintaining a connection to a given peer. 2. What addresses lnd has stored for reconnecting to the peer. 3. What the peer currently advertises in its NodeAnnouncement. 4. Which peers lnd is auto-reconnecting to but is not currently connected to. 5. Whether a reconnect attempt is in flight. This commit fills those gaps by adding to the `Peer` message: * `is_persistent` — true if lnd is auto-reconnecting to the peer. * `remembered_addresses` — addresses lnd has stored for the peer (from the LinkNode record captured at first channel open). * `gossip_addresses` — addresses from the peer's current NodeAnnouncement, populated inline instead of requiring a separate GetNodeInfo call per peer. * `reconnect_pending` — true if lnd currently has an in-flight reconnect attempt for the peer. `ListPeersRequest` gains `include_offline_persistent_peers`. When set, the response also includes peers that are in lnd's auto-reconnect set but not currently connected. Per-connection fields (address, bytes_sent, ping_time, etc.) are empty/zero for those entries. `lncli listpeers` gains an `--include_offline_persistent_peers` flag. itest: `listpeers address fields` opens a channel between two nodes, suspends the peer, and asserts the new field state in both the connected and offline-persistent cases. Partial fix for https://github.com/lightningnetwork/lnd/issues/10871 --- cmd/commands/commands.go | 13 +++- docs/release-notes/release-notes-0.22.0.md | 14 ++++ itest/list_on_test.go | 4 + itest/lnd_network_test.go | 81 +++++++++++++++++++ lnrpc/lightning.pb.go | 90 +++++++++++++++++++--- lnrpc/lightning.proto | 49 +++++++++++- lnrpc/lightning.swagger.json | 31 +++++++- lntest/rpc/lnd.go | 13 ++++ rpcserver.go | 83 +++++++++++++++++++- server.go | 17 ++++ 10 files changed, 381 insertions(+), 14 deletions(-) diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index e2726bb4fc6..470715b5872 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -1633,12 +1633,20 @@ func parseChannelPoint(ctx *cli.Context) (*lnrpc.ChannelPoint, error) { var listPeersCommand = cli.Command{ Name: "listpeers", Category: "Peers", - Usage: "List all active, currently connected peers.", + Usage: "List currently connected peers, and optionally also " + + "offline peers that lnd is auto-reconnecting to.", Flags: []cli.Flag{ cli.BoolFlag{ Name: "list_errors", Usage: "list a full set of most recent errors for the peer", }, + cli.BoolFlag{ + Name: "include_offline_persistent_peers", + Usage: "also list peers that lnd is auto-reconnecting " + + "to but is not currently connected to — " + + "either via a stored LinkNode record or " + + "because of an open channel", + }, }, Action: actionDecorator(listPeers), } @@ -1652,6 +1660,9 @@ func listPeers(ctx *cli.Context) error { // specifically requests a full error set, then we will provide it. req := &lnrpc.ListPeersRequest{ LatestError: !ctx.IsSet("list_errors"), + IncludeOfflinePersistentPeers: ctx.Bool( + "include_offline_persistent_peers", + ), } resp, err := client.ListPeers(ctxc, req) if err != nil { diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 85626b68f41..f019e0e85ef 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -72,6 +72,15 @@ the chain backend via bitcoind's `submitpackage`, allowing a zero-fee v3/TRUC parent to be accepted together with a fee-paying CPFP child. +* [`ListPeers` gains address-source + detail](https://github.com/lightningnetwork/lnd/pull/10973). Each `Peer` + now carries `remembered_addresses` (addresses lnd has stored for the peer), + `gossip_addresses` (from the peer's current `NodeAnnouncement`), + `is_persistent` (true if lnd is auto-reconnecting to the peer), and + `reconnect_pending` (true if a retry is in flight). A new + `ListPeersRequest.include_offline_persistent_peers` flag opts into + surfacing peers in the reconnect set we are not currently connected to. + ## lncli Additions * The `estimateroutefee` command now supports [restricting fee estimates to @@ -79,6 +88,10 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `--outgoing_chan_id` flag. +* `lncli listpeers` gains + [`--include_offline_persistent_peers`](https://github.com/lightningnetwork/lnd/pull/10973) + (see the `ListPeers` RPC entry above). + * A new [`wallet submitpackage`](https://github.com/lightningnetwork/lnd/pull/10900) command submits a package of hex-encoded transactions via the new @@ -148,3 +161,4 @@ * Boris Nagaev * Erick Cestari * Jared Tobin +* ZZiigguurraatt diff --git a/itest/list_on_test.go b/itest/list_on_test.go index f1e2eb48eca..410c6fe7a2c 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -150,6 +150,10 @@ var allTestCases = []*lntest.TestCase{ Name: "reconnect after ip change", TestFunc: testReconnectAfterIPChange, }, + { + Name: "listpeers address fields", + TestFunc: testListPeersAddressFields, + }, { Name: "addpeer config", TestFunc: testAddPeerConfig, diff --git a/itest/lnd_network_test.go b/itest/lnd_network_test.go index fb5c1758853..be202c86d18 100644 --- a/itest/lnd_network_test.go +++ b/itest/lnd_network_test.go @@ -333,3 +333,84 @@ func testDisconnectingTargetPeer(ht *lntest.HarnessTest) { // Check existing connection. ht.AssertConnected(alice, bob) } + +// testListPeersAddressFields verifies the address-source fields that +// ListPeers exposes on the Peer message: is_persistent, remembered_addresses +// (from the LinkNode record), gossip_addresses (from NodeAnnouncement), +// reconnect_pending, and the include_offline_persistent_peers request flag. +func testListPeersAddressFields(ht *lntest.HarnessTest) { + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + bobInfo := bob.RPC.GetInfo() + + // Opening a channel writes a LinkNode record for the peer (this is + // what remembered_addresses reads from), and puts the peer in the + // persistent-reconnect set. + ht.ConnectNodes(alice, bob) + ht.OpenChannel(alice, bob, lntest.OpenChannelParams{Amt: 100_000}) + + // Helper: pull Bob's entry out of Alice's ListPeers response. + bobPeer := func(req *lnrpc.ListPeersRequest) *lnrpc.Peer { + resp := alice.RPC.ListPeersReq(req) + for _, p := range resp.Peers { + if p.PubKey == bobInfo.IdentityPubkey { + return p + } + } + return nil + } + + // While connected, the new fields should be populated. + require.NoError(ht, wait.NoError(func() error { + p := bobPeer(&lnrpc.ListPeersRequest{}) + if p == nil { + return fmt.Errorf("bob not yet in listpeers") + } + if !p.IsPersistent { + return fmt.Errorf("is_persistent should be true for " + + "a channel peer") + } + if len(p.RememberedAddresses) == 0 { + return fmt.Errorf("remembered_addresses should be " + + "populated for a channel peer") + } + if p.ReconnectPending { + return fmt.Errorf("reconnect_pending should be " + + "false while connected") + } + return nil + }, defaultTimeout)) + + // Stop Bob so Alice's connection to him drops. + restartBob := ht.SuspendNode(bob) + + // Without include_offline_persistent_peers, Bob should not appear + // in the response once his connection is gone. + require.NoError(ht, wait.NoError(func() error { + p := bobPeer(&lnrpc.ListPeersRequest{}) + if p != nil { + return fmt.Errorf("bob still visible without " + + "include_offline_persistent_peers") + } + return nil + }, defaultTimeout)) + + // With include_offline_persistent_peers, Bob should appear as an + // offline persistent entry: address empty, remembered_addresses + // populated, is_persistent true. + p := bobPeer(&lnrpc.ListPeersRequest{ + IncludeOfflinePersistentPeers: true, + }) + require.NotNil(ht, p, + "bob should appear when include_offline_persistent_peers=true") + require.Empty(ht, p.Address, + "offline entry should have empty address") + require.True(ht, p.IsPersistent, + "offline channel peer should still be is_persistent") + require.NotEmpty(ht, p.RememberedAddresses, + "offline channel peer should still expose remembered_addresses") + + // Bring Bob back online so the test doesn't leave a hung retry. + require.NoError(ht, restartBob()) +} + diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 72352bdf1ea..e438f3901e8 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -5650,7 +5650,10 @@ type Peer struct { state protoimpl.MessageState `protogen:"open.v1"` // The identity pubkey of the peer PubKey string `protobuf:"bytes,1,opt,name=pub_key,json=pubKey,proto3" json:"pub_key,omitempty"` - // Network address of the peer; eg `127.0.0.1:10011` + // Network address of the currently active connection to the peer; e.g. + // `127.0.0.1:10011`. Empty when the entry represents a persistent peer + // that is not currently connected (only present in the response when + // the request sets `include_offline_persistent_peers`). Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` // Bytes of data transmitted to this peer BytesSent uint64 `protobuf:"varint,4,opt,name=bytes_sent,json=bytesSent,proto3" json:"bytes_sent,omitempty"` @@ -5686,8 +5689,28 @@ type Peer struct { LastFlapNs int64 `protobuf:"varint,14,opt,name=last_flap_ns,json=lastFlapNs,proto3" json:"last_flap_ns,omitempty"` // The last ping payload the peer has sent to us. LastPingPayload []byte `protobuf:"bytes,15,opt,name=last_ping_payload,json=lastPingPayload,proto3" json:"last_ping_payload,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // True if lnd is maintaining a persistent reconnect attempt for this + // peer. Equivalent to (len(remembered_addresses) > 0 or an active + // channel-driven persistent entry exists). + IsPersistent bool `protobuf:"varint,16,opt,name=is_persistent,json=isPersistent,proto3" json:"is_persistent,omitempty"` + // Addresses lnd has stored for the peer via its LinkNode record + // (captured at first channel open). Empty if no LinkNode entry exists + // for the peer. Note: this list is reported verbatim from storage, + // including any v2 .onion entries lnd will not dial (v2 was deprecated + // by the Tor project in 2021). v2 entries are preserved on disk so + // that signed NodeAnnouncements still verify; a v2 appearing here is + // a stale-storage signal, not an indication that lnd is attempting to + // reach that address. + RememberedAddresses []string `protobuf:"bytes,17,rep,name=remembered_addresses,json=rememberedAddresses,proto3" json:"remembered_addresses,omitempty"` + // Addresses currently advertised by this peer in the gossip network's + // NodeAnnouncement. Empty when no NodeAnnouncement is known. + GossipAddresses []string `protobuf:"bytes,18,rep,name=gossip_addresses,json=gossipAddresses,proto3" json:"gossip_addresses,omitempty"` + // True if lnd currently has an in-flight reconnect attempt for this + // peer. Mainly informative for entries returned when the request sets + // `include_offline_persistent_peers`. + ReconnectPending bool `protobuf:"varint,19,opt,name=reconnect_pending,json=reconnectPending,proto3" json:"reconnect_pending,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Peer) Reset() { @@ -5818,6 +5841,34 @@ func (x *Peer) GetLastPingPayload() []byte { return nil } +func (x *Peer) GetIsPersistent() bool { + if x != nil { + return x.IsPersistent + } + return false +} + +func (x *Peer) GetRememberedAddresses() []string { + if x != nil { + return x.RememberedAddresses + } + return nil +} + +func (x *Peer) GetGossipAddresses() []string { + if x != nil { + return x.GossipAddresses + } + return nil +} + +func (x *Peer) GetReconnectPending() bool { + if x != nil { + return x.ReconnectPending + } + return false +} + type TimestampedError struct { state protoimpl.MessageState `protogen:"open.v1"` // The unix timestamp in seconds when the error occurred. @@ -5877,9 +5928,16 @@ type ListPeersRequest struct { // If true, only the last error that our peer sent us will be returned with // the peer's information, rather than the full set of historic errors we have // stored. - LatestError bool `protobuf:"varint,1,opt,name=latest_error,json=latestError,proto3" json:"latest_error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + LatestError bool `protobuf:"varint,1,opt,name=latest_error,json=latestError,proto3" json:"latest_error,omitempty"` + // If true, the response also includes peers that the daemon is keeping in + // the auto-reconnect set but is not currently connected to. A peer is in + // this set if lnd has a stored record for it (a LinkNode entry from a + // prior channel open or `connect --perm`) or an open channel. Per- + // connection fields (address, bytes_sent, ping_time, etc.) are empty/zero + // for these entries. + IncludeOfflinePersistentPeers bool `protobuf:"varint,2,opt,name=include_offline_persistent_peers,json=includeOfflinePersistentPeers,proto3" json:"include_offline_persistent_peers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListPeersRequest) Reset() { @@ -5919,6 +5977,13 @@ func (x *ListPeersRequest) GetLatestError() bool { return false } +func (x *ListPeersRequest) GetIncludeOfflinePersistentPeers() bool { + if x != nil { + return x.IncludeOfflinePersistentPeers + } + return false +} + type ListPeersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The list of currently connected peers @@ -18852,7 +18917,7 @@ const file_lightning_proto_rawDesc = "" + "\x10funding_canceled\x18\x05 \x01(\bR\x0ffundingCanceled\x12\x1c\n" + "\tabandoned\x18\x06 \x01(\bR\tabandoned\"P\n" + "\x16ClosedChannelsResponse\x126\n" + - "\bchannels\x18\x01 \x03(\v2\x1a.lnrpc.ChannelCloseSummaryR\bchannels\"\x8b\x05\n" + + "\bchannels\x18\x01 \x03(\v2\x1a.lnrpc.ChannelCloseSummaryR\bchannels\"\xbb\x06\n" + "\x04Peer\x12\x17\n" + "\apub_key\x18\x01 \x01(\tR\x06pubKey\x12\x18\n" + "\aaddress\x18\x03 \x01(\tR\aaddress\x12\x1d\n" + @@ -18872,7 +18937,11 @@ const file_lightning_proto_rawDesc = "" + "flap_count\x18\r \x01(\x05R\tflapCount\x12 \n" + "\flast_flap_ns\x18\x0e \x01(\x03R\n" + "lastFlapNs\x12*\n" + - "\x11last_ping_payload\x18\x0f \x01(\fR\x0flastPingPayload\x1aK\n" + + "\x11last_ping_payload\x18\x0f \x01(\fR\x0flastPingPayload\x12#\n" + + "\ris_persistent\x18\x10 \x01(\bR\fisPersistent\x121\n" + + "\x14remembered_addresses\x18\x11 \x03(\tR\x13rememberedAddresses\x12)\n" + + "\x10gossip_addresses\x18\x12 \x03(\tR\x0fgossipAddresses\x12+\n" + + "\x11reconnect_pending\x18\x13 \x01(\bR\x10reconnectPending\x1aK\n" + "\rFeaturesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\rR\x03key\x12$\n" + "\x05value\x18\x02 \x01(\v2\x0e.lnrpc.FeatureR\x05value:\x028\x01\"P\n" + @@ -18883,9 +18952,10 @@ const file_lightning_proto_rawDesc = "" + "\vPINNED_SYNC\x10\x03\"F\n" + "\x10TimestampedError\x12\x1c\n" + "\ttimestamp\x18\x01 \x01(\x04R\ttimestamp\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"5\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"~\n" + "\x10ListPeersRequest\x12!\n" + - "\flatest_error\x18\x01 \x01(\bR\vlatestError\"6\n" + + "\flatest_error\x18\x01 \x01(\bR\vlatestError\x12G\n" + + " include_offline_persistent_peers\x18\x02 \x01(\bR\x1dincludeOfflinePersistentPeers\"6\n" + "\x11ListPeersResponse\x12!\n" + "\x05peers\x18\x01 \x03(\v2\v.lnrpc.PeerR\x05peers\"\x17\n" + "\x15PeerEventSubscription\"\x84\x01\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 30b78f66372..8eea351e21f 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1795,7 +1795,12 @@ message Peer { // The identity pubkey of the peer string pub_key = 1; - // Network address of the peer; eg `127.0.0.1:10011` + /* + Network address of the currently active connection to the peer; e.g. + `127.0.0.1:10011`. Empty when the entry represents a persistent peer + that is not currently connected (only present in the response when + the request sets `include_offline_persistent_peers`). + */ string address = 3; // Bytes of data transmitted to this peer @@ -1873,6 +1878,38 @@ message Peer { The last ping payload the peer has sent to us. */ bytes last_ping_payload = 15; + + /* + True if lnd is maintaining a persistent reconnect attempt for this + peer. Equivalent to (len(remembered_addresses) > 0 or an active + channel-driven persistent entry exists). + */ + bool is_persistent = 16; + + /* + Addresses lnd has stored for the peer via its LinkNode record + (captured at first channel open). Empty if no LinkNode entry exists + for the peer. Note: this list is reported verbatim from storage, + including any v2 .onion entries lnd will not dial (v2 was deprecated + by the Tor project in 2021). v2 entries are preserved on disk so + that signed NodeAnnouncements still verify; a v2 appearing here is + a stale-storage signal, not an indication that lnd is attempting to + reach that address. + */ + repeated string remembered_addresses = 17; + + /* + Addresses currently advertised by this peer in the gossip network's + NodeAnnouncement. Empty when no NodeAnnouncement is known. + */ + repeated string gossip_addresses = 18; + + /* + True if lnd currently has an in-flight reconnect attempt for this + peer. Mainly informative for entries returned when the request sets + `include_offline_persistent_peers`. + */ + bool reconnect_pending = 19; } message TimestampedError { @@ -1890,6 +1927,16 @@ message ListPeersRequest { stored. */ bool latest_error = 1; + + /* + If true, the response also includes peers that the daemon is keeping in + the auto-reconnect set but is not currently connected to. A peer is in + this set if lnd has a stored record for it (a LinkNode entry from a + prior channel open or `connect --perm`) or an open channel. Per- + connection fields (address, bytes_sent, ping_time, etc.) are empty/zero + for these entries. + */ + bool include_offline_persistent_peers = 2; } message ListPeersResponse { // The list of currently connected peers diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index c87fcb1f51a..63e352304f1 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -2442,6 +2442,13 @@ "in": "query", "required": false, "type": "boolean" + }, + { + "name": "include_offline_persistent_peers", + "description": "If true, the response also includes peers that the daemon is keeping in\nthe auto-reconnect set but is not currently connected to. A peer is in\nthis set if lnd has a stored record for it (a LinkNode entry from a\nprior channel open or `connect --perm`) or an open channel. Per-\nconnection fields (address, bytes_sent, ping_time, etc.) are empty/zero\nfor these entries.", + "in": "query", + "required": false, + "type": "boolean" } ], "tags": [ @@ -6919,7 +6926,7 @@ }, "address": { "type": "string", - "title": "Network address of the peer; eg `127.0.0.1:10011`" + "description": "Network address of the currently active connection to the peer; e.g.\n`127.0.0.1:10011`. Empty when the entry represents a persistent peer\nthat is not currently connected (only present in the response when\nthe request sets `include_offline_persistent_peers`)." }, "bytes_sent": { "type": "string", @@ -6983,6 +6990,28 @@ "type": "string", "format": "byte", "description": "The last ping payload the peer has sent to us." + }, + "is_persistent": { + "type": "boolean", + "description": "True if lnd is maintaining a persistent reconnect attempt for this\npeer. Equivalent to (len(remembered_addresses) \u003e 0 or an active\nchannel-driven persistent entry exists)." + }, + "remembered_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses lnd has stored for the peer via its LinkNode record\n(captured at first channel open). Empty if no LinkNode entry exists\nfor the peer. Note: this list is reported verbatim from storage,\nincluding any v2 .onion entries lnd will not dial (v2 was deprecated\nby the Tor project in 2021). v2 entries are preserved on disk so\nthat signed NodeAnnouncements still verify; a v2 appearing here is\na stale-storage signal, not an indication that lnd is attempting to\nreach that address." + }, + "gossip_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses currently advertised by this peer in the gossip network's\nNodeAnnouncement. Empty when no NodeAnnouncement is known." + }, + "reconnect_pending": { + "type": "boolean", + "description": "True if lnd currently has an in-flight reconnect attempt for this\npeer. Mainly informative for entries returned when the request sets\n`include_offline_persistent_peers`." } } }, diff --git a/lntest/rpc/lnd.go b/lntest/rpc/lnd.go index a9dc742e619..36047a7d6f0 100644 --- a/lntest/rpc/lnd.go +++ b/lntest/rpc/lnd.go @@ -49,6 +49,19 @@ func (h *HarnessRPC) ListPeers() *lnrpc.ListPeersResponse { return resp } +// ListPeersReq calls ListPeers with a fully formed request. +func (h *HarnessRPC) ListPeersReq( + req *lnrpc.ListPeersRequest) *lnrpc.ListPeersResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.LN.ListPeers(ctxt, req) + h.NoError(err, "ListPeers") + + return resp +} + // DisconnectPeer calls the DisconnectPeer RPC on a given node with a specified // public key string and asserts there's no error. func (h *HarnessRPC) DisconnectPeer( diff --git a/rpcserver.go b/rpcserver.go index bfedfbf7169..6cf0f3f333a 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -3570,6 +3570,51 @@ func (r *rpcServer) ListPeers(ctx context.Context, Peers: make([]*lnrpc.Peer, 0, len(serverPeers)), } + // Pre-fetch the LinkNode store into a pubkey-keyed map so the + // per-peer loop becomes a constant lookup rather than a per-peer DB + // hit. + linkNodes, err := r.server.linkNodeDB.FetchAllLinkNodes() + if err != nil && !errors.Is(err, channeldb.ErrLinkNodesNotFound) { + return nil, fmt.Errorf("unable to fetch link nodes: %w", err) + } + rememberedAddrsByPub := make(map[string][]net.Addr, len(linkNodes)) + for _, ln := range linkNodes { + key := string(ln.IdentityPub.SerializeCompressed()) + rememberedAddrsByPub[key] = ln.Addresses + } + + // Snapshot the in-memory persistent set so we can also emit + // disconnected entries if the caller opted in. + persistentSet := r.server.PersistentReconnectSet() + + // Track which pubkeys we have already emitted so we don't duplicate + // when appending offline entries. + connectedPubs := make(map[string]struct{}, len(serverPeers)) + + // Helper: []net.Addr → []string. + toStrs := func(addrs []net.Addr) []string { + out := make([]string, 0, len(addrs)) + for _, a := range addrs { + out = append(out, a.String()) + } + return out + } + + // Helper: gossip addresses for a pubkey, ignoring v2 onion entries. + // Returns an empty slice for any error (e.g. peer not in graph) since + // missing gossip data is normal. + gossipAddrsFor := func(pubBytes []byte) []string { + vertex, vErr := route.NewVertexFromBytes(pubBytes) + if vErr != nil { + return nil + } + node, gErr := r.server.v1Graph.FetchNode(ctx, vertex) + if gErr != nil { + return nil + } + return toStrs(withoutV2Onion(node.Addresses)) + } + for _, serverPeer := range serverPeers { var ( satSent int64 @@ -3690,10 +3735,46 @@ func (r *rpcServer) ListPeers(ctx context.Context, } } + // Enrich with the new persistent / multi-source address + // fields. + pubStr := string(nodePub[:]) + connectedPubs[pubStr] = struct{}{} + + rpcPeer.RememberedAddresses = toStrs( + rememberedAddrsByPub[pubStr], + ) + rpcPeer.GossipAddresses = gossipAddrsFor(nodePub[:]) + _, inPersistentSet := persistentSet[pubStr] + rpcPeer.IsPersistent = inPersistentSet || + len(rpcPeer.RememberedAddresses) > 0 + // We're connected, so reconnect_pending stays at its zero + // default. + resp.Peers = append(resp.Peers, rpcPeer) } - rpcsLog.Debugf("[listpeers] yielded %v peers", serverPeers) + // Optionally append entries for persistent peers we're not currently + // connected to. + if in.IncludeOfflinePersistentPeers { + for pubStr, pending := range persistentSet { + if _, ok := connectedPubs[pubStr]; ok { + continue + } + + pubBytes := []byte(pubStr) + resp.Peers = append(resp.Peers, &lnrpc.Peer{ + PubKey: hex.EncodeToString(pubBytes), + RememberedAddresses: toStrs( + rememberedAddrsByPub[pubStr], + ), + GossipAddresses: gossipAddrsFor(pubBytes), + IsPersistent: true, + ReconnectPending: pending, + }) + } + } + + rpcsLog.Debugf("[listpeers] yielded %d peers", len(resp.Peers)) return resp, nil } diff --git a/server.go b/server.go index a0312bb0146..eabe84c5800 100644 --- a/server.go +++ b/server.go @@ -5154,6 +5154,23 @@ func (s *server) removePeerUnsafe(ctx context.Context, p *peer.Brontide) { }() } +// PersistentReconnectSet returns a snapshot of the pubkeys currently in the +// persistent reconnect set, mapped to whether a reconnect attempt is in +// flight. Keys are the raw 33-byte compressed pubkey as a Go string (matching +// the keying convention used by s.persistentPeers). +// +// NOTE: This function is safe for concurrent access. +func (s *server) PersistentReconnectSet() map[string]bool { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make(map[string]bool, len(s.persistentPeers)) + for k := range s.persistentPeers { + out[k] = len(s.persistentConnReqs[k]) > 0 + } + return out +} + // ConnectToPeer requests that the server connect to a Lightning Network peer // at the specified address. This function will *block* until either a // connection is established, or the initial handshake process fails. From fd3c6cb97f6f08ba6178dd2c10962ba2b09d2486 Mon Sep 17 00:00:00 2001 From: ZZiigguurraatt <187441685+ZZiigguurraatt@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:00:44 -0400 Subject: [PATCH 2/2] multi: auto-persist connected address for channel peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When lnd completes an outbound connection to a peer that we have an open channel with, record the address we dialed in that peer's LinkNode entry (if it is not already listed). Peers without an open channel are skipped — so casual connections do not grow the on-disk store, and a lingering entry for a peer whose channels were closed but not yet reaped by `PruneLinkNodes` will not be extended if we happen to reconnect to them in the meantime. Motivation ---------- There are two sources of addresses used to reach a channel peer: 1. LinkNode — the peer's address stored in the channel state DB. 2. Gossip — addresses from the peer's most recent `NodeAnnouncement`, stored in the on-disk channel graph. On startup, `establishPersistentConnections` dials the union of these two sources. That behaviour is unchanged by this commit. Before this commit: LinkNode was written once at channel-open and not updated afterward. Gossip carries subsequent listener changes when a peer re-signs and re-broadcasts a `NodeAnnouncement`, but there are cases where the address we can actually reach the peer at is not in their current gossip entry: * the peer has since removed the address from their `NodeAnnouncement` but is still listening on the same host and port, * the peer moved to a new host and/or port and we learned the new address out-of-band faster than the peer's re-broadcast `NodeAnnouncement` could catch up, or * the peer never advertised the address in gossip in the first place (private channel, LSP setup, LAN address). In any of these cases, lnd could reach the peer at the address at some point — via automatic reconnect (at startup or after a mid-session disconnect) while the address was still in gossip, or via `lncli connect @` for addresses that were never in gossip or where gossip lagged the peer's move. But the address was not persisted anywhere — so the next restart's startup dial failed even though a live connection had just worked. After this commit: Every successful outbound connection to a channel peer appends the dialed address to the peer's LinkNode entry (deduped by string). The next restart's startup dial includes it, so an operator-supplied or otherwise learned address survives across restarts for channel peers. Non-channel peers, addresses already listed, and inbound connections are skipped (see below for the reasoning behind the inbound skip). Only outbound ------------- The auto-persist path runs only for outbound connections. For inbound TCP the peer's `RemoteAddr()` is the ephemeral source port their kernel picked for the outbound half of their side, not a listener we could dial back to. Persisting it would inflate the LinkNode address list with non-dialable entries that fail on every subsequent startup dial. There is no equivalent for learning a peer's new listener from an inbound connection today: BOLT #1 `Init` does not carry a listen address, and `NodeAnnouncement` gossip is the intended source of truth. lnd already forwards this: an lnd peer with a confirmed public channel to us pushes its signed `NodeAnnouncement` on reconnect (`peer.maybeSendNodeAnn`), and the gossiper writes it to the on-disk channel graph, which `establishPersistentConnections` reads. That covers the public-channel-with-lnd-peer case automatically. The gap this commit narrows is what remains: operator-supplied addresses for private channels, non-lnd peers, or gossip-lag windows. Design ------ * `channeldb.LinkNodeDB.AddAddressIfPeerKnown(pub, addr)` appends `addr` to the peer's LinkNode entry when one already exists and the address is not already listed. It is a no-op (nil error) when the peer has no LinkNode entry, which is the invariant we need to gate the store's growth. * `server.maybePersistPeerAddress` is called from `peerConnected` only when `inbound` is false and only after `addPeer`. It checks `chanStateDB.FetchOpenChannels` for the peer and only then calls the DB helper. Errors are logged and swallowed. Since channel peers always have a LinkNode entry (created at channel open, and `RepairLinkNodes` reinstates missing ones at startup), the "no-op when no entry" branch of `AddAddressIfPeerKnown` will not fire in practice for channel peers — the open-channel gate is what keeps the store from growing. The new address is surfaced through the existing `remembered_addresses` field on `Peer` introduced in #10973; no new RPC surface is required. itest ----- `auto persist channel peer address` opens a channel between Alice and Bob at Bob's initial P2P port, disconnects them, restarts Bob on a new port, then reconnects Alice to Bob at the new port. The test asserts that Alice's `remembered_addresses` for Bob then contains both the channel-open address and the newly-connected address. Depends on #10973: the itest reads the `remembered_addresses` field and uses the `include_offline_persistent_peers` flag added there. Contributes to #10870. --- channeldb/nodes.go | 40 ++++++++ docs/release-notes/release-notes-0.22.0.md | 18 ++++ itest/list_on_test.go | 4 + itest/lnd_network_test.go | 109 +++++++++++++++++++++ lnrpc/lightning.pb.go | 3 +- lnrpc/lightning.proto | 3 +- lnrpc/lightning.swagger.json | 2 +- server.go | 49 +++++++++ 8 files changed, 225 insertions(+), 3 deletions(-) diff --git a/channeldb/nodes.go b/channeldb/nodes.go index 688735ce168..93a18ee248d 100644 --- a/channeldb/nodes.go +++ b/channeldb/nodes.go @@ -243,6 +243,46 @@ func deleteLinkNode(tx kvdb.RwTx, identity *btcec.PublicKey) error { return nodeMetaBucket.Delete(pubKey) } +// AddAddressIfPeerKnown appends addr to the address list of the LinkNode entry +// for identity, but only if an entry already exists. If no LinkNode is stored +// for the peer this is a no-op and returns (false, nil). Duplicate addresses +// are ignored (matched by String()). Returns true if the address was newly +// stored. +func (l *LinkNodeDB) AddAddressIfPeerKnown(identity *btcec.PublicKey, + addr net.Addr) (bool, error) { + + var added bool + err := kvdb.Update(l.backend, func(tx kvdb.RwTx) error { + nodeMetaBucket := tx.ReadWriteBucket(nodeInfoBucket) + if nodeMetaBucket == nil { + return nil + } + + node, err := fetchLinkNode(tx, identity) + if errors.Is(err, ErrNodeNotFound) { + return nil + } + if err != nil { + return err + } + + for _, a := range node.Addresses { + if a.String() == addr.String() { + return nil + } + } + + node.Addresses = append(node.Addresses, addr) + added = true + + return putLinkNode(nodeMetaBucket, node) + }, func() { + added = false + }) + + return added, err +} + // FetchLinkNode attempts to lookup the data for a LinkNode based on a target // identity public key. If a particular LinkNode for the passed identity public // key cannot be found, then ErrNodeNotFound if returned. diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index f019e0e85ef..f939b9baf81 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -101,6 +101,24 @@ ## Functional Updates +* [Peer addresses now auto-persist on + connect](https://github.com/lightningnetwork/lnd/pull/10975) for peers we + have an open channel with. When lnd completes an outbound connection to + a channel peer, it records the dialed address in the peer's `LinkNode` + entry if it isn't already listed. On restart lnd attempts this address + in addition to those from the peer's current `NodeAnnouncement`, so a + channel peer remains reachable across restarts even when the address we + last used to reach them isn't in their gossip entry — because they have + since removed it from their `NodeAnnouncement` but are still listening + on the same host and port, because they moved to a new host and/or port + and we learned the new address out-of-band faster than their + re-broadcast `NodeAnnouncement` could catch up, or because they never + advertised it in gossip in the first place. Only outbound connections + trigger this — for inbound TCP the peer's remote address is an + ephemeral source port rather than a dialable listener. Non-channel + peers are also skipped so casual connections do not grow the on-disk + address list. + ## RPC Updates ## lncli Updates diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 410c6fe7a2c..eedec67b347 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -154,6 +154,10 @@ var allTestCases = []*lntest.TestCase{ Name: "listpeers address fields", TestFunc: testListPeersAddressFields, }, + { + Name: "auto persist channel peer address", + TestFunc: testAutoPersistChannelPeerAddress, + }, { Name: "addpeer config", TestFunc: testAddPeerConfig, diff --git a/itest/lnd_network_test.go b/itest/lnd_network_test.go index be202c86d18..8d2d0c16956 100644 --- a/itest/lnd_network_test.go +++ b/itest/lnd_network_test.go @@ -414,3 +414,112 @@ func testListPeersAddressFields(ht *lntest.HarnessTest) { require.NoError(ht, restartBob()) } +// testAutoPersistChannelPeerAddress verifies that when a peer we have an +// open channel with connects on an address that isn't already in its +// LinkNode entry, that address is appended to the entry so a future +// restart can dial it. The scenario: open a channel between Alice and Bob +// at port A, disconnect them, move Bob to port B, reconnect Alice to Bob +// at port B, then assert that Alice's remembered_addresses for Bob now +// contains both A and B. +func testAutoPersistChannelPeerAddress(ht *lntest.HarnessTest) { + // Alice is set up with --nolisten so Bob physically cannot dial + // back to her at any point during the test. Combined with the + // long backoff on Alice's own persistent-reconnect logic, this + // guarantees the only peer connection to Bob we ever observe + // during the test is the outbound one we initiate explicitly. + aliceArgs := []string{ + "--nolisten", + "--minbackoff=1h", + "--maxbackoff=1h", + } + alice := ht.NewNodeWithCoins("Alice", aliceArgs) + bob := ht.NewNode("Bob", nil) + bobInfo := bob.RPC.GetInfo() + + portA := bob.Cfg.P2PPort + addrA := fmt.Sprintf("127.0.0.1:%d", portA) + + // Open a channel — this seeds Alice's LinkNode entry for Bob with + // Bob's address at channel-open time, which is port A. + ht.ConnectNodes(alice, bob) + ht.OpenChannel(alice, bob, lntest.OpenChannelParams{Amt: 100_000}) + + // bobPeer pulls Bob's entry out of Alice's ListPeers response, + // including offline persistent entries so the assertion still works + // while Alice is disconnected from Bob. + bobPeer := func() *lnrpc.Peer { + resp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{ + IncludeOfflinePersistentPeers: true, + }) + for _, p := range resp.Peers { + if p.PubKey == bobInfo.IdentityPubkey { + return p + } + } + return nil + } + + // Sanity: port A appears in remembered_addresses now. + require.NoError(ht, wait.NoError(func() error { + p := bobPeer() + if p == nil { + return fmt.Errorf("bob not yet in listpeers") + } + for _, a := range p.RememberedAddresses { + if a == addrA { + return nil + } + } + return fmt.Errorf("remembered_addresses %v missing %s", + p.RememberedAddresses, addrA) + }, defaultTimeout)) + + // Move Bob to port B. + portB := port.NextAvailablePort() + addrB := fmt.Sprintf("127.0.0.1:%d", portB) + + ht.DisconnectNodes(alice, bob) + bob.Cfg.P2PPort = portB + ht.RestartNode(bob) + + // Reconnect Alice to Bob explicitly at port B. This is a plain + // (non-perm) connect — the auto-persist behaviour is what we're + // exercising. + alice.RPC.ConnectPeer(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: addrB, + }, + }) + ht.AssertConnected(alice, bob) + + // Alice's remembered_addresses for Bob should now list both A + // (from channel open) and B (auto-persisted on this connect). + require.NoError(ht, wait.NoError(func() error { + p := bobPeer() + if p == nil { + return fmt.Errorf("bob missing from listpeers") + } + haveA, haveB := false, false + for _, a := range p.RememberedAddresses { + switch a { + case addrA: + haveA = true + case addrB: + haveB = true + } + } + if !haveA { + return fmt.Errorf("remembered_addresses %v missing "+ + "channel-open address %s", + p.RememberedAddresses, addrA) + } + if !haveB { + return fmt.Errorf("remembered_addresses %v missing "+ + "auto-persisted address %s", + p.RememberedAddresses, addrB) + } + return nil + }, defaultTimeout)) +} + diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index e438f3901e8..2633dc1d283 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -5694,7 +5694,8 @@ type Peer struct { // channel-driven persistent entry exists). IsPersistent bool `protobuf:"varint,16,opt,name=is_persistent,json=isPersistent,proto3" json:"is_persistent,omitempty"` // Addresses lnd has stored for the peer via its LinkNode record - // (captured at first channel open). Empty if no LinkNode entry exists + // (captured at channel open and on subsequent outbound connects to a + // channel peer). Empty if no LinkNode entry exists // for the peer. Note: this list is reported verbatim from storage, // including any v2 .onion entries lnd will not dial (v2 was deprecated // by the Tor project in 2021). v2 entries are preserved on disk so diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 8eea351e21f..f0c498329c1 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1888,7 +1888,8 @@ message Peer { /* Addresses lnd has stored for the peer via its LinkNode record - (captured at first channel open). Empty if no LinkNode entry exists + (captured at channel open and on subsequent outbound connects to a + channel peer). Empty if no LinkNode entry exists for the peer. Note: this list is reported verbatim from storage, including any v2 .onion entries lnd will not dial (v2 was deprecated by the Tor project in 2021). v2 entries are preserved on disk so diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 63e352304f1..9a4369da052 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -7000,7 +7000,7 @@ "items": { "type": "string" }, - "description": "Addresses lnd has stored for the peer via its LinkNode record\n(captured at first channel open). Empty if no LinkNode entry exists\nfor the peer. Note: this list is reported verbatim from storage,\nincluding any v2 .onion entries lnd will not dial (v2 was deprecated\nby the Tor project in 2021). v2 entries are preserved on disk so\nthat signed NodeAnnouncements still verify; a v2 appearing here is\na stale-storage signal, not an indication that lnd is attempting to\nreach that address." + "description": "Addresses lnd has stored for the peer via its LinkNode record\n(captured at channel open and on subsequent outbound connects to a\nchannel peer). Empty if no LinkNode entry exists\nfor the peer. Note: this list is reported verbatim from storage,\nincluding any v2 .onion entries lnd will not dial (v2 was deprecated\nby the Tor project in 2021). v2 entries are preserved on disk so\nthat signed NodeAnnouncements still verify; a v2 appearing here is\na stale-storage signal, not an indication that lnd is attempting to\nreach that address." }, "gossip_addresses": { "type": "array", diff --git a/server.go b/server.go index eabe84c5800..a4c37fcf42c 100644 --- a/server.go +++ b/server.go @@ -4474,6 +4474,42 @@ func (s *server) notifyFundingTimeoutPeerEvent(op wire.OutPoint, s.channelNotifier.NotifyFundingTimeout(op) } +// maybePersistPeerAddress records addr in the LinkNode entry for pubKey when +// the peer has an open channel with us. It is a no-op for non-channel peers, +// for peers without an existing LinkNode entry, and for address types other +// than TCP and onion. Errors are logged and swallowed — this is best-effort +// bookkeeping and must not interfere with the connection. +func (s *server) maybePersistPeerAddress(pubKey *btcec.PublicKey, addr net.Addr) { + switch addr.(type) { + case *net.TCPAddr, *tor.OnionAddr: + default: + return + } + + channels, err := s.chanStateDB.FetchOpenChannels(pubKey) + if err != nil { + srvrLog.Errorf("Unable to fetch open channels for %x when "+ + "considering address persistence: %v", + pubKey.SerializeCompressed(), err) + return + } + if len(channels) == 0 { + return + } + + added, err := s.linkNodeDB.AddAddressIfPeerKnown(pubKey, addr) + if err != nil { + srvrLog.Errorf("Unable to record address %v for channel "+ + "peer %x: %v", addr, pubKey.SerializeCompressed(), err) + return + } + if added { + srvrLog.Infof("Recorded address %v for channel peer %x for "+ + "reconnection across restarts", addr, + pubKey.SerializeCompressed()) + } +} + // peerConnected is a function that handles initialization a newly connected // peer by adding it to the server's global list of all active peers, and // starting all the goroutines the peer needs to function properly. The inbound @@ -4659,6 +4695,19 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, s.addPeer(p) + // For outbound connections to a channel peer, record the address we + // dialed in the peer's LinkNode entry if it isn't already listed. + // This lets us reconnect on that address after a restart if the + // peer's current NodeAnnouncement no longer lists it. + // + // We only do this for outbound connections. For inbound TCP, + // conn.RemoteAddr() is the peer's ephemeral source port rather than + // a listener we could dial back to, so persisting it would just + // inflate the address list with non-dialable entries. + if !inbound { + s.maybePersistPeerAddress(pubKey, addr) + } + // Once we have successfully added the peer to the server, we can // delete the previous error buffer from the server's map of error // buffers.