Skip to content

multi: expose peer address sources and offline peers in ListPeers#10973

Open
ZZiigguurraatt wants to merge 1 commit into
lightningnetwork:masterfrom
ZZiigguurraatt:expand_listpeers
Open

multi: expose peer address sources and offline peers in ListPeers#10973
ZZiigguurraatt wants to merge 1 commit into
lightningnetwork:masterfrom
ZZiigguurraatt:expand_listpeers

Conversation

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

ListPeers previously returned only currently-connected peers with a single address field, leaving no way to see:

  1. Why lnd is maintaining a connection to a given peer.
  2. What addresses lnd has stored for reconnecting to the peer.
  3. What the peer currently advertises in its NodeAnnouncement.
  4. Which peers lnd is auto-reconnecting to but is not currently connected to.
  5. Whether a reconnect attempt is in flight.

This commit fills those gaps by adding to the Peer message:

  • is_persistent — true if lnd is auto-reconnecting to the peer.
  • remembered_addresses — addresses lnd has stored for the peer (from the LinkNode record captured at first channel open).
  • gossip_addresses — addresses from the peer's current NodeAnnouncement, populated inline instead of requiring a separate GetNodeInfo call per peer.
  • reconnect_pending — true if lnd currently has an in-flight reconnect attempt for the peer.

ListPeersRequest gains include_offline_persistent_peers. When set, the response also includes peers that are in lnd's auto-reconnect set but not currently connected. Per-connection fields (address, bytes_sent, ping_time, etc.) are empty/zero for those entries.

lncli listpeers gains an --include_offline_persistent_peers flag.

itest: listpeers address fields opens a channel between two nodes, suspends the peer, and asserts the new field state in both the connected and offline-persistent cases.

Partial fix for #10871

ListPeers previously returned only currently-connected peers with a single
`address` field, leaving no way to see:

  1. Why lnd is maintaining a connection to a given peer.
  2. What addresses lnd has stored for reconnecting to the peer.
  3. What the peer currently advertises in its NodeAnnouncement.
  4. Which peers lnd is auto-reconnecting to but is not currently connected
     to.
  5. Whether a reconnect attempt is in flight.

This commit fills those gaps by adding to the `Peer` message:

  * `is_persistent` — true if lnd is auto-reconnecting to the peer.
  * `remembered_addresses` — addresses lnd has stored for the peer (from
    the LinkNode record captured at first channel open).
  * `gossip_addresses` — addresses from the peer's current
    NodeAnnouncement, populated inline instead of requiring a separate
    GetNodeInfo call per peer.
  * `reconnect_pending` — true if lnd currently has an in-flight reconnect
    attempt for the peer.

`ListPeersRequest` gains `include_offline_persistent_peers`. When set, the
response also includes peers that are in lnd's auto-reconnect set but not
currently connected. Per-connection fields (address, bytes_sent, ping_time,
etc.) are empty/zero for those entries.

`lncli listpeers` gains an `--include_offline_persistent_peers` flag.

itest: `listpeers address fields` opens a channel between two nodes,
suspends the peer, and asserts the new field state in both the connected
and offline-persistent cases.

Partial fix for lightningnetwork#10871
@github-actions

Copy link
Copy Markdown

PR Severity: CRITICAL 🔴

Classified via file-path analysis | 10 files | 395 lines changed

🔴 Critical (2 files)
  • rpcserver.go - Core server coordination (explicitly listed critical file)
  • server.go - Core server coordination (explicitly listed critical file)
🟠 High (3 files)
  • lnrpc/lightning.proto - RPC/API definition change (lnrpc/*)
  • lnrpc/lightning.pb.go - Generated RPC bindings from the proto change (lnrpc/*; excluded from line/file bump counting as auto-generated)
  • lnrpc/lightning.swagger.json - Generated swagger spec from the proto change (lnrpc/*)
🟡 Medium (1 file)
  • cmd/commands/commands.go - CLI client command wiring (classified by package path, not inherited from server-side severity)
🟢 Low (4 files)
  • docs/release-notes/release-notes-0.22.0.md - Release notes
  • itest/list_on_test.go - Integration test
  • itest/lnd_network_test.go - Integration test
  • lntest/rpc/lnd.go - Test harness RPC helper

Analysis

The PR adds new functionality touching server.go and rpcserver.go directly (Core server coordination), which are both explicitly listed CRITICAL files — this alone sets the overall severity floor at CRITICAL. The corresponding lnrpc/* proto/RPC changes (proto, generated .pb.go, and swagger spec) are HIGH, and the CLI wiring in cmd/commands/commands.go is MEDIUM per the rule that cmd/* is always classified by its own package path rather than inheriting from any similarly-named server-side package. The remaining changes are test/docs-only and rated LOW.

No severity bump was applied: after excluding test files (itest/*, lntest/*) and auto-generated files (lnrpc/lightning.pb.go), the change touches 6 files and ~207 lines — both well under the 20-file / 500-line bump thresholds. The critical files (server.go, rpcserver.go) fall under a single critical category (Core server coordination), not multiple distinct critical packages, so no additional bump applies there either.

Given the direct modification of server.go and rpcserver.go, this PR should receive expert review of the core server coordination changes in addition to review of the new RPC surface.


To override, add a severity-override-{critical,high,medium,low} label.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Jul 15, 2026
@ZZiigguurraatt

Copy link
Copy Markdown
Contributor Author

/gateway review

@lightninglabs-gateway

Copy link
Copy Markdown

Sorry, /gateway review requires maintainer permission.

ZZiigguurraatt added a commit to ZZiigguurraatt/lnd that referenced this pull request Jul 16, 2026
When lnd completes an outbound connection to a peer that we have an
open channel with, record the address we dialed in that peer's LinkNode
entry (if it is not already listed). Peers without an open channel are
skipped — so casual connections do not grow the on-disk store, and a
lingering entry for a peer whose channels were closed but not yet
reaped by `PruneLinkNodes` will not be extended if we happen to
reconnect to them in the meantime.

Motivation
----------

There are two sources of addresses used to reach a channel peer:

  1. LinkNode — the peer's address stored in the channel state DB.
  2. Gossip — addresses from the peer's most recent
     `NodeAnnouncement`, stored in the on-disk channel graph.

On startup, `establishPersistentConnections` dials the union of these
two sources. That behaviour is unchanged by this commit.

Before this commit:

  LinkNode was written once at channel-open and not updated afterward.
  Gossip carries subsequent listener changes when a peer re-signs and
  re-broadcasts a `NodeAnnouncement`, but there are cases where the
  address we can actually reach the peer at is not in their current
  gossip entry:

    * the peer has since removed the address from their
      `NodeAnnouncement` but is still listening on the same host and
      port,
    * the peer moved to a new host and/or port and we learned the new
      address out-of-band faster than the peer's re-broadcast
      `NodeAnnouncement` could catch up, or
    * the peer never advertised the address in gossip in the first
      place (private channel, LSP setup, LAN address).

  In any of these cases, lnd could reach the peer at the address at
  some point — via automatic reconnect (at startup or after a
  mid-session disconnect) while the address was still in gossip, or
  via `lncli connect <pub>@<addr>` for addresses that were never in
  gossip or where gossip lagged the peer's move. But the address was
  not persisted anywhere — so the next restart's startup dial failed
  even though a live connection had just worked.

After this commit:

  Every successful outbound connection to a channel peer appends the
  dialed address to the peer's LinkNode entry (deduped by string). The
  next restart's startup dial includes it, so an operator-supplied or
  otherwise learned address survives across restarts for channel peers.

  Non-channel peers, addresses already listed, and inbound connections
  are skipped (see below for the reasoning behind the inbound skip).

Only outbound
-------------

The auto-persist path runs only for outbound connections. For inbound
TCP the peer's `RemoteAddr()` is the ephemeral source port their
kernel picked for the outbound half of their side, not a listener we
could dial back to. Persisting it would inflate the LinkNode address
list with non-dialable entries that fail on every subsequent startup
dial.

There is no equivalent for learning a peer's new listener from an
inbound connection today: BOLT #1 `Init` does not carry a listen
address, and `NodeAnnouncement` gossip is the intended source of
truth. lnd already forwards this: an lnd peer with a confirmed public
channel to us pushes its signed `NodeAnnouncement` on reconnect
(`peer.maybeSendNodeAnn`), and the gossiper writes it to the on-disk
channel graph, which `establishPersistentConnections` reads. That
covers the public-channel-with-lnd-peer case automatically. The gap
this commit narrows is what remains: operator-supplied addresses for
private channels, non-lnd peers, or gossip-lag windows.

Design
------

  * `channeldb.LinkNodeDB.AddAddressIfPeerKnown(pub, addr)` appends
    `addr` to the peer's LinkNode entry when one already exists and the
    address is not already listed. It is a no-op (nil error) when the
    peer has no LinkNode entry, which is the invariant we need to gate
    the store's growth.

  * `server.maybePersistPeerAddress` is called from `peerConnected`
    only when `inbound` is false and only after `addPeer`. It checks
    `chanStateDB.FetchOpenChannels` for the peer and only then calls
    the DB helper. Errors are logged and swallowed.

Since channel peers always have a LinkNode entry (created at channel
open, and `RepairLinkNodes` reinstates missing ones at startup), the
"no-op when no entry" branch of `AddAddressIfPeerKnown` will not fire
in practice for channel peers — the open-channel gate is what keeps the
store from growing.

The new address is surfaced through the existing `remembered_addresses`
field on `Peer` introduced in lightningnetwork#10973; no new RPC surface is required.

itest
-----

`auto persist channel peer address` opens a channel between Alice and
Bob at Bob's initial P2P port, disconnects them, restarts Bob on a new
port, then reconnects Alice to Bob at the new port. The test asserts
that Alice's `remembered_addresses` for Bob then contains both the
channel-open address and the newly-connected address.

Depends on lightningnetwork#10973: the itest reads the `remembered_addresses` field
and uses the `include_offline_persistent_peers` flag added there.

Contributes to lightningnetwork#10870.
ZZiigguurraatt added a commit to ZZiigguurraatt/lnd that referenced this pull request Jul 16, 2026
When lnd completes an outbound connection to a peer that we have an
open channel with, record the address we dialed in that peer's LinkNode
entry (if it is not already listed). Peers without an open channel are
skipped — so casual connections do not grow the on-disk store, and a
lingering entry for a peer whose channels were closed but not yet
reaped by `PruneLinkNodes` will not be extended if we happen to
reconnect to them in the meantime.

Motivation
----------

There are two sources of addresses used to reach a channel peer:

  1. LinkNode — the peer's address stored in the channel state DB.
  2. Gossip — addresses from the peer's most recent
     `NodeAnnouncement`, stored in the on-disk channel graph.

On startup, `establishPersistentConnections` dials the union of these
two sources. That behaviour is unchanged by this commit.

Before this commit:

  LinkNode was written once at channel-open and not updated afterward.
  Gossip carries subsequent listener changes when a peer re-signs and
  re-broadcasts a `NodeAnnouncement`, but there are cases where the
  address we can actually reach the peer at is not in their current
  gossip entry:

    * the peer has since removed the address from their
      `NodeAnnouncement` but is still listening on the same host and
      port,
    * the peer moved to a new host and/or port and we learned the new
      address out-of-band faster than the peer's re-broadcast
      `NodeAnnouncement` could catch up, or
    * the peer never advertised the address in gossip in the first
      place (private channel, LSP setup, LAN address).

  In any of these cases, lnd could reach the peer at the address at
  some point — via automatic reconnect (at startup or after a
  mid-session disconnect) while the address was still in gossip, or
  via `lncli connect <pub>@<addr>` for addresses that were never in
  gossip or where gossip lagged the peer's move. But the address was
  not persisted anywhere — so the next restart's startup dial failed
  even though a live connection had just worked.

After this commit:

  Every successful outbound connection to a channel peer appends the
  dialed address to the peer's LinkNode entry (deduped by string). The
  next restart's startup dial includes it, so an operator-supplied or
  otherwise learned address survives across restarts for channel peers.

  Non-channel peers, addresses already listed, and inbound connections
  are skipped (see below for the reasoning behind the inbound skip).

Only outbound
-------------

The auto-persist path runs only for outbound connections. For inbound
TCP the peer's `RemoteAddr()` is the ephemeral source port their
kernel picked for the outbound half of their side, not a listener we
could dial back to. Persisting it would inflate the LinkNode address
list with non-dialable entries that fail on every subsequent startup
dial.

There is no equivalent for learning a peer's new listener from an
inbound connection today: BOLT #1 `Init` does not carry a listen
address, and `NodeAnnouncement` gossip is the intended source of
truth. lnd already forwards this: an lnd peer with a confirmed public
channel to us pushes its signed `NodeAnnouncement` on reconnect
(`peer.maybeSendNodeAnn`), and the gossiper writes it to the on-disk
channel graph, which `establishPersistentConnections` reads. That
covers the public-channel-with-lnd-peer case automatically. The gap
this commit narrows is what remains: operator-supplied addresses for
private channels, non-lnd peers, or gossip-lag windows.

Design
------

  * `channeldb.LinkNodeDB.AddAddressIfPeerKnown(pub, addr)` appends
    `addr` to the peer's LinkNode entry when one already exists and the
    address is not already listed. It is a no-op (nil error) when the
    peer has no LinkNode entry, which is the invariant we need to gate
    the store's growth.

  * `server.maybePersistPeerAddress` is called from `peerConnected`
    only when `inbound` is false and only after `addPeer`. It checks
    `chanStateDB.FetchOpenChannels` for the peer and only then calls
    the DB helper. Errors are logged and swallowed.

Since channel peers always have a LinkNode entry (created at channel
open, and `RepairLinkNodes` reinstates missing ones at startup), the
"no-op when no entry" branch of `AddAddressIfPeerKnown` will not fire
in practice for channel peers — the open-channel gate is what keeps the
store from growing.

The new address is surfaced through the existing `remembered_addresses`
field on `Peer` introduced in lightningnetwork#10973; no new RPC surface is required.

itest
-----

`auto persist channel peer address` opens a channel between Alice and
Bob at Bob's initial P2P port, disconnects them, restarts Bob on a new
port, then reconnects Alice to Bob at the new port. The test asserts
that Alice's `remembered_addresses` for Bob then contains both the
channel-open address and the newly-connected address.

Depends on lightningnetwork#10973: the itest reads the `remembered_addresses` field
and uses the `include_offline_persistent_peers` flag added there.

Contributes to lightningnetwork#10870.
ZZiigguurraatt added a commit to ZZiigguurraatt/lnd that referenced this pull request Jul 16, 2026
Extends `lncli disconnect` with two ways to prune a peer's stored
LinkNode state:

  * `--forget_node` for whole-peer removal.
  * `--forget_address` for surgical, per-address removal.

Both operations are gated by a shared `--force` flag when the requested
action would lose meaningful state:

  * `--forget_node` drops the live connection (base `disconnect` behavior)
    and additionally deletes the peer's LinkNode entry. The LinkNode
    delete is refused if open channels still exist with the peer unless
    `--force` is also set.
  * `--forget_address <host:port>` removes a single stored address from
    the peer's LinkNode entry. The live connection is left in place
    unless the removed address happens to be the currently-connected
    one, in which case the connection is dropped so lnd can re-dial via
    the remaining stored/gossip addresses. If the removed address would
    be the last one stored for the peer, behaviour depends on whether
    open channels exist: with no open channels the LinkNode entry is
    deleted automatically; with open channels the request is refused
    unless `--force` is also set. Note that after `--forget_address
    --force` removes the last stored address for a channel peer, the
    peer stays in the persistent-reconnect set — the peer-termination
    watcher falls back to the peer's NodeAnnouncement addresses for
    reconnect. `--forget_node` is the way to also stop reconnecting
    from our side (until lnd next restarts). Even with `--forget_address
    --force`, a channel peer is only truly orphaned when it also has
    no NodeAnnouncement.
  * `--force` on its own is rejected. `--forget_node` and `--forget_address`
    are mutually exclusive.

Response Status carries a warning when `--forget_address` triggers the
last-address LinkNode delete.

DB helper:

  * new `channeldb.LinkNodeDB.RemoveAddressForPeer(pub, addr, allowLast)`
    returning `(deletedEntry bool, err error)`.
  * new sentinel errors: `ErrNodeAddressNotFound` (address not stored
    for peer), `ErrLastPeerAddress` (refusing to remove last address
    without `allowLast`).

Server:

  * `DisconnectPeer` signature grows `forgetNode` and `force` parameters.
  * new `ForgetPeerAddress` method handles the per-address case,
    including the "disconnect if removed address is current" behaviour.
  * all internal callers of `DisconnectPeer` updated to pass
    `false, false`.

Proto: `DisconnectPeerRequest` gains `forget_node` (2), `force` (3), and
`forget_address` (4). `DisconnectPeerResponse.Status` reuses its
existing type.

itest: `disconnect forget` covers all branches — per-address removal,
last-address gating, `--forget_node` with and without `--force` against a
channel peer, and both error cases (`--force` alone; `--forget_node` +
`--forget_address`).

Depends on lightningnetwork#10975 (which depends on lightningnetwork#10973): the itest seeds a second
stored address by reconnecting Alice on Bob's alternate listener, which
`remembered_addresses` field added in lightningnetwork#10973.

Contributes to lightningnetwork#10870.
Contributes to lightningnetwork#10871.
ZZiigguurraatt added a commit to ZZiigguurraatt/lnd that referenced this pull request Jul 16, 2026
Extends `lncli disconnect` with two ways to prune a peer's stored
LinkNode state:

  * `--forget_node` for whole-peer removal.
  * `--forget_address` for surgical, per-address removal.

Both operations are gated by a shared `--force` flag when the requested
action would lose meaningful state:

  * `--forget_node` drops the live connection (base `disconnect` behavior)
    and additionally deletes the peer's LinkNode entry. The LinkNode
    delete is refused if open channels still exist with the peer unless
    `--force` is also set.
  * `--forget_address <host:port>` removes a single stored address from
    the peer's LinkNode entry. The live connection is left in place
    unless the removed address happens to be the currently-connected
    one, in which case the connection is dropped so lnd can re-dial via
    the remaining stored/gossip addresses. If the removed address would
    be the last one stored for the peer, behaviour depends on whether
    open channels exist: with no open channels the LinkNode entry is
    deleted automatically; with open channels the request is refused
    unless `--force` is also set. Note that after `--forget_address
    --force` removes the last stored address for a channel peer, the
    peer stays in the persistent-reconnect set — the peer-termination
    watcher falls back to the peer's NodeAnnouncement addresses for
    reconnect. `--forget_node` is the way to also stop reconnecting
    from our side (until lnd next restarts). Even with `--forget_address
    --force`, a channel peer is only truly orphaned when it also has
    no NodeAnnouncement.
  * `--force` on its own is rejected. `--forget_node` and `--forget_address`
    are mutually exclusive.

Response Status carries a warning when `--forget_address` triggers the
last-address LinkNode delete.

DB helper:

  * new `channeldb.LinkNodeDB.RemoveAddressForPeer(pub, addr, allowLast)`
    returning `(deletedEntry bool, err error)`.
  * new sentinel errors: `ErrNodeAddressNotFound` (address not stored
    for peer), `ErrLastPeerAddress` (refusing to remove last address
    without `allowLast`).

Server:

  * `DisconnectPeer` signature grows `forgetNode` and `force` parameters.
  * new `ForgetPeerAddress` method handles the per-address case,
    including the "disconnect if removed address is current" behaviour.
  * all internal callers of `DisconnectPeer` updated to pass
    `false, false`.

Proto: `DisconnectPeerRequest` gains `forget_node` (2), `force` (3), and
`forget_address` (4). `DisconnectPeerResponse.Status` reuses its
existing type.

itest: `disconnect forget` covers all branches — per-address removal,
last-address gating, `--forget_node` with and without `--force` against a
channel peer, and both error cases (`--force` alone; `--forget_node` +
`--forget_address`).

Depends on lightningnetwork#10975 (which depends on lightningnetwork#10973): the itest seeds a second
stored address by reconnecting Alice on Bob's alternate listener, which
lightningnetwork#10975's auto-persist captures into LinkNode; it also reads the
`remembered_addresses` field added in lightningnetwork#10973.

Contributes to lightningnetwork#10871.
ZZiigguurraatt added a commit to ZZiigguurraatt/lnd that referenced this pull request Jul 16, 2026
Extends `lncli disconnect` with two ways to prune a peer's stored
LinkNode state:

  * `--forget_node` for whole-peer removal.
  * `--forget_address` for surgical, per-address removal.

Both operations are gated by a shared `--force` flag when the requested
action would lose meaningful state:

  * `--forget_node` drops the live connection (base `disconnect` behavior)
    and additionally deletes the peer's LinkNode entry. The LinkNode
    delete is refused if open channels still exist with the peer unless
    `--force` is also set.
  * `--forget_address <host:port>` removes a single stored address from
    the peer's LinkNode entry. The live connection is left in place
    unless the removed address happens to be the currently-connected
    one, in which case the connection is dropped so lnd can re-dial via
    the remaining stored/gossip addresses. If the removed address would
    be the last one stored for the peer, behaviour depends on whether
    open channels exist: with no open channels the LinkNode entry is
    deleted automatically; with open channels the request is refused
    unless `--force` is also set. Note that after `--forget_address
    --force` removes the last stored address for a channel peer, the
    peer stays in the persistent-reconnect set — the peer-termination
    watcher falls back to the peer's NodeAnnouncement addresses for
    reconnect. `--forget_node` is the way to also stop reconnecting
    from our side (until lnd next restarts). Even with `--forget_address
    --force`, a channel peer is only truly orphaned when it also has
    no NodeAnnouncement.
  * `--force` on its own is rejected. `--forget_node` and `--forget_address`
    are mutually exclusive.

Response Status carries a warning when `--forget_address` triggers the
last-address LinkNode delete.

DB helper:

  * new `channeldb.LinkNodeDB.RemoveAddressForPeer(pub, addr, allowLast)`
    returning `(deletedEntry bool, err error)`.
  * new sentinel errors: `ErrNodeAddressNotFound` (address not stored
    for peer), `ErrLastPeerAddress` (refusing to remove last address
    without `allowLast`).

Server:

  * `DisconnectPeer` signature grows `forgetNode` and `force` parameters.
  * new `ForgetPeerAddress` method handles the per-address case,
    including the "disconnect if removed address is current" behaviour.
  * all internal callers of `DisconnectPeer` updated to pass
    `false, false`.

Proto: `DisconnectPeerRequest` gains `forget_node` (2), `force` (3), and
`forget_address` (4). `DisconnectPeerResponse.Status` reuses its
existing type.

itest: `disconnect forget` covers all branches — per-address removal,
last-address gating, `--forget_node` with and without `--force` against a
channel peer, and both error cases (`--force` alone; `--forget_node` +
`--forget_address`).

Depends on lightningnetwork#10975 (which depends on lightningnetwork#10973): the itest seeds a second
stored address by reconnecting Alice on Bob's alternate listener, which
`remembered_addresses` field added in lightningnetwork#10973.

Contributes to lightningnetwork#10871.
@saubyk

saubyk commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

/gateway review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 6 findings

🔴 0 Blocker · 🟠 2 Major · 🟡 4 Minor · 🔵 0 Nit

Summary

This PR extends ListPeers with peer address-source detail (remembered_addresses, gossip_addresses, is_persistent, reconnect_pending) and an include_offline_persistent_peers request flag. The intent is sound, the proto field numbering is clean, the new flag defaults to false (backward compatible), and the pubkey map-keying is consistent across the connected and offline paths.

The main concern is performance: ListPeers was previously an in-memory read and now issues an unbounded FetchAllLinkNodes() scan plus one v1Graph.FetchNode() per peer on every call, on an endpoint with high caller reach that monitoring tools poll. The second concern is API shape: offline-persistent entries are structurally identical to connected peers but with every per-connection field zeroed, and there is no explicit discriminator, which invites consumers to read zeros as real telemetry.

Bot commands
  • /gateway re-review — re-run after pushing changes
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread lnrpc/lightning.proto
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.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F1 (Major) — Offline-persistent entries have no discriminator flag

When include_offline_persistent_peers=true, the peers list mixes live-connection records and offline placeholders that are structurally identical, with no explicit type tag. An offline entry carries address="" and every per-connection field (ping_time, bytes_sent, bytes_recv, sat_sent, sync_type, features, flap_count, …) zeroed, so a consumer reusing the existing for _, p := range resp.Peers { use(p.PingTime, p.SyncType) } pattern silently treats those zeros as real telemetry.

Why this matters

No field cleanly discriminates the two: empty address is also reachable transiently for a genuinely connected peer, and is_persistent/reconnect_pending can both be true for a live connection. Adding a discriminator to a released proto is a compatibility headache, so it is worth resolving before merge — e.g. an explicit bool connected (or a PeerConnectionState enum) on Peer that is unambiguously false for offline-persistent entries, letting a caller gate per-connection fields on one documented flag rather than inferring liveness from an in-band empty string.

Comment thread rpcserver.go
}

// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F2 (Major) — ListPeers now does an unbounded LinkNode scan plus a graph lookup per peer

Each ListPeers call now runs r.server.linkNodeDB.FetchAllLinkNodes() (a full, unpaginated table scan) plus a r.server.v1Graph.FetchNode(ctx, vertex) graph read for every connected peer and every offline persistent peer. This converts a previously cheap in-memory RPC into O(peers) database + graph reads, with no caching or bound.

Why this matters

ListPeers has high caller reach (~22 files, including getdebuginfo, itests, and operator/monitoring tooling that polls it), so the new cost is paid on every invocation and scales with the node's link-node history and connection count — a real latency and DB-contention regression on large nodes. It is macaroon-gated and bounded by the node's own state rather than remotely inflatable, so this is a performance/hardening regression, not a remote DoS. Consider batching or caching the FetchAllLinkNodes result, or gating the graph lookups behind the request so the default hot path stays cheap. (cf. SEC-RULE-120 / SEC-RULE-110.)

Comment thread lnrpc/lightning.proto
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 (Minor) — remembered_addresses can carry undialable v2 .onion entries

remembered_addresses is reported verbatim from LinkNode storage and may contain deprecated v2 .onion addresses the node will never dial, whereas the adjacent gossip_addresses has v2 filtered out. The dial-safety distinction between these two repeated string fields is load-bearing but invisible in the type; a consumer that feeds the intuitively-named remembered_addresses into a reconnect routine will attempt undialable addresses. The proto comment does document this, but consider filtering v2 out here too, or keep the caveat prominent.

Comment thread rpcserver.go
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 (Minor) — gossipAddrsFor silently swallows FetchNode errors

gossipAddrsFor returns nil on any FetchNode error, making a transient graph read failure indistinguishable from "node not in graph / no addresses." (The nil-on-error path does correctly avoid aborting the whole RPC, so there is no fatal-error regression.) Log the error at debug level so operators can tell an empty gossip_addresses from a real read failure.

Comment thread rpcserver.go

rpcPeer.RememberedAddresses = toStrs(
rememberedAddrsByPub[pubStr],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 (Minor) — is_persistent's headline meaning diverges from its computed value

is_persistent is documented as "true if lnd is maintaining a persistent reconnect attempt," but for connected peers it is computed as inPersistentSet || len(remembered_addresses) > 0 and for offline entries hardcoded true. A peer with a stale LinkNode record (e.g. all channels closed but not yet garbage-collected) will report is_persistent=true even when lnd is not reconnecting to it, and the value is derived by different rules across the two entry types. Either derive it uniformly from the persistent set or align the proto comment with the actual computation.

Comment thread rpcserver.go
// default.

resp.Peers = append(resp.Peers, rpcPeer)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F6 (Minor) — Offline peers appended in nondeterministic map order

The offline loop ranges over persistentSet (a Go map) and appends to resp.Peers, so the order of offline entries varies between calls while connected peers keep loop order. This can produce unstable output and flaky assertions; sort the offline pubkeys before appending if stable ordering is expected.

@lightninglabs-gateway

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway-active severity-critical Requires expert review - security/consensus critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants