Skip to content

net: peer resolution, getpeerinfo, locators and the outbound cap (#668, #691) - #718

Open
bkeroack wants to merge 3 commits into
fix/664-compat-notifications-timeoutsfrom
fix/668-691-net-parity
Open

net: peer resolution, getpeerinfo, locators and the outbound cap (#668, #691)#718
bkeroack wants to merge 3 commits into
fix/664-compat-notifications-timeoutsfrom
fix/668-691-net-parity

Conversation

@bkeroack

@bkeroack bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #668. Closes #691.

Seventh in the 0.5.2 close-out stack. Based on fix/664-compat-notifications-timeouts (#717) — merge that first.

Name resolution honours -proxy and -dns, and stops blocking the runtime

-addnode, -connect and the addnode RPC all went through
PeerAddr::parse_with_default_port, which did three things it must not:

  • resolved hostnames with the blocking std::net::ToSocketAddrs from
    inside async handlers, so a slow resolver parked a tokio worker;
  • resolved them with the local resolver even under -proxy, handing
    whoever watches that resolver the peers a proxied node exists to hide —
    the same leak resolve_operator_seeds already refuses for -seednode;
  • ignored -dns=0, which was parsed and then consulted only for DNS seeding.

net::dns::resolve_peer_target is now the single async entry point for every
operator-supplied peer target, and parse_with_default_port is a pure parser.
Literal IPs and .onion targets never touch a resolver and work in every
mode; a clearnet hostname is refused under -proxy and under -dns=0, with a
message saying which.

Not done here: handing the hostname to the proxy for it to resolve (Core's
by-name SOCKS5 ConnectThroughProxy). PeerAddr has no clearnet-by-name
variant and the dial/dedupe bookkeeping is keyed on either a socket or an
onion host, so that is its own change. The leak is closed by refusal in the
meantime, which is the behaviour satd already documents for -seednode.

getpeerinfo

Field Was Now
network address family only Core's CNetAddr::GetNetwork — every unroutable address is not_publicly_routable, via a new net::is_routable
addr_relay_enabled direction == Outbound the latch Core's SetupAddressRelay sets, so an inbound peer exchanging addrs reports true
servicesnames ad-hoc one bit-ordered 0..64 walk, with Core's UNKNOWN[2^n] fallback
last_block / last_transaction stamped on receipt stamped on acceptance, as Core does
relay_txes (field) dead, never read removed

last_block/last_transaction are eviction inputs. Stamped on receipt, a
peer could keep its own eviction protection alive with blocks the node
already had or transactions it rejects — the peer eviction exists to shed.
Carrying that meant the block-processing channel now carries the sending
peer's stats handle alongside the block.

net::is_routable also replaces the loopback-only proxy bypass in
dial_direct. That bypass happened to match Core (GetNetwork() returns
NET_UNROUTABLE for loopback, and GetProxy(NET_UNROUTABLE) is false) but
only for loopback; RFC1918 and link-local peers were being sent through the
proxy where Core dials them directly.

getconnectioncount now counts the same set getpeerinfo lists.

getblocks / getheaders answer from Core's fork point

locator_fork_height is CChain::FindForkInGlobalIndex:

  • a locator entry on a stale fork is no longer a fork point (it made the
    reply start at stale_height + 1 on the active chain, which the peer
    cannot connect to anything it holds);
  • an unmatched locator falls back to genesis and the reply starts at height
    1, instead of re-announcing the genesis block;
  • getblocks breaks before pushing hashStop, instead of announcing the
    requester the block it named as already held;
  • getheaders honours hashStop at all — it was answering every request
    with up to 2000 headers.

addnode <peer> remove left the peer manual

remove_peer_addr cleared the reconnect list but not manual_addrs /
manual_onion_hosts, so a removed added-node kept being dialled as a manual
connection and kept bypassing -connect gating.

Total outbound cap (#691)

Core bounds automatic outbound connections twice: per type, and with the
semOutbound counting semaphore sized
min(m_max_automatic_outbound, m_max_automatic_connections). satd had only
the per-type limits — and addr-fetch and feeler, the two types Core caps
only through that semaphore, returned "capacity fine" unconditionally, so
addconnection could open them until the process ran out of sockets. The
total check now runs under the same lock that reserves the per-type slot.
MANUAL stays exempt, as in Core.

Tests

New, each perturbation-proven (delete the guard, a named test fails):

  • net::dnsdns_disabled_refuses_a_hostname_but_not_a_literal,
    a_hostname_is_not_resolved_locally_under_a_proxy,
    a_hostname_still_resolves_with_no_proxy_and_dns_on,
    only_real_names_are_classified_as_needing_a_lookup
  • net::peerunroutable_peers_are_not_reported_as_ipv4_or_ipv6,
    servicesnames_is_emitted_in_bit_order,
    addr_relay_enabled_reports_the_latch_not_the_direction
  • net::managergetblocks_answers_from_cores_fork_point,
    getheaders_honours_the_stop_hash,
    removing_an_added_node_drops_its_manual_status
  • regtest — a_peers_last_block_and_last_tx_only_move_on_acceptance,
    addconnection_stops_at_the_total_outbound_capacity

Core-functional inventory

Two rows re-measured, both still skip but for different reasons than
recorded:

  • feature_proxy.py — the proxy-dial failure it was filed under is gone; it
    now cannot start, because its setup_nodes passes -i2psam (I2P is out of
    scope for satd). It also needs -cjdnsreachable and unix-socket proxies.
  • rpc_net.py — likewise past the proxy stage; it now fails asserting that
    help("getpeerinfo") contains Core's network list. satd's help <command>
    returns the method name, not per-method help text.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack

bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the updated fix/664-compat-notifications-timeouts (#717), which picked up two follow-up commits. No changes to this branch's own content.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from b4493af to 97b9fff Compare September 9, 2026 22:31
@bkeroack
bkeroack force-pushed the fix/668-691-net-parity branch from d0a0379 to 0a37777 Compare September 9, 2026 22:31
@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from 97b9fff to 40f5653 Compare September 9, 2026 22:50
@bkeroack
bkeroack force-pushed the fix/668-691-net-parity branch from 0a37777 to a752679 Compare September 9, 2026 22:50
bkeroack and others added 3 commits September 9, 2026 18:32
#691)

Resolution. `-addnode` / `-connect` / `addnode` all went through
`PeerAddr::parse_with_default_port`, which resolved hostnames with the
blocking `std::net::ToSocketAddrs` from inside async handlers, used the
local resolver even under `-proxy`, and ignored `-dns=0` outright. A new
async `resolve_peer_target` is the single entry point: literal IPs and
`.onion` targets never touch a resolver, a clearnet hostname is refused
under `-proxy` (the leak `resolve_operator_seeds` already refuses for
`-seednode`) and under `-dns=0`. `parse_with_default_port` is now pure.
Handing the name to the proxy to resolve, as Core does, is not implemented.

`getpeerinfo`. `network` folds every unroutable address into
`not_publicly_routable` via a new `net::is_routable` (Core's `IsRoutable`),
which also replaces the loopback-only proxy bypass in `dial_direct`.
`addr_relay_enabled` is the latch Core's `SetupAddressRelay` sets rather
than a function of the direction. `servicesnames` walks bits 0..64 in
order. `last_block` / `last_transaction` move only on acceptance, so the
block channel now carries the sending peer's stats. The dead `relay_txes`
field is gone, and `getconnectioncount` counts the set `getpeerinfo` lists.

Locators. `locator_fork_height` is Core's `FindForkInGlobalIndex`: only
entries on the active chain count, and an unmatched locator falls back to
genesis so the reply starts at height 1. `getblocks` breaks before pushing
`hashStop`; `getheaders` honours `hashStop` at all.

`remove_peer_addr` clears `manual_addrs` / `manual_onion_hosts`, so a
removed added-node stops being dialled as manual and stops bypassing
`-connect` gating.

Outbound cap. `check_outbound_limit_for` takes a second, total bound —
Core's `semOutbound`, `min(full_relay + block_relay + feeler,
maxconnections)` — under the same lock as the per-type reservation. The
two types with no individual limit, addr-fetch and feeler, returned early
before, so `addconnection` could open them without bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
Both notes were inherited and both were wrong. `feature_proxy.py`'s
recorded blocker — an outbound peer dialled through -proxy never
appearing in getpeerinfo — is gone; it now cannot start at all, because
its `setup_nodes` passes `-i2psam`, which satd refuses by name. It also
wants `-cjdnsreachable` and unix-socket proxies, so it stays `skip`
under `feature-missing`.

`rpc_net.py` likewise gets past the proxy stage now and fails asserting
that `help("getpeerinfo")` contains Core's network list; satd's
`help <command>` returns the method name, not per-method help text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
…in parallel

`is_routable` and `getpeerinfo.network` judged `::ffff:a.b.c.d` as an IPv6
address, so an RFC 1918 peer that arrived on a dual-stack `[::]` listener
was reported as a routable `ipv6` peer. Core's `CNetAddr` unwraps the
mapped form at construction; both sites now canonicalise first. Core's
`IsLocal` is 0.0.0.0/8 as well as 127.0.0.0/8, not the single unspecified
address.

`dial_added_peers` resolved and dialled the added entries in one spawned
loop, so N unreachable `-addnode`/`-connect` entries on SIGHUP held the N-th
behind (N-1) connect timeouts — a regression from the spawn-per-address
shape it replaced. One task per address again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from 40f5653 to 6faab7c Compare September 10, 2026 00:42
@bkeroack
bkeroack force-pushed the fix/668-691-net-parity branch from a752679 to 6ab12a7 Compare September 10, 2026 00:42
@bkeroack

Copy link
Copy Markdown
Contributor Author

Independent review pass — one fix landed on this branch.

  • IPv4-mapped IPv6 peers (::ffff:a.b.c.d) were classified as IPv6 in getpeerinfo and passed the routability check even when the embedded IPv4 address was private or loopback. Core's CNetAddr unwraps the mapping first; satd now canonicalises the address before both the network label and the routability check, and 0.0.0.0/8 is refused like 127/8.
  • SIGHUP addnode reload dialled the added peers serially, so one unreachable host stalled the rest. Each dial is now its own task.

Verified against Core v31.1 netaddress.cpp.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant