diff --git a/channeldb/nodes.go b/channeldb/nodes.go index 688735ce16..93a18ee248 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/cmd/commands/commands.go b/cmd/commands/commands.go index e2726bb4fc..470715b587 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 85626b68f4..f939b9baf8 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 @@ -88,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 @@ -148,3 +179,4 @@ * Boris Nagaev * Erick Cestari * Jared Tobin +* ZZiigguurraatt diff --git a/itest/list_on_test.go b/itest/list_on_test.go index f1e2eb48ec..eedec67b34 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -150,6 +150,14 @@ var allTestCases = []*lntest.TestCase{ Name: "reconnect after ip change", TestFunc: testReconnectAfterIPChange, }, + { + 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 fb5c175885..8d2d0c1695 100644 --- a/itest/lnd_network_test.go +++ b/itest/lnd_network_test.go @@ -333,3 +333,193 @@ 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()) +} + +// 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 72352bdf1e..2633dc1d28 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,29 @@ 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 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 + // 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 +5842,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 +5929,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 +5978,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 +18918,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 +18938,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 +18953,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 30b78f6637..f0c498329c 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,39 @@ 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 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 + 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 +1928,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 c87fcb1f51..9a4369da05 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 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", + "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 a9dc742e61..36047a7d6f 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 bfedfbf716..6cf0f3f333 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 a0312bb014..a4c37fcf42 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. @@ -5154,6 +5203,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.