Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
eb7f3e4
p2p: apply Core's message size and count limits, and bound queued sen…
bkeroack Oct 7, 2026
a33a266
p2p: wake the manager when a getdata or a getdata resume is handed over
bkeroack Oct 7, 2026
4c83105
p2p: don't ban local, inbound-onion or manual peers for misbehaviour
bkeroack Oct 8, 2026
02bdb2d
p2p: keep a getdata cmpctblock entry that could not be queued
bkeroack Oct 8, 2026
274cb0f
Merge remote-tracking branch 'origin/fix/p2p-message-limits' into fix…
bkeroack Oct 8, 2026
d41ad6e
p2p: tidy the MisbehaviourAction docs
bkeroack Oct 8, 2026
7c8b6ed
p2p: disconnect an inbound onion peer without a ban wherever Tor dial…
bkeroack Oct 8, 2026
aec3beb
p2p: bound learned addresses and outbound dials; manual peers outside…
bkeroack Oct 8, 2026
808fbc6
Merge remote-tracking branch 'origin/fix/discourage-not-ban-local-oni…
bkeroack Oct 8, 2026
b2a34d2
p2p: exempt a two-address test peer from the address rate limit
bkeroack Oct 8, 2026
68ce859
p2p: count only the announced addresses in the addr-fetch test
bkeroack Oct 8, 2026
8ea1863
Merge release/0.6 (B2 and the other 0.6.1 fixes merged)
bkeroack Oct 13, 2026
d8a9e56
p2p: setban of an onion host disconnects that onion peer
bkeroack Oct 13, 2026
fa938d6
p2p: a full address book replaces a terrible entry
bkeroack Oct 13, 2026
566eefb
p2p: a full network group replaces a terrible entry; a replaced addre…
bkeroack Oct 14, 2026
c3b12e7
p2p: a full book of terrible tried entries takes new addresses; lock …
bkeroack Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/manual/src/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,18 +189,18 @@ startup error.
| `v2transport` | true | hot | core | Offer/accept BIP 324 v2 encrypted transport (Core default since v26). |
| `v2only` | false | hot | satd | Refuse peers that do not speak BIP 324 v2 (privacy hardening). |
| `externalip` | none | hot | core | External address to advertise to peers (repeatable). |
| `whitelist` | none | hot | core | Grant net permissions to peers by source subnet (repeatable). `[<perms>@]<subnet>`. **Inbound only unless the permission list carries `out`**, as in Core; an `out` entry applies only to *manual* outbound connections (`connect`, `addnode`, and `seednode`, which satd keeps as a manual connection), never to automatic ones such as the peers the node finds through gossip, `peers.dat` or a DNS seed. `@<subnet>` with an empty list matches the range and grants nothing. A peer arriving over the Tor hidden service is never matched against this — it reaches the node on a loopback socket Tor forwards to, so matching would grant an anonymous remote peer whatever you granted your own machine. |
| `whitelist` | none | hot | core | Grant net permissions to peers by source subnet (repeatable). `[<perms>@]<subnet>`. **Inbound only unless the permission list carries `out`**, as in Core; an `out` entry applies only to *manual* outbound connections (`connect`, `addnode`, and `seednode`, which satd keeps as a manual connection), never to automatic ones such as the peers the node finds through gossip, `peers.dat` or a DNS seed. `@<subnet>` with an empty list matches the range and grants nothing. A peer arriving over the Tor hidden service is never matched against this — it reaches the node on a loopback socket Tor forwards to, so matching would grant an anonymous remote peer whatever you granted your own machine. The `addr` permission exempts a peer from Core's address rate limit: otherwise the node takes one address from a peer's `addr`/`addrv2` messages to start and one more every 10 seconds (a thousand more after it sends the peer `getaddr`), and `getpeerinfo` reports the rest as `addr_rate_limited`. |
| `whitelistrelay` | on | hot | core | Grant `relay` to whitelisted peers with default permissions (relay their txes even under `-blocksonly`). Entries with an explicit `perms@` prefix are unaffected. |
| `whitelistforcerelay` | off | hot | core | Grant `forcerelay` to whitelisted peers with default permissions. Entries with an explicit `perms@` prefix are unaffected. |
| `whitebind` | none | restart | core | Bind an extra permissioned P2P listener (repeatable). |
| `asmap` | none | restart | core | asmap file for ASN-based addrman bucketing (eclipse resistance). |
| `port` | network default | restart | core | P2P listen port. |
| `bind` | `0.0.0.0` | restart | core | Bind P2P to this address; **repeatable**. Accepts `addr`, `addr:port`, and `addr[:port]=onion`. An entry with a port uses it; a bare address takes `port`, and a bare `=onion` entry takes `port` + 1 (as in Core). IPv6 literals may be plain (`::1`) or bracketed. Any explicit `bind` replaces the default listener. Cannot be combined with `listen=0`. |
| `connect` | none | hot | core | Connect only to specific peer(s) (repeatable). An entry with no port takes the network's default P2P port. Any `connect` stops the node dialling addresses it learns from gossip and soft-sets `listen=0` (see `listen`); `connect=0` — and the command-line negation `-noconnect` — is Core's spelling for "open no outbound connections at all"; a literal `connect=0` mixed with a peer address is refused, since Core keeps the peer and satd will not dial the `0`. Connect-only *exclusivity* is a startup decision (restart to change). |
| `addnode` | none | hot | core | Add a node to connect to (does not disable DNS seeding, and does not affect `listen`). An entry with no port takes the network's default P2P port. |
| `addnode` | none | hot | core | Add a node to connect to (does not disable DNS seeding, and does not affect `listen`). An entry with no port takes the network's default P2P port. As in Core, added nodes are counted apart from the automatic outbound peers and `maxconnections`: at most 8 are connected at a time, and one that is not connected is dialled again at most a minute later, however many automatic peers the node has. A ban does not stop the dial; it does stop automatic dials to that address. |
| `uacomment` | none | restart | core | Append a comment to the advertised user agent (**repeatable**; command-line and config-file values accumulate, command line first). Renders as `/satd:<version>(c1; c2)/`. A comment may contain only alphanumerics and `` .,;-_?@`` — the user agent's own delimiters `/`, `:`, `(` and `)` are refused — and the whole user agent may not exceed 256 bytes. Either violation is a startup error, as in Core. |
| `seednode` | none | hot | core | One-shot seed peer connected at startup to bootstrap discovery. |
| `maxconnections` | 125 | hot | core | Maximum total connections. `0` (or any value ≤ 0) soft-sets `listen=0`, as in Core — see `listen`. That half is a startup decision: changing `maxconnections` over SIGHUP applies the new cap but does not start or stop the listener. |
| `maxconnections` | 125 | hot | core | Maximum total connections. Manual connections (`connect`, `addnode`, `seednode`, `addnode onetry`) do not count against the automatic outbound peers, as in Core. `0` (or any value ≤ 0) soft-sets `listen=0`, as in Core — see `listen`. That half is a startup decision: changing `maxconnections` over SIGHUP applies the new cap but does not start or stop the listener. |
| `maxinboundperip` | 3 | hot | satd | Max simultaneous inbound peers from one source IP (Core-style flood guard; no Core flag). |
| `cmpctblockprefill` | false | restart | satd | Announce a new block as a `cmpctblock` with the transactions this node lacked when it arrived prefilled, so a peer that lacks them too rebuilds the block without a `getblocktxn` round trip. Bitcoin Core's proposed design (#35558, not yet merged in Core); off until measurement says otherwise. |
| `cmpctblockprefillbytes` | 8192 | restart | satd | Transaction bytes a `cmpctblockprefill` announcement may carry beyond the coinbase. Transactions nobody relayed to this node go first, then replaced or policy-refused ones; one that does not fit is skipped. `0` prefills nothing. |
Expand Down
142 changes: 142 additions & 0 deletions node/src/net/addr_rate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! Bitcoin Core's rate limit on the addresses a peer announces
//! (`net_processing.cpp` `ProcessAddrs`).
//!
//! Every peer has a token bucket. It starts with one token and gains
//! [`MAX_ADDR_RATE_PER_SECOND`] tokens a second, up to
//! [`MAX_ADDR_PROCESSING_TOKEN_BUCKET`]. Each address taken from the peer's
//! `addr` and `addrv2` messages spends one token; an address that finds the
//! bucket empty is dropped, unless the peer has the `addr` permission.
//! Sending the peer a `getaddr` adds [`MAX_ADDR_TO_SEND`] tokens on top,
//! past that limit, so its answer is taken whole.

use crate::net::limits::MAX_ADDR_TO_SEND;

/// Core's `MAX_ADDR_RATE_PER_SECOND`: tokens gained per second.
pub const MAX_ADDR_RATE_PER_SECOND: f64 = 0.1;

/// Core's `MAX_ADDR_PROCESSING_TOKEN_BUCKET`: the bucket refills no
/// further than this.
pub const MAX_ADDR_PROCESSING_TOKEN_BUCKET: f64 = MAX_ADDR_TO_SEND as f64;

/// One peer's address token bucket and its counters: Core's
/// `Peer::m_addr_token_bucket`, `m_addr_token_timestamp`,
/// `m_addr_processed` and `m_addr_rate_limited`.
#[derive(Debug, Clone)]
pub struct AddrTokenBucket {
tokens: f64,
/// Node time of the last refill, in microseconds
/// (`crate::time::now_micros`).
refilled_at_micros: u64,
/// Addresses from this peer that passed the limit and were looked at
/// (`getpeerinfo.addr_processed`).
pub processed: u64,
/// Addresses from this peer dropped by the limit
/// (`getpeerinfo.addr_rate_limited`).
pub rate_limited: u64,
}

impl AddrTokenBucket {
/// A new peer's bucket: one token, as in Core.
pub fn new(now_micros: u64) -> Self {
Self {
tokens: 1.0,
refilled_at_micros: now_micros,
processed: 0,
rate_limited: 0,
}
}

/// Add the tokens earned since the last refill, unless the bucket is
/// already at [`MAX_ADDR_PROCESSING_TOKEN_BUCKET`], and restart the clock
/// either way. Core does this once per message, before taking any of it.
/// A clock that went backwards earns nothing.
pub fn refill(&mut self, now_micros: u64) {
if self.tokens < MAX_ADDR_PROCESSING_TOKEN_BUCKET {
let elapsed_secs = now_micros.saturating_sub(self.refilled_at_micros) as f64 / 1_000_000.0;
self.tokens = (self.tokens + elapsed_secs * MAX_ADDR_RATE_PER_SECOND)
.min(MAX_ADDR_PROCESSING_TOKEN_BUCKET);
}
self.refilled_at_micros = now_micros;
}

/// Spend a token on one address. Without one the address is dropped
/// and counted as rate limited, unless `exempt` (the `addr`
/// permission), in which case it is taken for free.
pub fn admit(&mut self, exempt: bool) -> bool {
if self.tokens < 1.0 {
if !exempt {
self.rate_limited += 1;
return false;
}
} else {
self.tokens -= 1.0;
}
true
}

/// A `getaddr` was sent to the peer: room for its answer, past the
/// refill limit.
pub fn getaddr_sent(&mut self) {
self.tokens += MAX_ADDR_TO_SEND as f64;
}
}

#[cfg(test)]
mod tests {
use super::*;

const SEC: u64 = 1_000_000;

fn take(bucket: &mut AddrTokenBucket, n: usize, exempt: bool) -> usize {
(0..n).filter(|_| bucket.admit(exempt)).count()
}

#[test]
fn a_new_peer_gets_one_address_then_one_every_ten_seconds() {
let mut b = AddrTokenBucket::new(0);
b.refill(0);
assert_eq!(take(&mut b, 1000, false), 1);
assert_eq!(b.rate_limited, 999);

b.refill(100 * SEC);
assert_eq!(take(&mut b, 1000, false), 10, "0.1 a second");

b.refill(105 * SEC);
assert_eq!(take(&mut b, 1, false), 0, "half a token is not one");
b.refill(110 * SEC);
assert_eq!(take(&mut b, 1, false), 1, "the half carried over");
}

#[test]
fn the_bucket_refills_to_a_thousand_and_no_further() {
let mut b = AddrTokenBucket::new(0);
b.refill(1_000_000 * SEC);
assert_eq!(take(&mut b, 2000, false), 1000);
}

#[test]
fn a_getaddr_makes_room_for_its_answer_past_the_limit() {
let mut b = AddrTokenBucket::new(0);
b.refill(1_000_000 * SEC);
b.getaddr_sent();
assert_eq!(take(&mut b, 3000, false), 2000);
}

#[test]
fn the_addr_permission_takes_every_address() {
let mut b = AddrTokenBucket::new(0);
b.refill(0);
assert_eq!(take(&mut b, 1000, true), 1000);
assert_eq!(b.rate_limited, 0);
}

#[test]
fn a_clock_that_goes_backwards_earns_nothing() {
let mut b = AddrTokenBucket::new(100 * SEC);
b.refill(0);
assert_eq!(take(&mut b, 10, false), 1, "only the first token");
// And the clock restarted from the earlier time, as Core's does.
b.refill(10 * SEC);
assert_eq!(take(&mut b, 10, false), 1);
}
}
87 changes: 79 additions & 8 deletions node/src/net/addrman.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use std::path::Path;

/// Hard cap on total stored addresses (new + tried). Keeps `peers.dat`
/// and memory bounded; well above what a single node needs to bootstrap.
const MAX_ENTRIES: usize = 16_384;
pub const MAX_ENTRIES: usize = 16_384;
/// Per-network-group cap in the *new* table (eclipse resistance: stop one
/// `/16`/ASN from flooding the book with addresses).
const MAX_NEW_PER_GROUP: usize = 64;
Expand Down Expand Up @@ -92,6 +92,29 @@ pub struct AddrEntry {
group: Vec<u8>,
}

/// Core's `ADDRMAN_HORIZON`: an address not seen for this long is terrible.
const ADDRMAN_HORIZON_SECS: u64 = 30 * 24 * 60 * 60;
/// Core's `ADDRMAN_RETRIES`: failed attempts, with no success ever, that
/// make an address terrible.
const ADDRMAN_RETRIES: u32 = 3;
/// Core's `ADDRMAN_MAX_FAILURES` within `ADDRMAN_MIN_FAIL` (7 days).
const ADDRMAN_MAX_FAILURES: u32 = 10;
const ADDRMAN_MIN_FAIL_SECS: u64 = 7 * 24 * 60 * 60;

impl AddrEntry {
/// Core's `AddrInfo::IsTerrible`, without its one-minute grace after an
/// attempt (satd does not record when it last tried): seen in the
/// future, not seen for 30 days, tried three times and never reached,
/// or ten failures with no success in a week.
pub fn is_terrible(&self, now: u64) -> bool {
self.last_seen > now + 10 * 60
|| now.saturating_sub(self.last_seen) > ADDRMAN_HORIZON_SECS
|| (self.last_success == 0 && self.attempts >= ADDRMAN_RETRIES)
|| (now.saturating_sub(self.last_success) > ADDRMAN_MIN_FAIL_SECS
&& self.attempts >= ADDRMAN_MAX_FAILURES)
}
}

/// Network group key used for bucketing. IPv4 → the `/16`; IPv6 → the
/// `/32`. A custom grouping (e.g. `-asmap` ASN) can be installed via
/// [`AddrMan::set_group_fn`].
Expand Down Expand Up @@ -251,19 +274,62 @@ impl AddrMan {
/// of the peer that announced it. A refresh of a known address updates
/// its last-seen time only, as before.
pub fn add_from(&mut self, addr: SocketAddr, now: u64, services: u64, source: IpAddr) -> bool {
self.add_from_replacing(addr, now, services, source).0
}

/// [`add_from`](Self::add_from), and when the book or the address's
/// network group is full, make room for the new address by dropping one
/// entry (of that group and new, when the group is full; new before
/// tried otherwise) that is terrible
/// ([`AddrEntry::is_terrible`]), as Core's address manager replaces
/// terrible entries. Returns whether `addr` was inserted, and the
/// address dropped for it.
///
/// Without this a full book refused every address it did not already
/// hold, for good: nothing ever left it.
pub fn add_from_replacing(
&mut self,
addr: SocketAddr,
now: u64,
services: u64,
source: IpAddr,
) -> (bool, Option<SocketAddr>) {
if !self.admits(&addr) {
return false;
return (false, None);
}
if let Some(e) = self.entries.get_mut(&addr) {
e.last_seen = now;
return false;
}
if self.entries.len() >= MAX_ENTRIES {
return false;
return (false, None);
}
let group = self.group_of(&addr);
let mut evicted = None;
if self.new_count_in_group(&group) >= MAX_NEW_PER_GROUP {
return false;
// The group is full: make room in it, or refuse.
let Some(victim) = self
.entries
.values()
.find(|e| !e.tried && e.group == group && e.is_terrible(now))
.map(|e| e.addr)
else {
return (false, None);
};
self.entries.remove(&victim);
evicted = Some(victim);
} else if self.entries.len() >= MAX_ENTRIES {
// A terrible new entry first, then a terrible tried one: Core
// keeps the two tables apart, so a full tried table never shuts
// new addresses out there; one shared cap must not either.
let terrible = |tried: bool| {
self.entries
.values()
.find(|e| e.tried == tried && e.is_terrible(now))
.map(|e| e.addr)
};
let Some(victim) = terrible(false).or_else(|| terrible(true)) else {
return (false, None);
};
self.entries.remove(&victim);
evicted = Some(victim);
}
self.entries.insert(
addr,
Expand All @@ -278,7 +344,7 @@ impl AddrMan {
group,
},
);
true
(true, evicted)
}

/// Promote an address to the *tried* table after a successful connect.
Expand Down Expand Up @@ -317,6 +383,11 @@ impl AddrMan {
entry.attempts = 0;
}

/// Whether the book holds `addr`, in either table.
pub fn contains(&self, addr: &SocketAddr) -> bool {
self.entries.contains_key(addr)
}

/// Record a failed connection attempt.
pub fn mark_attempt(&mut self, addr: SocketAddr) {
if let Some(e) = self.entries.get_mut(&addr) {
Expand Down
Loading
Loading