From 34b2a0639a7dec459c92cfa888d137eba958f7fc Mon Sep 17 00:00:00 2001 From: ZZiigguurraatt <187441685+ZZiigguurraatt@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:07:56 -0400 Subject: [PATCH] =?UTF-8?q?multi:=20peer=20address=20management=20?= =?UTF-8?q?=E2=80=94=20persist,=20list,=20forget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds three capabilities for managing peer addresses: 1. Persist addresses across restarts. `lncli connect --perm` now stores the address on disk so the connection is re-established automatically after an lnd restart, even when no open channel pins the peer. 2. List all known addresses associated with a peer. `lncli listpeers` gains three new per-peer address lists — perm_addresses (what the user typed at --perm time), channel_peer_addresses (the LinkNode record captured at first channel open), and gossip_addresses (the current NodeAnnouncement) — plus is_persistent and reconnect_pending status flags, and an --include_offline_persistent_peers request flag to also surface peers in the reconnect set we are not currently connected to. 3. Allow completely forgetting all of a peer's addresses. `lncli disconnect` gains --forget (remove the persistent-peer bucket entry; clears perm_addresses) and --force (also remove the LinkNode record even when open channels exist; clears channel_peer_addresses). --- Implementation details: Store user-requested perm peers in a new top-level persistent-peers-bucket in channeldb (keyed by compressed pubkey, value is the union of addresses typed at --perm time). On startup, establishPersistentConnections reads this bucket alongside LinkNode and graph addresses, and adds the entries to the persistent reconnect set with perm=true so they are not pruned when the channel count drops to zero. `--forget` (DisconnectPeerRequest.forget) removes the on-disk perm-bucket entry. By default it keeps the LinkNode record while open channels exist; `--force` (DisconnectPeerRequest.force) removes the LinkNode too. The handler rejects --force without --forget. `--wait_for_dial` (ConnectPeerRequest.wait_for_dial) makes `--perm` synchronous: the RPC blocks on the initial dial and only persists the address on success. The async --perm semantics (return immediately, persist regardless of dial success) remain the default. When --perm targets a peer we are already connected to on a new address, lnd now persists the new address and lets OutboundPeerConnected's existing duplicate-handling logic swap the active connection over once the new dial succeeds. Address sources are intentionally not deduped across perm_addresses, channel_peer_addresses, and gossip_addresses; dial-time dedup happens internally before any outbound connection. ConnectToPeer now returns descriptive status strings so the ConnectPeerResponse distinguishes "marked as persistent", "already persistent", and "swap initiated" cases from the default "connection initiated". itests: persistent peer survives restart, persistent peer address swap, persistent peer wait for dial, forget persistent peer with channel. Fixes #10870. Fixes #10871. --- channeldb/db.go | 1 + channeldb/persistent_peers.go | 195 ++++++++++ chanrestore.go | 6 +- cmd/commands/commands.go | 101 +++++- docs/release-notes/release-notes-0.22.0.md | 43 +++ itest/list_on_test.go | 16 + itest/lnd_network_test.go | 292 +++++++++++++++ lnrpc/lightning.pb.go | 158 ++++++++- lnrpc/lightning.pb.gw.go | 18 + lnrpc/lightning.proto | 85 ++++- lnrpc/lightning.swagger.json | 61 +++- lntest/rpc/lnd.go | 27 ++ pilot.go | 9 +- rpcserver.go | 118 +++++- server.go | 394 +++++++++++++++++++-- 15 files changed, 1441 insertions(+), 83 deletions(-) create mode 100644 channeldb/persistent_peers.go diff --git a/channeldb/db.go b/channeldb/db.go index 894f9fae555..cecd88130eb 100644 --- a/channeldb/db.go +++ b/channeldb/db.go @@ -462,6 +462,7 @@ var dbTopLevelBuckets = [][]byte{ payAddrIndexBucket, setIDIndexBucket, peersBucket, + persistentPeersBucket, nodeInfoBucket, metaBucket, closeSummaryBucket, diff --git a/channeldb/persistent_peers.go b/channeldb/persistent_peers.go new file mode 100644 index 00000000000..531fd65faea --- /dev/null +++ b/channeldb/persistent_peers.go @@ -0,0 +1,195 @@ +package channeldb + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + + "github.com/btcsuite/btcd/btcec/v2" + graphdb "github.com/lightningnetwork/lnd/graph/db" + "github.com/lightningnetwork/lnd/kvdb" +) + +var ( + // persistentPeersBucket is the name of a top level bucket in which we + // store the set of peers that the user has explicitly requested to + // remain connected to across LND restarts (e.g. via + // `lncli connect --perm`). Entries are keyed by the peer's compressed + // pubkey and the value is the serialized list of addresses we should + // try when (re)connecting. + persistentPeersBucket = []byte("persistent-peers-bucket") + + // ErrPersistentPeerNotFound is returned when no entry exists in the + // persistent peers bucket for the requested pubkey. + ErrPersistentPeerNotFound = errors.New("persistent peer not found") +) + +// PersistentPeer represents a peer that the user has explicitly marked as +// persistent. Its addresses are stored so that the connection can be +// re-established when LND restarts. +type PersistentPeer struct { + // PubKey is the identity public key of the peer. + PubKey *btcec.PublicKey + + // Addresses is the set of addresses that we will attempt to dial when + // (re)connecting to the peer. + Addresses []net.Addr +} + +// AddPersistentPeer unions the given addresses with whatever is already +// stored for the peer, deduping by string. Calling this multiple times for +// the same peer is additive: each call only adds addresses, never removes +// them. Use DeletePersistentPeer to fully forget a peer. The boolean return +// is true if at least one of the given addresses was newly added (i.e. the +// stored set grew); false if everything in addrs was already present. +func (d *DB) AddPersistentPeer(pubKey *btcec.PublicKey, + addrs []net.Addr) (bool, error) { + + var addedNew bool + err := kvdb.Update(d, func(tx kvdb.RwTx) error { + bucket, err := tx.CreateTopLevelBucket(persistentPeersBucket) + if err != nil { + return err + } + + key := pubKey.SerializeCompressed() + + // Read any existing entry so we can union with it. + existing := make([]net.Addr, 0) + if v := bucket.Get(key); v != nil { + existing, err = deserializeAddresses( + bytes.NewReader(v), + ) + if err != nil { + return fmt.Errorf("unable to read existing "+ + "persistent peer entry: %w", err) + } + } + + seen := make(map[string]struct{}, len(existing)+len(addrs)) + merged := make([]net.Addr, 0, len(existing)+len(addrs)) + for _, a := range existing { + if _, ok := seen[a.String()]; ok { + continue + } + seen[a.String()] = struct{}{} + merged = append(merged, a) + } + addedNew = false + for _, a := range addrs { + if _, ok := seen[a.String()]; ok { + continue + } + seen[a.String()] = struct{}{} + merged = append(merged, a) + addedNew = true + } + + var b bytes.Buffer + if err := serializeAddresses(&b, merged); err != nil { + return err + } + + return bucket.Put(key, b.Bytes()) + }, func() { + addedNew = false + }) + return addedNew, err +} + +// DeletePersistentPeer removes the persistent peer entry for the given +// pubkey. If no entry exists for the pubkey, this is a no-op. +func (d *DB) DeletePersistentPeer(pubKey *btcec.PublicKey) error { + return kvdb.Update(d, func(tx kvdb.RwTx) error { + bucket := tx.ReadWriteBucket(persistentPeersBucket) + if bucket == nil { + return nil + } + + return bucket.Delete(pubKey.SerializeCompressed()) + }, func() {}) +} + +// FetchAllPersistentPeers returns all persistent peers that the user has +// previously asked LND to keep connected to. +func (d *DB) FetchAllPersistentPeers() ([]*PersistentPeer, error) { + var peers []*PersistentPeer + err := kvdb.View(d, func(tx kvdb.RTx) error { + bucket := tx.ReadBucket(persistentPeersBucket) + if bucket == nil { + return nil + } + + return bucket.ForEach(func(k, v []byte) error { + if v == nil { + return nil + } + + pubKey, err := btcec.ParsePubKey(k) + if err != nil { + return fmt.Errorf("unable to parse "+ + "persistent peer pubkey: %w", err) + } + + addrs, err := deserializeAddresses(bytes.NewReader(v)) + if err != nil { + return fmt.Errorf("unable to deserialize "+ + "persistent peer addresses: %w", err) + } + + peers = append(peers, &PersistentPeer{ + PubKey: pubKey, + Addresses: addrs, + }) + return nil + }) + }, func() { + peers = nil + }) + if err != nil { + return nil, err + } + + return peers, nil +} + +// serializeAddresses writes the given list of addresses to w using the +// graphdb address serialization helpers. +func serializeAddresses(w io.Writer, addrs []net.Addr) error { + var buf [4]byte + byteOrder.PutUint32(buf[:], uint32(len(addrs))) + if _, err := w.Write(buf[:]); err != nil { + return err + } + + for _, addr := range addrs { + if err := graphdb.SerializeAddr(w, addr); err != nil { + return err + } + } + + return nil +} + +// deserializeAddresses reads a list of addresses from r in the format +// written by serializeAddresses. +func deserializeAddresses(r io.Reader) ([]net.Addr, error) { + var buf [4]byte + if _, err := io.ReadFull(r, buf[:]); err != nil { + return nil, err + } + numAddrs := byteOrder.Uint32(buf[:]) + + addrs := make([]net.Addr, 0, numAddrs) + for i := uint32(0); i < numAddrs; i++ { + addr, err := graphdb.DeserializeAddr(r) + if err != nil { + return nil, err + } + addrs = append(addrs, addr) + } + + return addrs, nil +} diff --git a/chanrestore.go b/chanrestore.go index d97e50f8ba6..c838a2fd6b4 100644 --- a/chanrestore.go +++ b/chanrestore.go @@ -332,7 +332,7 @@ func (s *server) ConnectPeer(nodePub *btcec.PublicKey, addrs []net.Addr) error { // Before we connect to the remote peer, we'll remove any connections // to ensure the new connection is created after this new link/channel // is known. - if err := s.DisconnectPeer(nodePub); err != nil { + if err := s.DisconnectPeer(nodePub, false, false); err != nil { ltndLog.Infof("Peer(%x) is already connected, proceeding "+ "with chan restore", nodePub.SerializeCompressed()) } @@ -357,7 +357,9 @@ func (s *server) ConnectPeer(nodePub *btcec.PublicKey, addrs []net.Addr) error { // Attempt to connect to the peer using this full address. If // we're unable to connect to them, then we'll try the next // address in place of it. - err := s.ConnectToPeer(netAddr, true, s.cfg.ConnectionTimeout) + _, err := s.ConnectToPeer( + netAddr, true, false, s.cfg.ConnectionTimeout, + ) // If we're already connected to this peer, then we don't // consider this an error, so we'll exit here. diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index e2726bb4fc6..e8339f6e868 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -922,20 +922,46 @@ var connectCommand = cli.Command{ the connection request in 30 seconds, use the following: lncli connect @host --timeout 30s + + If you are already connected to the peer: + - Without --perm, the request fails with an "already connected" error. + - With --perm on the same address, the peer is marked as persistent + without re-dialing. + - With --perm on a new address, lnd attempts to swap the active + connection to the new address. The behaviour depends on + --wait_for_dial: + * default (async): the new address is persisted immediately and + lnd dials it in the background. The swap happens if/when the + dial succeeds. If it never succeeds, the existing connection + remains and the conn manager keeps retrying. + * with --wait_for_dial: the RPC waits for the dial. The address + is persisted (and the connection swapped) only on success. On + failure the RPC returns an error, nothing is persisted, and + the existing connection is untouched. `, Flags: []cli.Flag{ cli.BoolFlag{ Name: "perm", Usage: "If set, the daemon will attempt to persistently " + - "connect to the target peer.\n" + - " If not, the call will be synchronous.", + "connect to the target peer. If not, the call " + + "will be synchronous.", }, cli.DurationFlag{ Name: "timeout", - Usage: "The connection timeout value for current request. " + - "Valid uints are {ms, s, m, h}.\n" + - "If not set, the global connection " + - "timeout value (default to 120s) is used.", + Usage: "The connection timeout value for current " + + "request. Valid uints are {ms, s, m, h}. " + + "If not set, the global connection timeout " + + "value (default to 120s) is used. Not " + + "applicable when --perm is used without " + + "--wait_for_dial, since the dial happens " + + "asynchronously in that case.", + }, + cli.BoolFlag{ + Name: "wait_for_dial", + Usage: "Only meaningful with --perm. Wait for the " + + "initial dial to complete and return an error " + + "if it fails. The address is only added to " + + "the persistent peer set on a successful dial.", }, }, Action: actionDecorator(connectPeer), @@ -958,9 +984,10 @@ func connectPeer(ctx *cli.Context) error { Host: splitAddr[1], } req := &lnrpc.ConnectPeerRequest{ - Addr: addr, - Perm: ctx.Bool("perm"), - Timeout: uint64(ctx.Duration("timeout").Seconds()), + Addr: addr, + Perm: ctx.Bool("perm"), + Timeout: uint64(ctx.Duration("timeout").Seconds()), + WaitForDial: ctx.Bool("wait_for_dial"), } lnid, err := client.ConnectPeer(ctxc, req) @@ -978,12 +1005,52 @@ var disconnectCommand = cli.Command{ Usage: "Disconnect a remote lightning peer identified by " + "public key.", ArgsUsage: "", + Description: ` + Disconnect a remote lightning peer identified by public key. + + By default this only drops the active connection to the peer; the peer + remains in lnd's auto-reconnect set if it was previously marked + permanent via 'lncli connect --perm' or if there are open channels + with it. + + With --forget, the peer is also removed from the persistent peer set, + so no automatic reconnect attempt will be made on the next lnd + restart. The --forget operation removes the entire persistent record + for the peer at once; there is no way to remove a single address while + keeping others. + + With --force (only meaningful together with --forget), the + channel-peer record is also removed even when open channels still + exist with the peer. + + Note: this command only drops the connection from our side. If the + remote peer has us in their persistent set (typical when an open + channel exists between the two nodes), they may re-dial us + immediately and the visible connection state will appear to "stay + connected" even though the disconnect succeeded locally. lnd cannot + prevent the other side from reconnecting. + `, Flags: []cli.Flag{ cli.StringFlag{ Name: "node_key", Usage: "The hex-encoded compressed public key of the peer " + "to disconnect from", }, + cli.BoolFlag{ + Name: "forget", + Usage: "Also remove the peer from the persistent peer " + + "set so it will not be auto-reconnected to on " + + "the next lnd restart. Without this flag, " + + "disconnect only drops the current connection.", + }, + cli.BoolFlag{ + Name: "force", + Usage: "Only meaningful with '--forget'. By default, " + + "'--forget' will not completely forget a peer " + + "while one or more open channels exist with " + + "them. With '--force', the peer is forgotten " + + "regardless of open channels.", + }, }, Action: actionDecorator(disconnectPeer), } @@ -1005,6 +1072,8 @@ func disconnectPeer(ctx *cli.Context) error { req := &lnrpc.DisconnectPeerRequest{ PubKey: pubKey, + Forget: ctx.Bool("forget"), + Force: ctx.Bool("force"), } lnid, err := client.DisconnectPeer(ctxc, req) @@ -1633,12 +1702,21 @@ 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 made persistent via 'lncli connect " + + "--perm', or held persistent because of an " + + "open channel", + }, }, Action: actionDecorator(listPeers), } @@ -1652,6 +1730,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 2f7f0ca1b7e..cfdcace5a90 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -46,6 +46,14 @@ ## Functional Enhancements +* [Peer addresses are now persisted across + restarts](https://github.com/lightningnetwork/lnd/pull/10931) when a peer is + marked permanent via `lncli connect --perm`. lnd reconnects to the peer + automatically on the next startup, even when no open channel pins the + peer. Channel-driven reconnect behaviour is unchanged. Fixes + [#10870](https://github.com/lightningnetwork/lnd/issues/10870) and + [#10871](https://github.com/lightningnetwork/lnd/issues/10871). + ## RPC Additions * The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates @@ -53,6 +61,29 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `outgoing_chan_ids` field in `RouteFeeRequest`. +* [`ConnectPeer` gains a `wait_for_dial` + field](https://github.com/lightningnetwork/lnd/pull/10931). When set together + with `perm`, the RPC blocks on the initial dial and only persists the + address on success. The existing async `--perm` semantics (return + immediately, persist regardless of dial success) remain the default. + +* [`DisconnectPeer` gains `forget` and `force` + fields](https://github.com/lightningnetwork/lnd/pull/10931). `forget=true` + forgets the peer's persistent addresses so no automatic reconnect attempt + is made on the next lnd restart. `force=true` (only meaningful with + `forget`) additionally removes the channel-peer address record even when + open channels still exist with the peer. The handler rejects `force` + without `forget`. + +* [`ListPeers` gains address-source + detail](https://github.com/lightningnetwork/lnd/pull/10931). Each `Peer` + now carries three independent address lists — `perm_addresses` (set via + `connect --perm`), `channel_peer_addresses` (captured at first channel + open), and `gossip_addresses` (current `NodeAnnouncement`) — plus + `is_persistent` and `reconnect_pending` status flags. 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 @@ -60,6 +91,18 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `--outgoing_chan_id` flag. +* `lncli connect` gains + [`--wait_for_dial`](https://github.com/lightningnetwork/lnd/pull/10931) (see + the `ConnectPeer` RPC entry above). + +* `lncli disconnect` gains + [`--forget` and `--force`](https://github.com/lightningnetwork/lnd/pull/10931) + (see the `DisconnectPeer` RPC entry above). + +* `lncli listpeers` gains + [`--include_offline_persistent_peers`](https://github.com/lightningnetwork/lnd/pull/10931) + (see the `ListPeers` RPC entry above). + # Improvements ## Functional Updates diff --git a/itest/list_on_test.go b/itest/list_on_test.go index d5b3d5051d1..7b37f3ca9d6 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -150,6 +150,22 @@ var allTestCases = []*lntest.TestCase{ Name: "reconnect after ip change", TestFunc: testReconnectAfterIPChange, }, + { + Name: "persistent peer survives restart", + TestFunc: testPersistentPeerSurvivesRestart, + }, + { + Name: "persistent peer address swap", + TestFunc: testPersistentPeerAddressSwap, + }, + { + Name: "persistent peer wait for dial", + TestFunc: testPersistentPeerWaitForDial, + }, + { + Name: "forget persistent peer with channel", + TestFunc: testForgetPersistentPeerWithChannel, + }, { Name: "addpeer config", TestFunc: testAddPeerConfig, diff --git a/itest/lnd_network_test.go b/itest/lnd_network_test.go index fb5c1758853..81ed1899534 100644 --- a/itest/lnd_network_test.go +++ b/itest/lnd_network_test.go @@ -333,3 +333,295 @@ func testDisconnectingTargetPeer(ht *lntest.HarnessTest) { // Check existing connection. ht.AssertConnected(alice, bob) } + +// testPersistentPeerSurvivesRestart verifies that a peer marked as persistent +// via `connect --perm` (with no channel between the two nodes) is reconnected +// to automatically after the initiating node restarts, that the new ListPeers +// fields reflect the persistence state, and that `disconnect --forget` +// disables the auto-reconnect for the following restart. +func testPersistentPeerSurvivesRestart(ht *lntest.HarnessTest) { + // Two fresh nodes with no channel between them, so the only reason + // lnd would reconnect them on restart is the user-perm bucket. + alice := ht.NewNode("Alice", nil) + bob := ht.NewNode("Bob", nil) + + bobInfo := bob.RPC.GetInfo() + + // Mark Bob as a permanent peer of Alice. + ht.ConnectNodesPerm(alice, bob) + ht.AssertConnected(alice, bob) + + // ListPeers from Alice should show Bob with is_persistent=true, + // perm_addresses set to Bob's P2P address, and reconnect_pending + // false (we're connected). + listResp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.Equal(ht, bobInfo.IdentityPubkey, listResp.Peers[0].PubKey) + require.True(ht, listResp.Peers[0].IsPersistent, + "is_persistent should be true for a --perm peer") + require.Contains(ht, listResp.Peers[0].PermAddresses, bob.Cfg.P2PAddr(), + "perm_addresses should include the address used at --perm") + require.False(ht, listResp.Peers[0].ReconnectPending, + "reconnect_pending should be false while connected") + + // Re-running --perm with the SAME address while connected should + // be idempotent: no duplicate entry in perm_addresses, no + // disconnect/reconnect dance. + alice.RPC.ConnectPeer(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: bob.Cfg.P2PAddr(), + }, + Perm: true, + }) + listResp = alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.ElementsMatch(ht, + []string{bob.Cfg.P2PAddr()}, + listResp.Peers[0].PermAddresses, + "repeating --perm with same address should be idempotent") + + // Adding a new (unreachable) --perm address while we are still + // connected via the original one should: append the new address to + // perm_addresses, attempt to dial it in the background, and leave + // the existing connection untouched (since the dial fails so no + // swap can occur). + extraAddr := "127.0.0.1:1" + alice.RPC.ConnectPeer(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: extraAddr, + }, + Perm: true, + }) + listResp = alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.ElementsMatch(ht, + []string{bob.Cfg.P2PAddr(), extraAddr}, + listResp.Peers[0].PermAddresses, + "adding a new --perm address while connected should record it") + require.Equal(ht, bob.Cfg.P2PAddr(), listResp.Peers[0].Address, + "the current connection should not be disrupted by a "+ + "failing dial to a new --perm address") + + // Restart Alice. With the new persistent-peer bucket in place she + // should automatically reconnect to Bob without any further RPC + // from us. + ht.RestartNode(alice) + ht.AssertConnected(alice, bob) + + // Now disconnect with --forget. The current connection drops, the + // bucket entry is removed, and after the next restart Alice should + // NOT reconnect to Bob. + alice.RPC.DisconnectPeerReq(&lnrpc.DisconnectPeerRequest{ + PubKey: bobInfo.IdentityPubkey, + Forget: true, + }) + ht.AssertPeerNotConnected(alice, bob) + + // While offline, include_offline_persistent_peers should NOT return + // Bob: we forgot him, so he is no longer in the persistent set. + listResp = alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{ + IncludeOfflinePersistentPeers: true, + }) + for _, p := range listResp.Peers { + require.NotEqual(ht, bobInfo.IdentityPubkey, p.PubKey, + "forgotten peer should not appear in ListPeers") + } + + // Restart Alice again — without --forget, this should have + // reconnected. With --forget, she stays disconnected. + ht.RestartNode(alice) + + // Give lnd a moment to (not) reconnect. AssertPeerNotConnected + // re-checks until the default timeout, so a stable not-connected + // state will pass. + ht.AssertPeerNotConnected(alice, bob) +} + +// testPersistentPeerAddressSwap verifies that running `connect --perm` with a +// new address for a peer we are already connected to causes lnd to atomically +// swap the active connection over to the new address (via +// OutboundPeerConnected's duplicate-handling logic) once the new dial +// succeeds. Both stored addresses end up in perm_addresses. +func testPersistentPeerAddressSwap(ht *lntest.HarnessTest) { + alice := ht.NewNode("Alice", nil) + + // Pre-allocate a second P2P port and tell Bob to listen on it in + // addition to the default port the harness assigns. + secondPort := port.NextAvailablePort() + secondAddr := fmt.Sprintf("127.0.0.1:%d", secondPort) + bob := ht.NewNode("Bob", []string{ + fmt.Sprintf("--listen=%s", secondAddr), + }) + bobInfo := bob.RPC.GetInfo() + + // First --perm connect on Bob's primary P2P address. + ht.ConnectNodesPerm(alice, bob) + ht.AssertConnected(alice, bob) + + listResp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.Equal(ht, bob.Cfg.P2PAddr(), listResp.Peers[0].Address, + "sanity: should be connected on Bob's primary address first") + + // Now --perm with the alternate address while still connected on + // the primary. The new dial should succeed (Bob is listening on + // both ports) and OutboundPeerConnected's duplicate-handling logic + // should swap the active connection over to the new address. + alice.RPC.ConnectPeer(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: secondAddr, + }, + Perm: true, + }) + + // Wait for the active connection's address to swap. + err := wait.NoError(func() error { + resp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + if len(resp.Peers) != 1 { + return fmt.Errorf("expected 1 peer, got %d", + len(resp.Peers)) + } + if resp.Peers[0].Address != secondAddr { + return fmt.Errorf("expected swap to %s, still on %s", + secondAddr, resp.Peers[0].Address) + } + return nil + }, defaultTimeout) + require.NoError(ht, err, + "expected the active connection to swap to %s", secondAddr) + + // Both addresses should now be in the persistent set. + listResp = alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.ElementsMatch(ht, + []string{bob.Cfg.P2PAddr(), secondAddr}, + listResp.Peers[0].PermAddresses, + "both addresses should be persisted after the swap") +} + +// testForgetPersistentPeerWithChannel verifies the --forget / --force +// interaction when there is an open channel with the peer: +// +// - disconnect --forget without --force preserves the LinkNode entry +// (so channel_peer_addresses is still populated after a reconnect), +// because otherwise the open channel would be orphaned from its +// address record. +// - disconnect --forget --force removes the LinkNode entry even with +// an open channel. +// +// Relies on integration builds defaulting --dev.unsafedisconnect=true so +// that disconnecting while channels exist is permitted. +func testForgetPersistentPeerWithChannel(ht *lntest.HarnessTest) { + alice := ht.NewNodeWithCoins("Alice", nil) + bob := ht.NewNode("Bob", nil) + bobInfo := bob.RPC.GetInfo() + + 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() *lnrpc.Peer { + resp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + for _, p := range resp.Peers { + if p.PubKey == bobInfo.IdentityPubkey { + return p + } + } + return nil + } + + // LinkNode is created at first channel open; channel_peer_addresses + // should reflect that. + require.NoError(ht, wait.NoError(func() error { + p := bobPeer() + if p == nil { + return fmt.Errorf("bob not yet in listpeers") + } + if len(p.ChannelPeerAddresses) == 0 { + return fmt.Errorf("channel_peer_addresses not yet " + + "populated") + } + return nil + }, defaultTimeout)) + + // --forget without --force: LinkNode preserved (channel still + // open). Reconnect and verify channel_peer_addresses is still + // populated. + alice.RPC.DisconnectPeerReq(&lnrpc.DisconnectPeerRequest{ + PubKey: bobInfo.IdentityPubkey, + Forget: true, + }) + ht.AssertPeerNotConnected(alice, bob) + + ht.ConnectNodes(alice, bob) + ht.AssertConnected(alice, bob) + + p := bobPeer() + require.NotNil(ht, p) + require.NotEmpty(ht, p.ChannelPeerAddresses, + "--forget alone should preserve LinkNode while channels "+ + "are open") + + // --forget --force: LinkNode removed regardless of channels. + alice.RPC.DisconnectPeerReq(&lnrpc.DisconnectPeerRequest{ + PubKey: bobInfo.IdentityPubkey, + Forget: true, + Force: true, + }) + ht.AssertPeerNotConnected(alice, bob) + + ht.ConnectNodes(alice, bob) + ht.AssertConnected(alice, bob) + + p = bobPeer() + require.NotNil(ht, p) + require.Empty(ht, p.ChannelPeerAddresses, + "--force should remove the LinkNode record even with an "+ + "open channel") +} + +// testPersistentPeerWaitForDial verifies that --perm with wait_for_dial=true +// only persists the address if the initial dial succeeded. +func testPersistentPeerWaitForDial(ht *lntest.HarnessTest) { + alice := ht.NewNode("Alice", nil) + bob := ht.NewNode("Bob", nil) + bobInfo := bob.RPC.GetInfo() + + // Successful sync dial: real address. Should connect and persist. + alice.RPC.ConnectPeer(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: bob.Cfg.P2PAddr(), + }, + Perm: true, + WaitForDial: true, + }) + ht.AssertConnected(alice, bob) + + listResp := alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.Contains(ht, listResp.Peers[0].PermAddresses, + bob.Cfg.P2PAddr(), + "reachable address should be persisted") + + // Failed sync dial: unreachable address. Should return an error + // and NOT add the unreachable address to perm_addresses. + unreachable := "127.0.0.1:1" + err := alice.RPC.ConnectPeerAssertErr(&lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: bobInfo.IdentityPubkey, + Host: unreachable, + }, + Perm: true, + WaitForDial: true, + }) + require.Error(ht, err, + "sync --perm to an unreachable address should fail") + + listResp = alice.RPC.ListPeersReq(&lnrpc.ListPeersRequest{}) + require.Len(ht, listResp.Peers, 1) + require.NotContains(ht, listResp.Peers[0].PermAddresses, unreachable, + "failed sync --perm should not persist the address") +} diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 30d38bbd280..aaccdb939a8 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -4164,8 +4164,17 @@ type ConnectPeerRequest struct { // peer. Otherwise, the call will be synchronous. Perm bool `protobuf:"varint,2,opt,name=perm,proto3" json:"perm,omitempty"` // The connection timeout value (in seconds) for this request. It won't affect - // other requests. - Timeout uint64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + // other requests. Not applicable when `perm` is set without `wait_for_dial`, + // since the dial happens asynchronously in that case. + Timeout uint64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + // Only meaningful with `perm = true`. By default `perm` makes the dial + // asynchronous: the RPC returns success immediately and the daemon keeps + // retrying in the background, persisting the address even if it cannot + // currently be reached. When `wait_for_dial` is set, the RPC waits for + // the initial dial to complete and returns its error on failure. The + // address is only persisted to the on-disk persistent-peer set if the + // dial succeeded. + WaitForDial bool `protobuf:"varint,4,opt,name=wait_for_dial,json=waitForDial,proto3" json:"wait_for_dial,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4221,6 +4230,13 @@ func (x *ConnectPeerRequest) GetTimeout() uint64 { return 0 } +func (x *ConnectPeerRequest) GetWaitForDial() bool { + if x != nil { + return x.WaitForDial + } + return false +} + type ConnectPeerResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The status of the connect operation. @@ -4269,7 +4285,17 @@ func (x *ConnectPeerResponse) GetStatus() string { type DisconnectPeerRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The pubkey of the node to disconnect from - PubKey string `protobuf:"bytes,1,opt,name=pub_key,json=pubKey,proto3" json:"pub_key,omitempty"` + PubKey string `protobuf:"bytes,1,opt,name=pub_key,json=pubKey,proto3" json:"pub_key,omitempty"` + // If set, the peer will also be removed from the on-disk set of persistent + // peers, so no automatic reconnection attempt will be made on the next LND + // restart. If unset, only the current connection is dropped, and the peer + // will be reconnected to after a restart if it was previously marked as + // persistent (via `connect --perm`). + Forget bool `protobuf:"varint,2,opt,name=forget,proto3" json:"forget,omitempty"` + // Only meaningful when `forget` is set. By default `forget` will not fully + // forget a peer while any open channels still exist with them. If `force` + // is set, the peer is forgotten regardless of open channels. + Force bool `protobuf:"varint,3,opt,name=force,proto3" json:"force,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4311,6 +4337,20 @@ func (x *DisconnectPeerRequest) GetPubKey() string { return "" } +func (x *DisconnectPeerRequest) GetForget() bool { + if x != nil { + return x.Forget + } + return false +} + +func (x *DisconnectPeerRequest) GetForce() bool { + if x != nil { + return x.Force + } + return false +} + type DisconnectPeerResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The status of the disconnect operation. @@ -5641,11 +5681,21 @@ func (x *ClosedChannelsResponse) GetChannels() []*ChannelCloseSummary { return nil } +// Address sources: a Peer carries three independent address-list fields +// — perm_addresses (user `connect --perm` bucket), channel_peer_addresses +// (LinkNode record captured at first channel open), gossip_addresses +// (current NodeAnnouncement). Each list reflects only what its source +// recorded; the same address may appear in 0, 1, 2, or all 3 fields, and +// lnd does not dedupe across them. Dial-path dedup happens internally +// before any outbound connection. 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"` @@ -5681,8 +5731,25 @@ 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(perm_addresses) > 0 || len(channel_peer_addresses) > 0). + IsPersistent bool `protobuf:"varint,16,opt,name=is_persistent,json=isPersistent,proto3" json:"is_persistent,omitempty"` + // Addresses passed by the user at `connect --perm` time. A non-empty value + // means the peer was explicitly marked as persistent by the user. + PermAddresses []string `protobuf:"bytes,17,rep,name=perm_addresses,json=permAddresses,proto3" json:"perm_addresses,omitempty"` + // Addresses captured when a channel was first opened with this peer + // (LinkNode record). A non-empty value means the peer is or was a channel + // counterparty. + ChannelPeerAddresses []string `protobuf:"bytes,18,rep,name=channel_peer_addresses,json=channelPeerAddresses,proto3" json:"channel_peer_addresses,omitempty"` + // Addresses currently advertised by this peer in the gossip network's + // NodeAnnouncement. Empty when no NodeAnnouncement is known. + GossipAddresses []string `protobuf:"bytes,19,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,20,opt,name=reconnect_pending,json=reconnectPending,proto3" json:"reconnect_pending,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Peer) Reset() { @@ -5813,6 +5880,41 @@ func (x *Peer) GetLastPingPayload() []byte { return nil } +func (x *Peer) GetIsPersistent() bool { + if x != nil { + return x.IsPersistent + } + return false +} + +func (x *Peer) GetPermAddresses() []string { + if x != nil { + return x.PermAddresses + } + return nil +} + +func (x *Peer) GetChannelPeerAddresses() []string { + if x != nil { + return x.ChannelPeerAddresses + } + 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. @@ -5872,9 +5974,15 @@ 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 it has been marked permanent via `connect --perm` or if there + // is an open channel with it. 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() { @@ -5914,6 +6022,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 @@ -18710,15 +18825,18 @@ const file_lightning_proto_rawDesc = "" + "\tsignature\x18\x02 \x01(\tR\tsignature\"E\n" + "\x15VerifyMessageResponse\x12\x14\n" + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x16\n" + - "\x06pubkey\x18\x02 \x01(\tR\x06pubkey\"o\n" + + "\x06pubkey\x18\x02 \x01(\tR\x06pubkey\"\x93\x01\n" + "\x12ConnectPeerRequest\x12+\n" + "\x04addr\x18\x01 \x01(\v2\x17.lnrpc.LightningAddressR\x04addr\x12\x12\n" + "\x04perm\x18\x02 \x01(\bR\x04perm\x12\x18\n" + - "\atimeout\x18\x03 \x01(\x04R\atimeout\"-\n" + + "\atimeout\x18\x03 \x01(\x04R\atimeout\x12\"\n" + + "\rwait_for_dial\x18\x04 \x01(\bR\vwaitForDial\"-\n" + "\x13ConnectPeerResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\"0\n" + + "\x06status\x18\x01 \x01(\tR\x06status\"^\n" + "\x15DisconnectPeerRequest\x12\x17\n" + - "\apub_key\x18\x01 \x01(\tR\x06pubKey\"0\n" + + "\apub_key\x18\x01 \x01(\tR\x06pubKey\x12\x16\n" + + "\x06forget\x18\x02 \x01(\bR\x06forget\x12\x14\n" + + "\x05force\x18\x03 \x01(\bR\x05force\"0\n" + "\x16DisconnectPeerResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\"\xa3\x02\n" + "\x04HTLC\x12\x1a\n" + @@ -18847,7 +18965,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\"\xe5\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" + @@ -18867,7 +18985,12 @@ 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\x12%\n" + + "\x0eperm_addresses\x18\x11 \x03(\tR\rpermAddresses\x124\n" + + "\x16channel_peer_addresses\x18\x12 \x03(\tR\x14channelPeerAddresses\x12)\n" + + "\x10gossip_addresses\x18\x13 \x03(\tR\x0fgossipAddresses\x12+\n" + + "\x11reconnect_pending\x18\x14 \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" + @@ -18878,9 +19001,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.pb.gw.go b/lnrpc/lightning.pb.gw.go index 00b1d8e843c..a2685911c01 100644 --- a/lnrpc/lightning.pb.gw.go +++ b/lnrpc/lightning.pb.gw.go @@ -427,6 +427,10 @@ func local_request_Lightning_ConnectPeer_0(ctx context.Context, marshaler runtim } +var ( + filter_Lightning_DisconnectPeer_0 = &utilities.DoubleArray{Encoding: map[string]int{"pub_key": 0, "pubKey": 1}, Base: []int{1, 1, 2, 0, 0}, Check: []int{0, 1, 1, 2, 3}} +) + func request_Lightning_DisconnectPeer_0(ctx context.Context, marshaler runtime.Marshaler, client LightningClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq DisconnectPeerRequest var metadata runtime.ServerMetadata @@ -448,6 +452,13 @@ func request_Lightning_DisconnectPeer_0(ctx context.Context, marshaler runtime.M return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "pub_key", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Lightning_DisconnectPeer_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DisconnectPeer(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err @@ -474,6 +485,13 @@ func local_request_Lightning_DisconnectPeer_0(ctx context.Context, marshaler run return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "pub_key", err) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Lightning_DisconnectPeer_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DisconnectPeer(ctx, &protoReq) return msg, metadata, err diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index be43c88a458..b66995a3238 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1257,9 +1257,21 @@ message ConnectPeerRequest { /* The connection timeout value (in seconds) for this request. It won't affect - other requests. + other requests. Not applicable when `perm` is set without `wait_for_dial`, + since the dial happens asynchronously in that case. */ uint64 timeout = 3; + + /* + Only meaningful with `perm = true`. By default `perm` makes the dial + asynchronous: the RPC returns success immediately and the daemon keeps + retrying in the background, persisting the address even if it cannot + currently be reached. When `wait_for_dial` is set, the RPC waits for + the initial dial to complete and returns its error on failure. The + address is only persisted to the on-disk persistent-peer set if the + dial succeeded. + */ + bool wait_for_dial = 4; } message ConnectPeerResponse { // The status of the connect operation. @@ -1269,6 +1281,22 @@ message ConnectPeerResponse { message DisconnectPeerRequest { // The pubkey of the node to disconnect from string pub_key = 1; + + /* + If set, the peer will also be removed from the on-disk set of persistent + peers, so no automatic reconnection attempt will be made on the next LND + restart. If unset, only the current connection is dropped, and the peer + will be reconnected to after a restart if it was previously marked as + persistent (via `connect --perm`). + */ + bool forget = 2; + + /* + Only meaningful when `forget` is set. By default `forget` will not fully + forget a peer while any open channels still exist with them. If `force` + is set, the peer is forgotten regardless of open channels. + */ + bool force = 3; } message DisconnectPeerResponse { // The status of the disconnect operation. @@ -1784,11 +1812,23 @@ message ClosedChannelsResponse { repeated ChannelCloseSummary channels = 1; } +// Address sources: a Peer carries three independent address-list fields +// — perm_addresses (user `connect --perm` bucket), channel_peer_addresses +// (LinkNode record captured at first channel open), gossip_addresses +// (current NodeAnnouncement). Each list reflects only what its source +// recorded; the same address may appear in 0, 1, 2, or all 3 fields, and +// lnd does not dedupe across them. Dial-path dedup happens internally +// before any outbound connection. 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 @@ -1866,6 +1906,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(perm_addresses) > 0 || len(channel_peer_addresses) > 0). + */ + bool is_persistent = 16; + + /* + Addresses passed by the user at `connect --perm` time. A non-empty value + means the peer was explicitly marked as persistent by the user. + */ + repeated string perm_addresses = 17; + + /* + Addresses captured when a channel was first opened with this peer + (LinkNode record). A non-empty value means the peer is or was a channel + counterparty. + */ + repeated string channel_peer_addresses = 18; + + /* + Addresses currently advertised by this peer in the gossip network's + NodeAnnouncement. Empty when no NodeAnnouncement is known. + */ + repeated string gossip_addresses = 19; + + /* + 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 = 20; } message TimestampedError { @@ -1883,6 +1955,15 @@ 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 it has been marked permanent via `connect --perm` or if there + is an open channel with it. 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 7be82853d4e..096704eab48 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 it has been marked permanent via `connect --perm` or if there\nis an open channel with it. Per-connection fields (address, bytes_sent,\nping_time, etc.) are empty/zero for these entries.", + "in": "query", + "required": false, + "type": "boolean" } ], "tags": [ @@ -2537,6 +2544,20 @@ "in": "path", "required": true, "type": "string" + }, + { + "name": "forget", + "description": "If set, the peer will also be removed from the on-disk set of persistent\npeers, so no automatic reconnection attempt will be made on the next LND\nrestart. If unset, only the current connection is dropped, and the peer\nwill be reconnected to after a restart if it was previously marked as\npersistent (via `connect --perm`).", + "in": "query", + "required": false, + "type": "boolean" + }, + { + "name": "force", + "description": "Only meaningful when `forget` is set. By default `forget` will not fully\nforget a peer while any open channels still exist with them. If `force`\nis set, the peer is forgotten regardless of open channels.", + "in": "query", + "required": false, + "type": "boolean" } ], "tags": [ @@ -4828,7 +4849,11 @@ "timeout": { "type": "string", "format": "uint64", - "description": "The connection timeout value (in seconds) for this request. It won't affect\nother requests." + "description": "The connection timeout value (in seconds) for this request. It won't affect\nother requests. Not applicable when `perm` is set without `wait_for_dial`,\nsince the dial happens asynchronously in that case." + }, + "wait_for_dial": { + "type": "boolean", + "description": "Only meaningful with `perm = true`. By default `perm` makes the dial\nasynchronous: the RPC returns success immediately and the daemon keeps\nretrying in the background, persisting the address even if it cannot\ncurrently be reached. When `wait_for_dial` is set, the RPC waits for\nthe initial dial to complete and returns its error on failure. The\naddress is only persisted to the on-disk persistent-peer set if the\ndial succeeded." } } }, @@ -6919,7 +6944,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 that\nis not currently connected (only present in the response when the request\nsets `include_offline_persistent_peers`)." }, "bytes_sent": { "type": "string", @@ -6983,8 +7008,38 @@ "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 peer.\nEquivalent to (len(perm_addresses) \u003e 0 || len(channel_peer_addresses) \u003e 0)." + }, + "perm_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses passed by the user at `connect --perm` time. A non-empty value\nmeans the peer was explicitly marked as persistent by the user." + }, + "channel_peer_addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses captured when a channel was first opened with this peer\n(LinkNode record). A non-empty value means the peer is or was a channel\ncounterparty." + }, + "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 peer.\nMainly informative for entries returned when the request sets\n`include_offline_persistent_peers`." } - } + }, + "description": "Address sources: a Peer carries three independent address-list fields\n— perm_addresses (user `connect --perm` bucket), channel_peer_addresses\n(LinkNode record captured at first channel open), gossip_addresses\n(current NodeAnnouncement). Each list reflects only what its source\nrecorded; the same address may appear in 0, 1, 2, or all 3 fields, and\nlnd does not dedupe across them. Dial-path dedup happens internally\nbefore any outbound connection." }, "lnrpcPeerEvent": { "type": "object", diff --git a/lntest/rpc/lnd.go b/lntest/rpc/lnd.go index a9dc742e619..1d9560f6331 100644 --- a/lntest/rpc/lnd.go +++ b/lntest/rpc/lnd.go @@ -65,6 +65,33 @@ func (h *HarnessRPC) DisconnectPeer( return resp } +// DisconnectPeerReq calls DisconnectPeer with a fully formed request, allowing +// tests to exercise the forget / force flags. +func (h *HarnessRPC) DisconnectPeerReq( + req *lnrpc.DisconnectPeerRequest) *lnrpc.DisconnectPeerResponse { + + ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout) + defer cancel() + + resp, err := h.LN.DisconnectPeer(ctxt, req) + h.NoError(err, "DisconnectPeer") + + 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 +} + // DeleteAllPayments makes a RPC call to the node's DeleteAllPayments and // asserts. func (h *HarnessRPC) DeleteAllPayments() { diff --git a/pilot.go b/pilot.go index 58d971ca8e4..9909f0e6ac9 100644 --- a/pilot.go +++ b/pilot.go @@ -228,8 +228,9 @@ func initAutoPilot(svr *server, cfg *lncfg.AutoPilot, "address type %T", addr) } - err := svr.ConnectToPeer( - lnAddr, false, svr.cfg.ConnectionTimeout, + _, err := svr.ConnectToPeer( + lnAddr, false, false, + svr.cfg.ConnectionTimeout, ) if err != nil { // If we weren't able to connect to the @@ -251,7 +252,9 @@ func initAutoPilot(svr *server, cfg *lncfg.AutoPilot, return false, nil }, - DisconnectPeer: svr.DisconnectPeer, + DisconnectPeer: func(pk *btcec.PublicKey) error { + return svr.DisconnectPeer(pk, false, false) + }, } // Create and return the autopilot.ManagerCfg that administrates this diff --git a/rpcserver.go b/rpcserver.go index 9b2202d39db..7882770e0b9 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -1874,9 +1874,10 @@ func (r *rpcServer) ConnectPeer(ctx context.Context, timeout) } - if err := r.server.ConnectToPeer( - peerAddr, in.Perm, timeout, - ); err != nil { + status, err := r.server.ConnectToPeer( + peerAddr, in.Perm, in.WaitForDial, timeout, + ) + if err != nil { rpcsLog.Errorf("[connectpeer]: error connecting to peer: %v", err) return nil, err @@ -1884,10 +1885,11 @@ func (r *rpcServer) ConnectPeer(ctx context.Context, rpcsLog.Debugf("Connected to peer: %v", peerAddr.String()) - return &lnrpc.ConnectPeerResponse{ - Status: fmt.Sprintf("connection to %v initiated", - peerAddr.String()), - }, nil + if status == "" { + status = fmt.Sprintf("connection to %v initiated", + peerAddr.String()) + } + return &lnrpc.ConnectPeerResponse{Status: status}, nil } // DisconnectPeer attempts to disconnect one peer from another identified by a @@ -1944,9 +1946,15 @@ func (r *rpcServer) DisconnectPeer(ctx context.Context, pubKeyBytes, len(nodeChannels)) } + // `force` is only meaningful in combination with `forget`. + if in.Force && !in.Forget { + return nil, fmt.Errorf("force can only be set together " + + "with forget") + } + // With all initial validation complete, we'll now request that the // server disconnects from the peer. - err = r.server.DisconnectPeer(peerPubKey) + err = r.server.DisconnectPeer(peerPubKey, in.Forget, in.Force) if err != nil { return nil, fmt.Errorf("unable to disconnect peer: %w", err) } @@ -3569,6 +3577,62 @@ func (r *rpcServer) ListPeers(ctx context.Context, Peers: make([]*lnrpc.Peer, 0, len(serverPeers)), } + // Pre-fetch the persistent-peer bucket and the LinkNode store into + // pubkey-keyed maps, so the per-peer loop becomes constant lookups + // rather than per-peer DB hits. + permPeers, err := r.server.miscDB.FetchAllPersistentPeers() + if err != nil { + return nil, fmt.Errorf("unable to fetch persistent peers: %w", + err) + } + permAddrsByPub := make(map[string][]net.Addr, len(permPeers)) + for _, p := range permPeers { + key := string(p.PubKey.SerializeCompressed()) + permAddrsByPub[key] = p.Addresses + } + + 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) + } + linkAddrsByPub := make(map[string][]net.Addr, len(linkNodes)) + for _, ln := range linkNodes { + key := string(ln.IdentityPub.SerializeCompressed()) + linkAddrsByPub[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 @@ -3689,10 +3753,46 @@ func (r *rpcServer) ListPeers(ctx context.Context, } } + // Enrich with the new persistent / multi-source address fields. + pubStr := string(nodePub[:]) + connectedPubs[pubStr] = struct{}{} + + rpcPeer.PermAddresses = toStrs(permAddrsByPub[pubStr]) + rpcPeer.ChannelPeerAddresses = toStrs(linkAddrsByPub[pubStr]) + rpcPeer.GossipAddresses = gossipAddrsFor(nodePub[:]) + rpcPeer.IsPersistent = len(rpcPeer.PermAddresses) > 0 || + len(rpcPeer.ChannelPeerAddresses) > 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) + perm := toStrs(permAddrsByPub[pubStr]) + link := toStrs(linkAddrsByPub[pubStr]) + gossip := gossipAddrsFor(pubBytes) + + resp.Peers = append(resp.Peers, &lnrpc.Peer{ + PubKey: hex.EncodeToString(pubBytes), + IsPersistent: true, + PermAddresses: perm, + ChannelPeerAddresses: link, + GossipAddresses: gossip, + ReconnectPending: pending, + }) + } + } + + rpcsLog.Debugf("[listpeers] yielded %d peers", len(resp.Peers)) return resp, nil } diff --git a/server.go b/server.go index 6baeaeaf787..335080c3132 100644 --- a/server.go +++ b/server.go @@ -2622,8 +2622,8 @@ func (s *server) Start(ctx context.Context) error { ChainNet: s.cfg.ActiveNetParams.Net, } - err = s.ConnectToPeer( - peerAddr, true, + _, err = s.ConnectToPeer( + peerAddr, true, false, s.cfg.ConnectionTimeout, ) if err != nil { @@ -2876,7 +2876,7 @@ func (s *server) Stop() error { // Disconnect from each active peers to ensure that // peerTerminationWatchers signal completion to each peer. for _, peer := range s.Peers() { - err := s.DisconnectPeer(peer.IdentityKey()) + err := s.DisconnectPeer(peer.IdentityKey(), false, false) if err != nil { srvrLog.Warnf("could not disconnect peer: %v"+ "received error: %v", peer.IdentityKey(), @@ -3784,6 +3784,45 @@ func (s *server) establishPersistentConnections(ctx context.Context) error { nodeAddrsMap[pubStr] = nodeAddr } + // Also fetch peers that the user has explicitly requested to remain + // connected to across restarts (e.g. via `lncli connect --perm`). + // These should be reconnected to on startup regardless of whether + // we share a channel with them. + userPermPeers, err := s.miscDB.FetchAllPersistentPeers() + if err != nil { + return fmt.Errorf("failed to fetch user persistent peers: %w", + err) + } + + if len(userPermPeers) > 0 { + srvrLog.Infof("Reconnecting to %d user-requested persistent "+ + "peer(s) from DB", len(userPermPeers)) + } + + userPermPeerSet := make(map[string]struct{}, len(userPermPeers)) + for _, pp := range userPermPeers { + pubStr := string(pp.PubKey.SerializeCompressed()) + userPermPeerSet[pubStr] = struct{}{} + + n, ok := nodeAddrsMap[pubStr] + if !ok { + n = &nodeAddresses{pubKey: pp.PubKey} + nodeAddrsMap[pubStr] = n + } + + seen := make(map[string]struct{}, len(n.addresses)) + for _, a := range n.addresses { + seen[a.String()] = struct{}{} + } + for _, addr := range withoutV2Onion(pp.Addresses) { + if _, ok := seen[addr.String()]; ok { + continue + } + seen[addr.String()] = struct{}{} + n.addresses = append(n.addresses, addr) + } + } + srvrLog.Debugf("Establishing %v persistent connections on start", len(nodeAddrsMap)) @@ -3797,11 +3836,12 @@ func (s *server) establishPersistentConnections(ctx context.Context) error { var numOutboundConns int for pubStr, nodeAddr := range nodeAddrsMap { // Add this peer to the set of peers we should maintain a - // persistent connection with. We set the value to false to - // indicate that we should not continue to reconnect if the - // number of channels returns to zero, since this peer has not - // been requested as perm by the user. - s.persistentPeers[pubStr] = false + // persistent connection with. Peers that the user has + // explicitly marked as permanent are stored as `true` so that + // they are not pruned when the channel count drops to zero. + // All others are stored as `false`. + _, userPerm := userPermPeerSet[pubStr] + s.persistentPeers[pubStr] = userPerm if _, ok := s.persistentPeersBackoff[pubStr]; !ok { s.persistentPeersBackoff[pubStr] = s.cfg.MinBackoff } @@ -4464,7 +4504,7 @@ func (s *server) notifyFundingTimeoutPeerEvent(op wire.OutPoint, if errors.Is(err, ErrNoMoreRestrictedAccessSlots) { // If we encounter an error while attempting to disconnect the // peer, log the error. - if dcErr := s.DisconnectPeer(remotePub); dcErr != nil { + if dcErr := s.DisconnectPeer(remotePub, false, false); dcErr != nil { srvrLog.Errorf("Unable to disconnect peer: %v\n", err) } } @@ -4597,7 +4637,9 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, ChannelNotifier: s.channelNotifier, HtlcNotifier: s.htlcNotifier, TowerClient: towerClient, - DisconnectPeer: s.DisconnectPeer, + DisconnectPeer: func(pk *btcec.PublicKey) error { + return s.DisconnectPeer(pk, false, false) + }, GenNodeAnnouncement: func(...netann.NodeAnnModifier) ( lnwire.NodeAnnouncement1, error) { @@ -5157,9 +5199,14 @@ func (s *server) removePeerUnsafe(ctx context.Context, p *peer.Brontide) { // at the specified address. This function will *block* until either a // connection is established, or the initial handshake process fails. // +// The returned status string, when non-empty, contains a human-readable +// description of the action taken (e.g. "already connected; marked as +// persistent"). When empty, the caller should fall back to a default +// "connection initiated" message. +// // NOTE: This function is safe for concurrent access. func (s *server) ConnectToPeer(addr *lnwire.NetAddress, - perm bool, timeout time.Duration) error { + perm, waitForDial bool, timeout time.Duration) (string, error) { targetPub := string(addr.IdentityKey.SerializeCompressed()) @@ -5171,33 +5218,175 @@ func (s *server) ConnectToPeer(addr *lnwire.NetAddress, // Ensure we're not already connected to this peer. peer, err := s.findPeerByPubStr(targetPub) + alreadyConnected := err == nil - // When there's no error it means we already have a connection with this - // peer. If this is a dev environment with the `--unsafeconnect` flag - // set, we will ignore the existing connection and continue. + // When there's no error it means we already have a connection with + // this peer. If this is a dev environment with `--unsafeconnect`, we + // ignore the existing connection and continue. Otherwise: + // + // - For a non-perm request, return errPeerAlreadyConnected so the + // caller knows the dial was redundant (existing behavior). + // + // - For a perm request where the user-supplied address matches + // the address we're already connected on, just record the + // persistence intent without re-dialing — re-dialing the same + // address would just cause an unnecessary brief reconnect. + // + // - For a perm request with a NEW address, fall through to the + // perm branch below. The new outbound dial will go through and + // OutboundPeerConnected's existing duplicate-handling logic + // will atomically swap the connection over to the new address + // if/when the dial succeeds; if it never succeeds, the old + // connection stays untouched. if err == nil && !s.cfg.Dev.GetUnsafeConnect() { - s.mu.Unlock() - return &errPeerAlreadyConnected{peer: peer} - } + if !perm { + s.mu.Unlock() + return "", &errPeerAlreadyConnected{peer: peer} + } - // Peer was not found, continue to pursue connection with peer. + sameAddr := peer.Address().String() == addr.Address.String() + if sameAddr { + s.persistentPeers[targetPub] = true + if _, ok := s.persistentPeersBackoff[targetPub]; !ok { + s.persistentPeersBackoff[targetPub] = + s.cfg.MinBackoff + } + + existing := make(map[string]struct{}, + len(s.persistentPeerAddrs[targetPub])) + for _, a := range s.persistentPeerAddrs[targetPub] { + existing[a.Address.String()] = struct{}{} + } + if _, ok := existing[addr.Address.String()]; !ok { + s.persistentPeerAddrs[targetPub] = append( + s.persistentPeerAddrs[targetPub], addr, + ) + } + + s.mu.Unlock() + + addedNew, addErr := s.miscDB.AddPersistentPeer( + addr.IdentityKey, []net.Addr{addr.Address}, + ) + if addErr != nil { + srvrLog.Errorf("Unable to persist permanent "+ + "peer %x: %v", + addr.IdentityKey.SerializeCompressed(), + addErr) + return "", nil + } - // If there's already a pending connection request for this pubkey, - // then we ignore this request to ensure we don't create a redundant - // connection. - if reqs, ok := s.persistentConnReqs[targetPub]; ok { - srvrLog.Warnf("Already have %d persistent connection "+ - "requests for %v, connecting anyway.", len(reqs), addr) + if addedNew { + srvrLog.Infof("Marked already-connected peer "+ + "%x as permanent on its current "+ + "address %v", + addr.IdentityKey.SerializeCompressed(), + addr.Address) + return fmt.Sprintf("peer %x already "+ + "connected on %v; marked as "+ + "persistent", + addr.IdentityKey.SerializeCompressed(), + addr.Address), nil + } + return fmt.Sprintf("peer %x already connected on %v "+ + "and already persistent", + addr.IdentityKey.SerializeCompressed(), + addr.Address), nil + } + // Different address requested — let the perm branch below + // initiate a fresh dial; the swap is handled by + // OutboundPeerConnected. + srvrLog.Infof("Adding new permanent address %v for "+ + "peer %x (currently connected on %v); will swap on "+ + "successful dial", + addr.Address, + addr.IdentityKey.SerializeCompressed(), + peer.Address()) } + // Peer was not found, continue to pursue connection with peer. + // If there's not already a pending or active connection to this node, // then instruct the connection manager to attempt to establish a // persistent connection to the peer. srvrLog.Debugf("Connecting to %v", addr) if perm { - connReq := &connmgr.ConnReq{ - Addr: addr, - Permanent: true, + // Synchronous-dial variant: wait for the initial dial to + // complete and only persist the address if it succeeded. + // This is the opt-in mode for callers who want to verify + // reachability before recording the address. + if waitForDial { + s.mu.Unlock() + + errChan := make(chan error, 1) + s.connectToPeer(addr, errChan, timeout) + select { + case err := <-errChan: + if err != nil { + return "", err + } + case <-s.quit: + return "", ErrServerShuttingDown + } + + // Dial succeeded — connection is up (OutboundPeer- + // Connected fired inside connectToPeer, including the + // swap path if a prior connection existed). Mark the + // peer as user-perm and record the address in memory + // and on disk. + s.mu.Lock() + s.persistentPeers[targetPub] = true + if _, ok := s.persistentPeersBackoff[targetPub]; !ok { + s.persistentPeersBackoff[targetPub] = + s.cfg.MinBackoff + } + existing := make(map[string]struct{}, + len(s.persistentPeerAddrs[targetPub])) + for _, a := range s.persistentPeerAddrs[targetPub] { + existing[a.Address.String()] = struct{}{} + } + if _, ok := existing[addr.Address.String()]; !ok { + s.persistentPeerAddrs[targetPub] = append( + s.persistentPeerAddrs[targetPub], addr, + ) + } + s.mu.Unlock() + + if _, err := s.miscDB.AddPersistentPeer( + addr.IdentityKey, []net.Addr{addr.Address}, + ); err != nil { + srvrLog.Errorf("Unable to persist permanent "+ + "peer %x: %v", + addr.IdentityKey.SerializeCompressed(), + err) + } else { + srvrLog.Infof("Sync-dialed and stored "+ + "permanent peer %x address %v", + addr.IdentityKey.SerializeCompressed(), + addr.Address) + } + return "", nil + } + + // Async path (default --perm). Persist the intent first, + // then start an async dial via the connection manager. The + // address is recorded even if the dial never succeeds, so + // the peer remains in our retry set across restarts. + + // Check whether we already have a pending persistent + // connection request for this exact (pubkey, address) pair. + // If so, treat the call as a no-op for the connection + // manager so we don't stack duplicate retries on repeated + // --perm calls. We still record the persistence intent in + // the DB below (the channeldb union is idempotent), which + // matters when an earlier conn req came from the channel- + // driven path with no bucket entry yet. + haveExistingReq := false + for _, req := range s.persistentConnReqs[targetPub] { + if req.Addr.String() == addr.String() { + haveExistingReq = true + break + } } // Since the user requested a permanent connection, we'll set @@ -5208,14 +5397,75 @@ func (s *server) ConnectToPeer(addr *lnwire.NetAddress, if _, ok := s.persistentPeersBackoff[targetPub]; !ok { s.persistentPeersBackoff[targetPub] = s.cfg.MinBackoff } - s.persistentConnReqs[targetPub] = append( - s.persistentConnReqs[targetPub], connReq, - ) + + var connReq *connmgr.ConnReq + if !haveExistingReq { + connReq = &connmgr.ConnReq{ + Addr: addr, + Permanent: true, + } + s.persistentConnReqs[targetPub] = append( + s.persistentConnReqs[targetPub], connReq, + ) + } + + // Track this address in the in-memory persistent-peer-addrs + // set so the connection manager will retry on it. + existing := make(map[string]struct{}, + len(s.persistentPeerAddrs[targetPub])) + for _, a := range s.persistentPeerAddrs[targetPub] { + existing[a.Address.String()] = struct{}{} + } + if _, ok := existing[addr.Address.String()]; !ok { + s.persistentPeerAddrs[targetPub] = append( + s.persistentPeerAddrs[targetPub], addr, + ) + } + s.mu.Unlock() - go s.connMgr.Connect(connReq) + // Persist exactly the address the user typed at `--perm` + // time. The channeldb call unions with any previously stored + // entries for the same peer; we deliberately do NOT persist + // the broader in-memory persistentPeerAddrs union here, + // because that set also contains addresses sourced from + // LinkNode/graph and we want `stored_addresses` to mean only + // "what the user typed via --perm". + if _, err := s.miscDB.AddPersistentPeer( + addr.IdentityKey, []net.Addr{addr.Address}, + ); err != nil { + srvrLog.Errorf("Unable to persist permanent peer "+ + "%x: %v", + addr.IdentityKey.SerializeCompressed(), err) + } else { + srvrLog.Infof("Stored permanent peer %x address %v "+ + "for reconnection across restarts", + addr.IdentityKey.SerializeCompressed(), + addr.Address) + } - return nil + if connReq != nil { + go s.connMgr.Connect(connReq) + } else { + srvrLog.Debugf("Skipping duplicate --perm conn req "+ + "for %v: an active conn req already exists "+ + "for this address", addr) + } + + // If we got here via the "already-connected with a new + // address" fall-through, surface that in the status so the + // caller (and CLI user) knows the dial they just initiated is + // going to swap the active connection rather than open a + // fresh one. + if alreadyConnected { + return fmt.Sprintf("peer %x already connected on "+ + "%v; added %v to persistent set, will swap "+ + "on successful dial", + addr.IdentityKey.SerializeCompressed(), + peer.Address(), addr.Address), nil + } + + return "", nil } s.mu.Unlock() @@ -5228,9 +5478,9 @@ func (s *server) ConnectToPeer(addr *lnwire.NetAddress, select { case err := <-errChan: - return err + return "", err case <-s.quit: - return ErrServerShuttingDown + return "", ErrServerShuttingDown } } @@ -5260,11 +5510,34 @@ func (s *server) connectToPeer(addr *lnwire.NetAddress, s.OutboundPeerConnected(nil, conn) } +// 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 +} + // DisconnectPeer sends the request to server to close the connection with peer -// identified by public key. +// identified by public key. If forget is true, the peer is also removed from +// the on-disk set of persistent peers so that it will not be reconnected to on +// the next LND restart. If force is true (only meaningful with forget), the +// peer's LinkNode record is removed even when open channels exist with the +// peer. // // NOTE: This function is safe for concurrent access. -func (s *server) DisconnectPeer(pubKey *btcec.PublicKey) error { +func (s *server) DisconnectPeer(pubKey *btcec.PublicKey, + forget, force bool) error { + pubBytes := pubKey.SerializeCompressed() pubStr := string(pubBytes) @@ -5279,7 +5552,8 @@ func (s *server) DisconnectPeer(pubKey *btcec.PublicKey) error { return fmt.Errorf("peer %x is not connected", pubBytes) } - srvrLog.Infof("Disconnecting from %v", peer) + srvrLog.Infof("Disconnecting from %v (forget=%v, force=%v)", peer, + forget, force) s.cancelConnReqs(pubStr, nil) @@ -5289,6 +5563,52 @@ func (s *server) DisconnectPeer(pubKey *btcec.PublicKey) error { delete(s.persistentPeers, pubStr) delete(s.persistentPeersBackoff, pubStr) + // If the caller asked to forget this peer, also drop the on-disk + // records so the auto-reconnect on next restart doesn't bring it + // back. + if forget { + if err := s.miscDB.DeletePersistentPeer(pubKey); err != nil { + srvrLog.Errorf("Unable to remove persistent peer %x "+ + "from DB: %v", pubBytes, err) + } + + // Decide whether to also drop the LinkNode (channel-peer) + // record. With `force` we always drop it; otherwise we only + // drop it when no open channels remain, to avoid orphaning a + // live channel from its address record. + dropLinkNode := force + if !force { + nodeChannels, fcErr := s.chanStateDB.FetchOpenChannels( + pubKey, + ) + switch { + case fcErr != nil: + srvrLog.Errorf("Unable to check open "+ + "channels for peer %x while "+ + "forgetting: %v", pubBytes, fcErr) + + case len(nodeChannels) > 0: + srvrLog.Infof("Keeping LinkNode for peer "+ + "%x: %d open channel(s) still "+ + "exist; pass force=true to remove "+ + "anyway", pubBytes, len(nodeChannels)) + + default: + dropLinkNode = true + } + } + if dropLinkNode { + err := s.linkNodeDB.DeleteLinkNode(pubKey) + if err != nil && + !errors.Is(err, channeldb.ErrLinkNodesNotFound) && + !errors.Is(err, channeldb.ErrNodeNotFound) { + + srvrLog.Errorf("Unable to remove LinkNode "+ + "for peer %x: %v", pubBytes, err) + } + } + } + // Remove the peer by calling Disconnect. Previously this was done with // removePeerUnsafe, which bypassed the peerTerminationWatcher. //