697 lines
33 KiB
Rust
697 lines
33 KiB
Rust
//! The swarm task — owns the libp2p `Swarm`, the HTTP command channel,
|
|
//! and the kad-DHT/bootstrap/peer-directory state, and drives them all
|
|
//! from one `tokio::select!` loop.
|
|
//!
|
|
//! Refactored from the previous monolithic `swarm::spawn` closure into a
|
|
//! `SwarmTask` struct with per-arm handler methods. The select! itself
|
|
//! stays in [`SwarmTask::run`] because tokio's macro needs all arm futures
|
|
//! to be constructed in one scope — `swarm` and `rx` are passed in as
|
|
//! locals (not held on `self`) so each handler method can borrow
|
|
//! `&mut self` cleanly without conflicting with the arm futures.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use futures::StreamExt;
|
|
use libp2p::core::ConnectedPoint;
|
|
use libp2p::kad::{self, GetProvidersOk, QueryResult, RecordKey};
|
|
use libp2p::mdns;
|
|
use libp2p::swarm::SwarmEvent;
|
|
use libp2p::{identify, Multiaddr, PeerId, Swarm};
|
|
use tokio::sync::mpsc;
|
|
use tracing::{debug, info, trace, warn};
|
|
|
|
use crate::gateway_discovery::{advertises_gateways, fetch_gateway_caps};
|
|
|
|
use super::bootstrap::{addr_targets_match, redial_bootstrap, BootstrapEntry};
|
|
use super::bitswap_client::BitswapInflight;
|
|
use super::identify_agent::parse_base_url;
|
|
use super::kad_discovery::{is_dialable_addr, namespace_key, peer_id_from_multiaddr, KadConfig};
|
|
use super::peer_directory::PeerDirectory;
|
|
use super::{Behaviour, BehaviourEvent, Command, GatewayCapsConfig, PeersInfo};
|
|
|
|
/// How often [`SwarmTask::on_gateway_keepalive_tick`] re-dials dropped
|
|
/// gateways and re-stamps live ones. Half meta-search's 60 s: an NZB play is
|
|
/// blocked for as long as the gateway is missing (no one else can redeem).
|
|
const GATEWAY_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
|
|
|
|
/// A gateway we've identified and now pin for keep-alive: its API base URL (for
|
|
/// the periodic caps refresh that keeps it routable) plus dialable addresses
|
|
/// (for re-pinning the connection if it drops). Persists across TTL expiry and
|
|
/// disconnect — unlike the read-side directory entry, which `forget_peer` drops
|
|
/// on the last close — so a gateway is never permanently forgotten once seen.
|
|
///
|
|
/// Ported from meta-search. Without it a gateway that restarts under the same
|
|
/// peer id is lost for good: its mDNS record never expires, so `Discovered`
|
|
/// never fires again, and the gateway itself never dials consumers
|
|
/// (watch.nsl.sh 2026-10-04: every NZB answered "no gateway can redeem it"
|
|
/// until this container was restarted).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct GatewayPin {
|
|
base_url: Option<String>,
|
|
addrs: Vec<Multiaddr>,
|
|
}
|
|
|
|
/// The pin an identify from a gateway yields, or `None` for any other peer.
|
|
/// Only dialable addresses are kept: a pin is retried for the life of the
|
|
/// process, so a loopback / unspecified listener would be a permanent cost.
|
|
fn gateway_pin(agent_version: &str, listen_addrs: &[Multiaddr], base_url: Option<String>) -> Option<GatewayPin> {
|
|
if !advertises_gateways(agent_version) {
|
|
return None;
|
|
}
|
|
Some(GatewayPin {
|
|
base_url,
|
|
addrs: listen_addrs.iter().filter(|a| is_dialable_addr(a)).cloned().collect(),
|
|
})
|
|
}
|
|
|
|
/// All the state that lives across iterations of the event loop. `swarm`
|
|
/// and the channel receivers stay as locals in [`SwarmTask::run`] so the
|
|
/// `tokio::select!` arms can borrow them without conflicting with
|
|
/// `&mut self` calls in the arm bodies.
|
|
pub(super) struct SwarmTask {
|
|
local_peer_id: PeerId,
|
|
/// Bootstrap targets we keep alive across reconnects.
|
|
bootstrap: Vec<BootstrapEntry>,
|
|
/// Shared peer→info directory (populated from identify events).
|
|
peer_directory: PeerDirectory,
|
|
/// kad-DHT discovery state.
|
|
kad_namespace: String,
|
|
ns_key: RecordKey,
|
|
kad_target_peer_count: usize,
|
|
kad_fast_discovery_interval: Duration,
|
|
kad_discovery_interval: Duration,
|
|
kad_provide_interval: Duration,
|
|
/// Whether to publish the namespace provider record at all — see
|
|
/// [`KadConfig::provide_enabled`]. False when this peer isn't publicly
|
|
/// reachable.
|
|
kad_provide_enabled: bool,
|
|
kad_bootstrap_peers: Vec<Multiaddr>,
|
|
/// Rate-limit floor for reactive kad kicks on `ConnectionClosed`.
|
|
last_kad_query_at: tokio::time::Instant,
|
|
/// Auto-redial cadence; zero / empty bootstrap effectively disables.
|
|
redial_interval: Duration,
|
|
/// In-flight `bitswap.get(cid)` calls awaiting block delivery.
|
|
/// Populated by `Command::BitswapGet`, drained by the
|
|
/// `BehaviourEvent::Bitswap` arm of `on_swarm_event`.
|
|
bitswap_inflight: BitswapInflight,
|
|
/// Process-wide HTTP client, for the gateway capability fetch off identify.
|
|
http: reqwest::Client,
|
|
/// Timeout + debounce for that fetch.
|
|
gateway_caps: GatewayCapsConfig,
|
|
/// Gateways kept connected + routable by the keep-alive tick. See
|
|
/// [`GatewayPin`].
|
|
pinned_gateways: HashMap<PeerId, GatewayPin>,
|
|
}
|
|
|
|
impl SwarmTask {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn new(
|
|
local_peer_id: PeerId,
|
|
bootstrap_peers: Vec<Multiaddr>,
|
|
redial_interval: Duration,
|
|
kad_config: KadConfig,
|
|
peer_directory: PeerDirectory,
|
|
http: reqwest::Client,
|
|
gateway_caps: GatewayCapsConfig,
|
|
) -> Self {
|
|
let bootstrap: Vec<BootstrapEntry> =
|
|
bootstrap_peers.into_iter().map(BootstrapEntry::new).collect();
|
|
let ns_key = namespace_key(&kad_config.namespace);
|
|
// Initialise far enough in the past that the very first reactive kick
|
|
// can fire (subject to the fast-discovery rate-limit floor).
|
|
let last_kad_query_at = tokio::time::Instant::now()
|
|
.checked_sub(kad_config.fast_discovery_interval)
|
|
.unwrap_or_else(tokio::time::Instant::now);
|
|
Self {
|
|
local_peer_id,
|
|
bootstrap,
|
|
peer_directory,
|
|
kad_namespace: kad_config.namespace,
|
|
ns_key,
|
|
kad_target_peer_count: kad_config.target_peer_count,
|
|
kad_fast_discovery_interval: kad_config.fast_discovery_interval,
|
|
kad_discovery_interval: kad_config.discovery_interval,
|
|
kad_provide_interval: kad_config.provide_interval,
|
|
kad_provide_enabled: kad_config.provide_enabled,
|
|
kad_bootstrap_peers: kad_config.bootstrap_peers,
|
|
last_kad_query_at,
|
|
redial_interval,
|
|
bitswap_inflight: BitswapInflight::new(),
|
|
http,
|
|
gateway_caps,
|
|
pinned_gateways: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Drive the swarm task to completion. Owns `swarm` and the channel
|
|
/// receivers as locals — the `tokio::select!` arms borrow these
|
|
/// directly, and the arm bodies are free to call `&mut self` methods
|
|
/// because the arm futures have completed (and their borrows
|
|
/// released) by the time a body runs.
|
|
pub(super) async fn run(
|
|
mut self,
|
|
mut swarm: Swarm<Behaviour>,
|
|
mut rx: mpsc::Receiver<Command>,
|
|
) {
|
|
// Initial bootstrap dials.
|
|
for entry in &self.bootstrap {
|
|
match swarm.dial(entry.addr.clone()) {
|
|
Ok(()) => info!(addr = %entry.addr, "bootstrap dial issued"),
|
|
Err(e) => warn!(addr = %entry.addr, error = %e, "bootstrap dial failed"),
|
|
}
|
|
}
|
|
|
|
// Seed kad's routing table with the configured bootstrap multiaddrs.
|
|
// Each entry must include `/p2p/<peer_id>` so we can register the
|
|
// peer-id without an extra round-trip; entries without are skipped
|
|
// with a warning (the kad routing table is keyed by peer-id).
|
|
let mut kad_seeded = 0usize;
|
|
for addr in &self.kad_bootstrap_peers {
|
|
match peer_id_from_multiaddr(addr) {
|
|
Some(pid) => {
|
|
swarm.behaviour_mut().kad.add_address(&pid, addr.clone());
|
|
kad_seeded += 1;
|
|
debug!(%pid, %addr, "kad: bootstrap address registered");
|
|
}
|
|
None => warn!(%addr, "kad: bootstrap multiaddr lacks /p2p/<id>; skipped"),
|
|
}
|
|
}
|
|
if kad_seeded > 0 {
|
|
match swarm.behaviour_mut().kad.bootstrap() {
|
|
Ok(qid) => info!(?qid, kad_seeded, "kad: bootstrap query issued"),
|
|
Err(e) => warn!(error = %e, "kad: bootstrap query failed"),
|
|
}
|
|
} else {
|
|
// Not a fault — it's the local-mode default. An empty bootstrap list
|
|
// is exactly what keeps an unreachable peer off the public DHT, so
|
|
// saying it at WARN would train operators to ignore a real warning.
|
|
info!(
|
|
"kad: no bootstrap peers configured; public-DHT discovery is off \
|
|
(local mode — mDNS still works). Set PUBLIC_ADDR to join the public DHT."
|
|
);
|
|
}
|
|
info!(namespace = %self.kad_namespace, "kad: discovery namespace");
|
|
|
|
// Auto-redial ticker. `redial_interval == 0` disables; we still need a
|
|
// ticker (`tokio::select!` requires all branches to compile) so use a
|
|
// very long interval that effectively never fires.
|
|
let effective_interval = if self.redial_interval.is_zero() || self.bootstrap.is_empty() {
|
|
Duration::from_secs(60 * 60 * 24 * 365) // 1y; effectively never
|
|
} else {
|
|
self.redial_interval
|
|
};
|
|
let mut redial_tick = tokio::time::interval(effective_interval);
|
|
// Skip the immediate first tick — we just did the initial dials above.
|
|
redial_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
redial_tick.tick().await;
|
|
|
|
// kad provide ticker — fires immediately so we publish on startup,
|
|
// then every kad_provide_interval. Provider records on remote peers
|
|
// expire (~24 h default), so this must be lower than that.
|
|
let mut kad_provide_tick = tokio::time::interval(self.kad_provide_interval);
|
|
kad_provide_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
|
|
// kad discovery ticker — same pattern, fires immediately.
|
|
let mut kad_discovery_tick = tokio::time::interval(self.kad_discovery_interval);
|
|
kad_discovery_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
|
|
// Keep known gateways connected + routable (re-dial dropped ones,
|
|
// re-fetch caps on live ones). See [`GatewayPin`].
|
|
let mut gateway_keepalive_tick = tokio::time::interval(GATEWAY_KEEPALIVE_INTERVAL);
|
|
gateway_keepalive_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
cmd = rx.recv() => match cmd {
|
|
Some(cmd) => self.on_command(&mut swarm, cmd).await,
|
|
None => {
|
|
debug!("command channel closed; shutting down swarm task");
|
|
break;
|
|
}
|
|
},
|
|
_ = redial_tick.tick() => {
|
|
redial_bootstrap(&mut swarm, &self.bootstrap);
|
|
},
|
|
_ = kad_provide_tick.tick() => {
|
|
// A provider record is a promise that this peer can be
|
|
// dialed. In local mode it can't be (no public address,
|
|
// only container-internal listeners), so publishing one
|
|
// would send every peer that walks the DHT into a dial
|
|
// that can never connect. Stay quiet.
|
|
if self.kad_provide_enabled {
|
|
match swarm.behaviour_mut().kad.start_providing(self.ns_key.clone()) {
|
|
Ok(qid) => debug!(?qid, namespace = %self.kad_namespace,
|
|
"kad: re-publishing provider record"),
|
|
Err(e) => warn!(error = %e, namespace = %self.kad_namespace,
|
|
"kad: start_providing failed"),
|
|
}
|
|
}
|
|
},
|
|
_ = kad_discovery_tick.tick() => {
|
|
self.on_kad_discovery_tick(&mut swarm, &mut kad_discovery_tick);
|
|
},
|
|
_ = gateway_keepalive_tick.tick() => {
|
|
self.on_gateway_keepalive_tick(&mut swarm);
|
|
},
|
|
event = swarm.select_next_some() => {
|
|
self.on_swarm_event(&mut swarm, event, &mut kad_discovery_tick).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle a single HTTP-layer command.
|
|
async fn on_command(&mut self, swarm: &mut Swarm<Behaviour>, cmd: Command) {
|
|
match cmd {
|
|
Command::Peers { reply } => {
|
|
let connected: Vec<String> =
|
|
swarm.connected_peers().map(|p| p.to_string()).collect();
|
|
let info = PeersInfo {
|
|
local_peer_id: self.local_peer_id.to_string(),
|
|
connected,
|
|
};
|
|
let _ = reply.send(info);
|
|
}
|
|
Command::BitswapGet { cid, reply } => {
|
|
// M12: issue a bitswap fetch for one block. The
|
|
// behaviour fans WANT-HAVE / WANT-BLOCK to connected
|
|
// peers (gateways + sibling consumers that already
|
|
// cached the cid). `bitswap.get` returns a QueryId;
|
|
// we register it with the inflight map and the
|
|
// `BehaviourEvent::Bitswap` arm fires the reply when
|
|
// either `GetQueryResponse` (success) or
|
|
// `GetQueryError` (transport / no-providers /
|
|
// timeout) arrives.
|
|
let query_id = swarm.behaviour_mut().bitswap.get(&cid);
|
|
tracing::debug!(%cid, ?query_id, "bitswap: issued .get()");
|
|
self.bitswap_inflight.register(query_id, reply);
|
|
}
|
|
Command::Provide { cid } => {
|
|
// Announce on the public IPFS DHT, keyed by the CID's
|
|
// multihash bytes — that's the kubo-compatible provider key,
|
|
// so a vanilla IPFS node `get_providers`ing the same content
|
|
// finds us. The StartProviding result is logged in
|
|
// `default_log`. Works in `Mode::Client` (same call the
|
|
// gateway makes).
|
|
let key = RecordKey::new(&cid.hash().to_bytes());
|
|
match swarm.behaviour_mut().kad.start_providing(key) {
|
|
Ok(qid) => debug!(?qid, %cid, "kad: providing seeded cid"),
|
|
Err(e) => warn!(error = %e, %cid, "kad: start_providing(cid) failed"),
|
|
}
|
|
}
|
|
Command::StopProviding { cid } => {
|
|
let key = RecordKey::new(&cid.hash().to_bytes());
|
|
swarm.behaviour_mut().kad.stop_providing(&key);
|
|
debug!(%cid, "kad: stopped providing seeded cid");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fired every `kad_discovery_interval` (steady-state) or
|
|
/// `kad_fast_discovery_interval` (below floor). Issues a fresh
|
|
/// `get_providers` query and adapts the next tick's cadence based on
|
|
/// the current connection count.
|
|
fn on_kad_discovery_tick(
|
|
&mut self,
|
|
swarm: &mut Swarm<Behaviour>,
|
|
kad_discovery_tick: &mut tokio::time::Interval,
|
|
) {
|
|
let connected = swarm.connected_peers().count();
|
|
let qid = swarm.behaviour_mut().kad.get_providers(self.ns_key.clone());
|
|
self.last_kad_query_at = tokio::time::Instant::now();
|
|
// Adapt the next tick's cadence to whether we're below the
|
|
// connection-floor. `reset_after` only affects the *next* tick —
|
|
// current behaviour keeps the steady-state config-driven topup rate
|
|
// when the swarm is full and accelerates discovery when it's not.
|
|
let next = if connected < self.kad_target_peer_count {
|
|
self.kad_fast_discovery_interval
|
|
} else {
|
|
self.kad_discovery_interval
|
|
};
|
|
debug!(
|
|
?qid, namespace = %self.kad_namespace,
|
|
connected, target = self.kad_target_peer_count,
|
|
next_tick_secs = next.as_secs(),
|
|
"kad: querying for providers"
|
|
);
|
|
kad_discovery_tick.reset_after(next);
|
|
}
|
|
|
|
/// Top-level swarm-event dispatch. Each branch inspects `&event` and
|
|
/// falls through to `default_log` at the end.
|
|
/// Keep pinned gateways connected + routable (ported from meta-search):
|
|
///
|
|
/// 1. **Connection drop** — re-dial a pinned gateway that isn't connected,
|
|
/// directly (not through the mDNS / kad-provider dial-gates), re-seeding
|
|
/// its addresses into kad first in case they were evicted. The redial
|
|
/// triggers identify, which restores its base URL and redeem claims.
|
|
/// 2. **Directory staleness** — identify goes quiet on an idle connection,
|
|
/// so the 600 s gateway TTL can lapse on a live peer. Re-fetch caps
|
|
/// over HTTP to re-stamp it.
|
|
fn on_gateway_keepalive_tick(&mut self, swarm: &mut Swarm<Behaviour>) {
|
|
// Small, stable set — clone to sidestep the self/swarm borrow overlap.
|
|
for (pid, pin) in self.pinned_gateways.clone() {
|
|
if swarm.is_connected(&pid) {
|
|
if self
|
|
.peer_directory
|
|
.needs_cap_refresh(&pid, self.gateway_caps.refresh_after)
|
|
{
|
|
if let Some(base_url) = pin.base_url {
|
|
self.spawn_caps_fetch(pid, base_url);
|
|
}
|
|
}
|
|
} else if pin.addrs.is_empty() {
|
|
// Every address it announced was undialable — most likely a
|
|
// gateway without PUBLIC_ADDR seen only over a relay. A dial
|
|
// would fail every tick for the life of the process.
|
|
trace!(peer_id = %pid,
|
|
"gateway keep-alive: no dialable address for pinned gateway; skipping re-dial");
|
|
} else {
|
|
for addr in &pin.addrs {
|
|
swarm.behaviour_mut().kad.add_address(&pid, addr.clone());
|
|
}
|
|
match swarm.dial(pid) {
|
|
Ok(()) => debug!(peer_id = %pid,
|
|
"gateway keep-alive: re-dialing dropped gateway"),
|
|
Err(e) => debug!(peer_id = %pid, error = %e,
|
|
"gateway keep-alive: re-dial failed"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch a gateway's capabilities over HTTP off its `base_url` and record
|
|
/// them in the directory. Fire-and-forget: a slow/dead gateway must not
|
|
/// stall the swarm loop; a failure keeps whatever was held (it ages out on
|
|
/// its own TTL) and is retried on the next identify / keep-alive tick.
|
|
fn spawn_caps_fetch(&self, pid: PeerId, base_url: String) {
|
|
let http = self.http.clone();
|
|
let dir = self.peer_directory.clone();
|
|
let timeout = self.gateway_caps.fetch_timeout;
|
|
tokio::spawn(async move {
|
|
match fetch_gateway_caps(&http, &base_url, timeout).await {
|
|
Ok(caps) => {
|
|
let redeems = caps.redeem_claims();
|
|
debug!(peer_id = %pid, base_url = %base_url,
|
|
nzb_fetch = caps.nzb_fetch,
|
|
redeem_claims = redeems.len(),
|
|
"gateway caps: fetched");
|
|
dir.record_gateway_caps(pid, caps.nzb_fetch, redeems);
|
|
}
|
|
Err(e) => debug!(peer_id = %pid, base_url = %base_url,
|
|
error = %e, "gateway caps: fetch failed"),
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn on_swarm_event(
|
|
&mut self,
|
|
swarm: &mut Swarm<Behaviour>,
|
|
event: SwarmEvent<BehaviourEvent>,
|
|
kad_discovery_tick: &mut tokio::time::Interval,
|
|
) {
|
|
// 0) Soft ceiling for inbound: when we're already at target,
|
|
// close inbound connections before they negotiate. Pre-handshake
|
|
// close — peer-id is not known yet, only the source address.
|
|
// Bootstrap dials and our own outgoing dials are unaffected
|
|
// (they go through the dial-gates in the mdns/kad branches
|
|
// below).
|
|
if let SwarmEvent::IncomingConnection { connection_id, .. } = &event {
|
|
let cid = *connection_id;
|
|
let connected = swarm.connected_peers().count();
|
|
if connected >= self.kad_target_peer_count {
|
|
debug!(?cid, connected, target = self.kad_target_peer_count,
|
|
"rejecting inbound connection; at target");
|
|
let _ = swarm.close_connection(cid);
|
|
}
|
|
}
|
|
// 1) On every outgoing-dial connection, opportunistically populate
|
|
// bootstrap[].peer_id so future redial ticks can skip
|
|
// already-connected entries.
|
|
if let SwarmEvent::ConnectionEstablished { peer_id, endpoint, .. } = &event {
|
|
if let ConnectedPoint::Dialer { address, .. } = endpoint {
|
|
// Always overwrite the cached peer-id on a fresh outgoing
|
|
// dial, not just on first contact. Bootstrap targets that
|
|
// lack a persistent peer-id (today: every meta-share peer
|
|
// in the POC) rotate their identity on restart, and a
|
|
// stale cached peer-id makes is_connected() return false
|
|
// on the next tick — causing a redundant dial that
|
|
// doubles the connection count.
|
|
for entry in self.bootstrap.iter_mut() {
|
|
if addr_targets_match(&entry.addr, address)
|
|
&& entry.peer_id != Some(*peer_id)
|
|
{
|
|
entry.peer_id = Some(*peer_id);
|
|
debug!(peer_id = %peer_id, addr = %entry.addr,
|
|
"auto-redial: bootstrap target peer-id learned/refreshed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 1b) On disconnect: if we drop below the connection floor, kick
|
|
// the kad discovery tick to fire immediately so a replacement
|
|
// peer is found quickly — but only if the last query was at
|
|
// least `fast_discovery_interval` ago, so a flapping peer
|
|
// can't trigger DHT queries faster than that floor.
|
|
// `reset_after(0)` makes the *next* tick fire now; if we're
|
|
// already mid-query the in-flight one continues.
|
|
if let SwarmEvent::ConnectionClosed { peer_id, num_established, .. } = &event {
|
|
// On the *last* connection to a peer closing, drop its gateway
|
|
// state immediately — the fast complement to the read-side TTL
|
|
// in PeerDirectory. A peer whose libp2p id rotates on restart
|
|
// (the dev-stack norm) otherwise keeps advertising upstreams it
|
|
// no longer serves, and the M7 fan-out burns a slot dialing the
|
|
// dead id. `num_established` is the count remaining *after* this
|
|
// close, so 0 means the peer is fully gone.
|
|
if *num_established == 0 && self.peer_directory.forget_peer(peer_id) {
|
|
debug!(peer_id = %peer_id, "peer fully disconnected — forgot gateway state");
|
|
}
|
|
let connected = swarm.connected_peers().count();
|
|
if connected < self.kad_target_peer_count
|
|
&& self.last_kad_query_at.elapsed() >= self.kad_fast_discovery_interval
|
|
{
|
|
debug!(connected, target = self.kad_target_peer_count,
|
|
"kad: below connection floor — kicking discovery");
|
|
kad_discovery_tick.reset_after(Duration::ZERO);
|
|
}
|
|
}
|
|
// 2) When the identify protocol delivers a peer's listen
|
|
// addresses, feed them into kad's routing table and parse
|
|
// `baseUrl=` into the peer directory (the byte-fetch path
|
|
// resolves a peer's HTTP API URL from here).
|
|
if let SwarmEvent::Behaviour(BehaviourEvent::Identify(
|
|
identify::Event::Received { peer_id, info, .. }
|
|
)) = &event {
|
|
let pid = *peer_id;
|
|
// Only what a remote peer could actually dial: a container behind
|
|
// docker's bridge reports 0.0.0.0 / 127.0.0.1 listeners too, and
|
|
// the keep-alive below would retry a pinned bad address forever.
|
|
for addr in info.listen_addrs.iter().filter(|a| is_dialable_addr(a)) {
|
|
swarm.behaviour_mut().kad.add_address(&pid, addr.clone());
|
|
}
|
|
let base_url = parse_base_url(&info.agent_version).map(str::to_string);
|
|
let update = self.peer_directory.upsert_from_identify(pid, base_url);
|
|
if let Some(url) = &update.base_url_changed {
|
|
debug!(peer_id = %pid, base_url = %url,
|
|
"identify: learned/refreshed peer base URL");
|
|
}
|
|
// Pin every identified gateway for the keep-alive tick, refreshing
|
|
// its addresses each time, so a dropped gateway gets re-dialed.
|
|
if let Some(pin) = gateway_pin(
|
|
&info.agent_version,
|
|
&info.listen_addrs,
|
|
self.peer_directory.base_url(&pid),
|
|
) {
|
|
self.pinned_gateways.insert(pid, pin);
|
|
}
|
|
// Gateway discovery. A non-empty `gateways=` token is emitted by
|
|
// meta-gateway and nobody else, so it's the "this peer is a
|
|
// gateway" signal. Its *capabilities* aren't on the wire though —
|
|
// libp2p 0.56 can't mutate agent_version after startup, so a
|
|
// runtime plugin toggle would never reach us. We fetch them over
|
|
// plain HTTP off the just-learned baseUrl instead (the reliable
|
|
// source that replaced the flaky gossipsub heartbeat).
|
|
//
|
|
// Debounced by the directory so the identify burst on a
|
|
// multi-connection peer doesn't fan out into redundant fetches.
|
|
// The keep-alive tick re-stamps it when identify goes quiet.
|
|
if advertises_gateways(&info.agent_version)
|
|
&& self
|
|
.peer_directory
|
|
.needs_cap_refresh(&pid, self.gateway_caps.refresh_after)
|
|
{
|
|
if let Some(base_url) = self.peer_directory.base_url(&pid) {
|
|
self.spawn_caps_fetch(pid, base_url);
|
|
}
|
|
}
|
|
}
|
|
// 2b) mDNS: register the address in kad's routing table (so
|
|
// subsequent lookups dedupe) and dial — unless at target.
|
|
if let SwarmEvent::Behaviour(BehaviourEvent::Mdns(
|
|
mdns::Event::Discovered(peers)
|
|
)) = &event {
|
|
for (pid, addr) in peers.iter() {
|
|
if *pid == self.local_peer_id {
|
|
continue;
|
|
}
|
|
swarm.behaviour_mut().kad.add_address(pid, addr.clone());
|
|
if swarm.is_connected(pid) {
|
|
trace!(peer_id = %pid, %addr, "mdns: peer already connected");
|
|
continue;
|
|
}
|
|
// LAN-local (mDNS) peers bypass the public-peer connection
|
|
// ceiling and are always dialed — this is the local mesh (the
|
|
// gateway + sibling peers) and it must never be starved by a
|
|
// busy public DHT that has pushed `connected` past `target`.
|
|
// mDNS is LAN-scoped, so the set stays small (no unbounded-dial
|
|
// risk); remote/public peers still respect the ceiling below.
|
|
match swarm.dial(*pid) {
|
|
Ok(()) => debug!(peer_id = %pid, %addr,
|
|
"mdns: dialing newly-discovered peer"),
|
|
Err(e) => debug!(peer_id = %pid, %addr, error = %e,
|
|
"mdns: dial failed"),
|
|
}
|
|
}
|
|
}
|
|
// 2c.5) Bitswap (M12) — fire the reply oneshot for an outbound
|
|
// `.get(cid)` when bitswap delivers the block or surfaces
|
|
// a query error. The same Behaviour also serves inbound
|
|
// WANT-HAVE/WANT-BLOCK from our local blockstore;
|
|
// those are intercepted by the Behaviour internally and
|
|
// never bubble up as `beetswap::Event` variants, so this
|
|
// arm only handles outbound results.
|
|
if let SwarmEvent::Behaviour(BehaviourEvent::Bitswap(ev)) = &event {
|
|
let fired = self.bitswap_inflight.on_event(ev);
|
|
if fired {
|
|
trace!("bitswap: inflight query resolved");
|
|
}
|
|
}
|
|
// 3) kad providers — dial each new peer-id (subject to soft ceiling).
|
|
if let SwarmEvent::Behaviour(BehaviourEvent::Kad(
|
|
kad::Event::OutboundQueryProgressed {
|
|
result: QueryResult::GetProviders(Ok(GetProvidersOk::FoundProviders {
|
|
providers, ..
|
|
})),
|
|
..
|
|
}
|
|
)) = &event {
|
|
for pid in providers.iter() {
|
|
if *pid == self.local_peer_id {
|
|
continue; // ourselves
|
|
}
|
|
if swarm.is_connected(pid) {
|
|
trace!(peer_id = %pid, "kad: provider already connected");
|
|
continue;
|
|
}
|
|
let connected = swarm.connected_peers().count();
|
|
if connected >= self.kad_target_peer_count {
|
|
trace!(peer_id = %pid,
|
|
connected, target = self.kad_target_peer_count,
|
|
"kad: at target — skipping dial of provider");
|
|
continue;
|
|
}
|
|
match swarm.dial(*pid) {
|
|
Ok(()) => debug!(peer_id = %pid,
|
|
"kad: dialing newly-discovered provider"),
|
|
Err(e) => debug!(peer_id = %pid, error = %e,
|
|
"kad: dial of discovered provider failed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
default_log(event);
|
|
}
|
|
}
|
|
|
|
/// Generic swarm-event logger — catches everything worth surfacing at
|
|
/// info/debug/trace level after `on_swarm_event`'s explicit branches.
|
|
fn default_log(event: SwarmEvent<BehaviourEvent>) {
|
|
match event {
|
|
SwarmEvent::NewListenAddr { address, .. } => {
|
|
info!(%address, "swarm listening");
|
|
}
|
|
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
|
|
trace!(%peer_id, "connection established");
|
|
}
|
|
SwarmEvent::ConnectionClosed { peer_id, cause, .. } => {
|
|
trace!(%peer_id, ?cause, "connection closed");
|
|
}
|
|
SwarmEvent::IncomingConnectionError { error, .. } => {
|
|
warn!(%error, "incoming connection error");
|
|
}
|
|
SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
|
|
warn!(?peer_id, %error, "outgoing connection error");
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received { peer_id, info, .. })) => {
|
|
trace!(%peer_id, listen_addrs = info.listen_addrs.len(), "identify received");
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Identify(_)) => {
|
|
// identify::Event::{Sent,Pushed,Error} — not interesting at info level
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
|
result: QueryResult::GetProviders(Ok(GetProvidersOk::FoundProviders { providers, .. })),
|
|
..
|
|
})) => {
|
|
debug!(provider_count = providers.len(), "kad: GetProviders ok");
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
|
result: QueryResult::StartProviding(res), ..
|
|
})) => match res {
|
|
Ok(_) => debug!("kad: StartProviding ok"),
|
|
Err(e) => warn!(error = ?e, "kad: StartProviding error"),
|
|
},
|
|
SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
|
|
result: QueryResult::Bootstrap(res), ..
|
|
})) => match res {
|
|
Ok(_) => trace!("kad: Bootstrap step ok"),
|
|
Err(e) => debug!(error = ?e, "kad: Bootstrap step error"),
|
|
},
|
|
SwarmEvent::Behaviour(BehaviourEvent::Kad(_)) => {
|
|
// RoutingUpdated, InboundRequest (won't happen in client mode),
|
|
// ModeChanged, etc. — quiet at info.
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Mdns(mdns::Event::Discovered(peers))) => {
|
|
trace!(peer_count = peers.len(), "mdns: discovered local peers");
|
|
}
|
|
SwarmEvent::Behaviour(BehaviourEvent::Mdns(mdns::Event::Expired(peers))) => {
|
|
trace!(peer_count = peers.len(), "mdns: expired local peers");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ma(s: &str) -> Multiaddr {
|
|
s.parse().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_gateway_is_pinned() {
|
|
let addrs = [ma("/ip4/172.18.0.16/tcp/4002")];
|
|
assert_eq!(gateway_pin("meta-share/2.3.0 baseUrl=http://x:3000", &addrs, None), None);
|
|
assert!(gateway_pin("meta-gateway/1.0.41 gateways=usenet,tmdb", &addrs, None).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pin_keeps_only_dialable_addresses() {
|
|
let addrs = [
|
|
ma("/ip4/127.0.0.1/tcp/4002"),
|
|
ma("/ip4/0.0.0.0/tcp/4002"),
|
|
ma("/ip4/172.18.0.16/tcp/4002"),
|
|
ma("/ip4/85.17.246.67/tcp/4002"),
|
|
];
|
|
let pin = gateway_pin(
|
|
"meta-gateway/1.0.41 gateways=usenet",
|
|
&addrs,
|
|
Some("https://metagateway-watch.nsl.sh".into()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(pin.addrs, vec![addrs[2].clone(), addrs[3].clone()]);
|
|
assert_eq!(pin.base_url.as_deref(), Some("https://metagateway-watch.nsl.sh"));
|
|
}
|
|
}
|