meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share
Publish image / publish (push) Failing after 14s

This commit is contained in:
Worph
2026-10-03 15:43:58 +00:00
parent f6dfbc49ce
commit eb21c607d4
35 changed files with 12867 additions and 1 deletions
+150
View File
@@ -0,0 +1,150 @@
//! Consumer-side bitswap call path. The HTTP layer's `/ipfs/{cid}`
//! handler calls [`bitswap_get_block`] with a cid and a timeout; the
//! helper sends a [`Command::BitswapGet`] to the swarm task and awaits
//! the reply oneshot.
//!
//! Mirrors `gateway_client.rs` in shape:
//! - [`bitswap_get_block`] — HTTP-side ergonomics helper with timeout.
//! - [`BitswapInflight`] — tracking map from `beetswap::QueryId` to
//! caller reply channels. Owned by the swarm task; mutated on
//! `Command::BitswapGet` (insert) and on `beetswap::Event::*` (fire +
//! remove).
//!
//! The block-fetching path stops at the *block* layer, not the file
//! layer: a single `bitswap.get(cid)` resolves one block. M13's
//! `/ipfs/{cid}` endpoint handles dag-pb traversal — it issues one
//! `bitswap_get_block` per cid (root + children) and reassembles the
//! UnixFS file.
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tracing::warn;
use crate::blockstore::MsCid;
use super::{BitswapGetError, Command};
/// Issue a single-block bitswap fetch and await the bytes (or a
/// transport failure / timeout). The `timeout` is enforced caller-side;
/// beetswap itself has its own per-query budget but a runaway WANT-list
/// shouldn't block the HTTP response.
pub async fn bitswap_get_block(
swarm_tx: &mpsc::Sender<Command>,
cid: MsCid,
timeout: Duration,
) -> Result<Vec<u8>, BitswapGetError> {
let (reply_tx, reply_rx) = oneshot::channel();
swarm_tx
.send(Command::BitswapGet {
cid,
reply: reply_tx,
})
.await
.map_err(|_| BitswapGetError::ReplyChannelClosed)?;
match tokio::time::timeout(timeout, reply_rx).await {
Ok(Ok(res)) => res,
// Reply oneshot dropped — swarm task fell over mid-call.
Ok(Err(_)) => Err(BitswapGetError::ReplyChannelClosed),
Err(_) => Err(BitswapGetError::QueryFailed(format!(
"bitswap fetch timed out after {:?}",
timeout
))),
}
}
/// Inflight map from `beetswap::QueryId` to the caller's reply channel.
/// Owned by the swarm task; mutated on `Command::BitswapGet` (insert)
/// and on `beetswap::Event::{GetQueryResponse, GetQueryError}` (fire +
/// remove).
#[derive(Default)]
pub(super) struct BitswapInflight {
map: HashMap<beetswap::QueryId, oneshot::Sender<Result<Vec<u8>, BitswapGetError>>>,
}
impl BitswapInflight {
pub(super) fn new() -> Self {
Self::default()
}
/// Register the reply channel for a newly-issued `bitswap.get`.
/// The libp2p call must run *before* this — its `QueryId` is what
/// we key by.
pub(super) fn register(
&mut self,
id: beetswap::QueryId,
reply: oneshot::Sender<Result<Vec<u8>, BitswapGetError>>,
) {
if self.map.insert(id, reply).is_some() {
warn!(?id, "bitswap inflight: id collision; overwrote pending reply");
}
}
/// Process a `beetswap::Event` from the swarm. Returns `true` if
/// the event was a query completion that fired a reply, `false`
/// otherwise.
pub(super) fn on_event(&mut self, event: &beetswap::Event) -> bool {
match event {
beetswap::Event::GetQueryResponse { query_id, data } => {
if let Some(tx) = self.map.remove(query_id) {
let _ = tx.send(Ok(data.clone()));
true
} else {
false
}
}
beetswap::Event::GetQueryError { query_id, error } => {
if let Some(tx) = self.map.remove(query_id) {
let _ = tx.send(Err(BitswapGetError::QueryFailed(error.to_string())));
true
} else {
false
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn dummy_cid() -> MsCid {
MsCid::from_str("bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e")
.expect("static cid parses")
}
/// Mirrors `call_gateway_returns_swarm_gone_on_dead_channel` — if
/// the swarm task has exited, the helper surfaces a closed-channel
/// error rather than hanging.
#[tokio::test]
async fn bitswap_get_returns_channel_closed_on_dead_channel() {
let (tx, rx) = mpsc::channel::<Command>(1);
drop(rx);
let res = bitswap_get_block(&tx, dummy_cid(), Duration::from_millis(50)).await;
assert!(matches!(res, Err(BitswapGetError::ReplyChannelClosed)));
}
/// Mirrors `call_gateway_returns_timeout_when_reply_never_arrives` —
/// if the swarm task accepts the command but never delivers the
/// reply, the caller-side timeout fires.
#[tokio::test]
async fn bitswap_get_returns_timeout_when_reply_never_arrives() {
let (tx, mut rx) = mpsc::channel::<Command>(1);
let pump = tokio::spawn(async move {
let _held = rx.recv().await;
tokio::time::sleep(Duration::from_secs(60)).await;
drop(_held);
});
let res = bitswap_get_block(&tx, dummy_cid(), Duration::from_millis(50)).await;
pump.abort();
match res {
Err(BitswapGetError::QueryFailed(msg)) => {
assert!(msg.contains("timed out"), "got `{msg}`");
}
other => panic!("expected timed-out QueryFailed, got {other:?}"),
}
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Bootstrap-multiaddr auto-redial machine. Distinct from kad-DHT
//! discovery: this layer keeps a small set of operator-configured
//! multiaddrs dialled across reconnects, so a leaf doesn't silently
//! disconnect when its bootstrap target restarts.
//!
//! Today's failure mode (pre-v0.2): rebuilding the bootstrap peer
//! without bouncing the leaves leaves them disconnected — they don't
//! auto-redial. This module fixes that. kad-DHT discovery is the
//! secondary mechanism (it'd re-establish the mesh anyway, but only at
//! `discovery_interval` cadence).
use libp2p::multiaddr::Protocol;
use libp2p::{Multiaddr, PeerId, Swarm};
use tracing::{debug, trace};
use super::Behaviour;
/// One bootstrap target we keep alive across reconnects.
///
/// `peer_id` is populated either from a `/p2p/<id>` segment in the configured
/// multiaddr or, lacking that, from the first successful outgoing dial through
/// this address. Once known, it's used to skip redundant dials when a
/// connection is already up — without it, every redial tick adds a duplicate
/// connection that only goes away on idle timeout.
pub struct BootstrapEntry {
pub addr: Multiaddr,
pub peer_id: Option<PeerId>,
}
impl BootstrapEntry {
pub fn new(addr: Multiaddr) -> Self {
let peer_id = addr.iter().find_map(|p| match p {
Protocol::P2p(id) => Some(id),
_ => None,
});
Self { addr, peer_id }
}
}
/// Re-dial any bootstrap entry whose target is not currently connected.
pub fn redial_bootstrap(swarm: &mut Swarm<Behaviour>, bootstrap: &[BootstrapEntry]) {
for entry in bootstrap {
if let Some(pid) = entry.peer_id {
if swarm.is_connected(&pid) {
trace!(addr = %entry.addr, "auto-redial: already connected, skip");
continue;
}
}
// Either we never learned this entry's peer-id (so we can't tell if
// we're connected — dial unconditionally and rely on libp2p's
// ConnectionEstablished to populate the peer-id next time), or the
// peer-id is known and we're not connected.
match swarm.dial(entry.addr.clone()) {
Ok(()) => debug!(addr = %entry.addr, "auto-redial: dial issued"),
Err(e) => debug!(addr = %entry.addr, error = %e, "auto-redial: dial failed"),
}
}
}
/// Returns true if `endpoint_addr` (the resolved address libp2p actually
/// dialed) was reached via the configured `bootstrap_addr`. Compares only the
/// non-DNS, non-`/p2p/...` prefix shape — DNS resolution rewrites the addr,
/// so byte-equality won't match. Pragmatic check: same TCP port, plus either
/// IP-prefix shape or both having a DNS segment. Good enough to
/// opportunistically link a learned peer-id back to a configured bootstrap
/// entry; false matches just mean we'd skip a redial that wasn't redundant.
pub fn addr_targets_match(bootstrap_addr: &Multiaddr, endpoint_addr: &Multiaddr) -> bool {
let port_a = extract_tcp_port(bootstrap_addr);
let port_b = extract_tcp_port(endpoint_addr);
port_a.is_some() && port_a == port_b
}
fn extract_tcp_port(addr: &Multiaddr) -> Option<u16> {
addr.iter().find_map(|p| match p {
Protocol::Tcp(port) => Some(port),
_ => None,
})
}
+558
View File
@@ -0,0 +1,558 @@
//! 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::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::{namespace_key, peer_id_from_multiaddr, KadConfig};
use super::peer_directory::PeerDirectory;
use super::{Behaviour, BehaviourEvent, Command, GatewayCapsConfig, PeersInfo};
/// 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,
}
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,
}
}
/// 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);
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);
},
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.
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;
let addrs: Vec<Multiaddr> = info.listen_addrs.clone();
for addr in addrs {
swarm.behaviour_mut().kad.add_address(&pid, addr);
}
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");
}
// 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).
//
// Fire-and-forget: a slow/dead gateway must not stall the swarm
// loop. Debounced by the directory so the identify burst on a
// multi-connection peer doesn't fan out into redundant fetches;
// identify's ~5min re-announce then keeps the capability fresh
// inside its 600s TTL, which is why there's no keep-alive tick.
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) {
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);
}
// Best-effort: keep whatever we held (it ages out on
// its own TTL) and retry on the next identify.
Err(e) => debug!(peer_id = %pid, base_url = %base_url,
error = %e, "gateway caps: fetch failed"),
}
});
}
}
}
// 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");
}
_ => {}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! libp2p `identify` protocol — how meta-share peers learn each other's
//! HTTP API URL.
//!
//! `identify::Behaviour` already exchanges peer-ids, listen addresses,
//! and an `agent_version` string. We overload `agent_version` with
//! whitespace-separated structured tokens:
//!
//! - `baseUrl=<url>` — the peer's HTTP API URL, used by cross-peer
//! fetches (`/api/peer/:peer_id/file/:cid`) to dial server-to-server.
//!
//! Free-form-string-with-keys is the convention for `agent_version` in
//! libp2p; we'll graduate to a dedicated protocol if the shape starts
//! feeling cramped.
/// Identifier advertised on the identify protocol. Bumping this is harmless;
/// peers identify regardless of the protocol-version string.
pub const IDENTIFY_PROTOCOL: &str = "/meta-share/identify/1.0.0";
/// Token prefix in `identify.agent_version` carrying our HTTP API base URL.
/// Everything from this token to the next whitespace is the URL. Parsed by
/// other peers on `identify::Event::Received` and stored in the peer
/// directory so cross-peer fetches (`/api/peer/:peer_id/file/:cid`) can
/// dial without going through meta-core's filesystem registry.
const AGENT_BASE_URL_TOKEN: &str = "baseUrl=";
/// Construct the identify-protocol agent_version string. Format:
/// `meta-share/<version>` followed by `baseUrl=<url>` if set. Other peers
/// parse the URL via [`parse_base_url`].
pub fn build_agent_version(base_url: Option<&str>) -> String {
let v = env!("CARGO_PKG_VERSION");
let mut s = format!("meta-share/{v}");
if let Some(url) = base_url {
if !url.is_empty() {
s.push(' ');
s.push_str(AGENT_BASE_URL_TOKEN);
s.push_str(url);
}
}
s
}
/// Extract the HTTP API base URL from a remote peer's `agent_version`.
/// Returns `None` if the field doesn't carry one (consumer-only peer
/// with no `BASE_URL` set, or a non-meta-share peer).
pub fn parse_base_url(agent_version: &str) -> Option<&str> {
agent_version
.split_whitespace()
.find_map(|tok| tok.strip_prefix(AGENT_BASE_URL_TOKEN))
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_agent_version_includes_base_url() {
let s = build_agent_version(Some("http://x:9"));
assert!(s.starts_with("meta-share/"));
assert!(s.contains("baseUrl=http://x:9"));
}
#[test]
fn parse_base_url_extracts_token() {
let av = "meta-share/0.1.0 baseUrl=http://x:9";
assert_eq!(parse_base_url(av), Some("http://x:9"));
}
#[test]
fn parse_base_url_returns_none_when_absent() {
let av = "meta-share/0.1.0";
assert_eq!(parse_base_url(av), None);
}
}
+173
View File
@@ -0,0 +1,173 @@
//! libp2p kad-DHT discovery — peer discovery in `Mode::Client` against
//! the public IPFS DHT. Two responsibilities:
//!
//! 1. **Peer discovery** — periodic `start_providing` / `get_providers`
//! under a namespace key lets every archivist find every other
//! archivist without a designated bootstrap node.
//! 2. **CID → peer lookup** for the metadata fetch path (planned;
//! not wired through the HTTP layer yet).
//!
//! See ADR 0001 §"Operational discovery in v0.2" and the package
//! `CLAUDE.md` "Load-bearing decisions" for the rationale.
use std::time::Duration;
use libp2p::kad::RecordKey;
use libp2p::multiaddr::Protocol;
use libp2p::{Multiaddr, PeerId};
use sha2::{Digest, Sha256};
/// kad protocol name. Using `/ipfs/kad/1.0.0` so we ride the same DHT as
/// IPFS / kubo and inherit the network-effect of its bootstrap nodes.
/// Cohort isolation is achieved via a unique namespace key, not a unique
/// protocol.
pub const IPFS_KAD_PROTOCOL: &str = "/ipfs/kad/1.0.0";
/// Default kad bootstrap multiaddrs, copied from kubo. Operators can
/// override (or prepend) via the `KAD_BOOTSTRAP_PEERS` env var.
pub const DEFAULT_KAD_BOOTSTRAPS: &[&str] = &[
"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
"/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
"/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
"/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
];
/// Compute the kad provider key for a given namespace string.
/// SHA-256 gives uniformly-distributed 32-byte keys, so multiple cohorts
/// don't cluster on the same DHT nodes.
pub fn namespace_key(namespace: &str) -> RecordKey {
let digest = Sha256::digest(namespace.as_bytes());
RecordKey::new(&digest.as_slice().to_vec())
}
/// Configuration for the kad-based peer discovery loop.
pub struct KadConfig {
/// Multiaddrs to seed kad's routing table. Each entry MUST include a
/// trailing `/p2p/<peer_id>` segment so we can register the peer-id with
/// the routing table without an extra dial round-trip.
pub bootstrap_peers: Vec<Multiaddr>,
/// Namespace string. Hashed to a `RecordKey`; cohort isolation knob.
pub namespace: String,
/// How often to (re)publish ourselves as a provider for the namespace
/// key. Provider records expire on remote peers (default ~24 h), so this
/// must be lower than that — 6 h is the kubo default.
pub provide_interval: Duration,
/// How often to query the DHT for other providers of the namespace key
/// when the swarm is at or above `target_peer_count`. Steady-state topup
/// rate; trades discovery latency for DHT-traffic load.
pub discovery_interval: Duration,
/// How often to query when the swarm is below `target_peer_count`. Should
/// be substantially shorter than `discovery_interval` so a peer that
/// joined cold can fan out quickly. Also acts as the rate-limit floor for
/// reactive kicks on `ConnectionClosed`.
pub fast_discovery_interval: Duration,
/// Connection target — used as both a discovery floor and a soft
/// ceiling for *new* discovery-driven connections (kadDHT k-bucket
/// style):
/// - **Below target**: tick at `fast_discovery_interval`; react to
/// `ConnectionClosed` by firing the next kad query immediately.
/// - **At/above target**: tick at `discovery_interval`; mDNS- and
/// kad-discovered peers are added to the kad routing table for
/// later lookup but not actively dialled, and inbound
/// `IncomingConnection` events are immediately closed.
///
/// "Soft" because it doesn't apply to bootstrap dials or auto-redial
/// (those are intentional connections), and there's a small race
/// window where two simultaneous inbounds can both pass the check
/// before either is established. Also doesn't kick out existing
/// connections that drift over the cap from outside causes — idle
/// timeout handles that.
pub target_peer_count: usize,
/// Whether to publish the namespace provider record at all. False when
/// this peer isn't publicly reachable (no `PUBLIC_ADDR`) — a provider
/// record for an undialable peer is pure pollution: every peer that
/// walks the DHT and finds us burns a dial that can never connect.
/// Derived in `config::resolve_network`.
pub provide_enabled: bool,
}
/// Extract the peer-id from a multiaddr's trailing `/p2p/<id>` segment.
/// Returns `None` if the multiaddr doesn't include one (kad's routing
/// table is keyed by peer-id, so address-only entries can't be registered
/// without a separate identify round-trip).
pub fn peer_id_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
addr.iter().find_map(|p| match p {
Protocol::P2p(id) => Some(id),
_ => None,
})
}
/// Whether a multiaddr is worth handing to a remote peer as somewhere we
/// can be reached.
///
/// A listener bound to `0.0.0.0` reports itself back as `/ip4/0.0.0.0/...`,
/// and an ephemeral bind reports `/tcp/0` — neither is dialable by anyone.
/// Loopback is only dialable by a peer already inside our netns. Publishing
/// any of them (in a kad provider record, in an identify payload) costs
/// every peer that receives it a dial that can never succeed.
///
/// RFC1918 / link-local addresses deliberately PASS: they're exactly what
/// mDNS discovery trades on, and a private cross-host mesh is a legitimate
/// deployment. The public/private distinction is the operator's to make via
/// `PUBLIC_ADDR` — this function only rejects addresses that are undialable
/// by *construction*.
///
/// Mirrors meta-gateway's `swarm::kad_helpers::is_dialable_addr` and
/// meta-search's copy. Duplicated by necessity (independent submodules).
pub fn is_dialable_addr(addr: &Multiaddr) -> bool {
let mut has_host = false;
for proto in addr.iter() {
match proto {
Protocol::Ip4(ip) => {
if ip.is_unspecified() || ip.is_loopback() {
return false;
}
has_host = true;
}
Protocol::Ip6(ip) => {
if ip.is_unspecified() || ip.is_loopback() {
return false;
}
has_host = true;
}
Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_) | Protocol::Dnsaddr(_) => {
has_host = true;
}
Protocol::Tcp(0) | Protocol::Udp(0) => return false,
_ => {}
}
}
has_host
}
#[cfg(test)]
mod tests {
use super::*;
fn ma(s: &str) -> Multiaddr {
s.parse().expect("test multiaddr")
}
#[test]
fn rejects_unspecified_loopback_and_port_zero() {
assert!(!is_dialable_addr(&ma("/ip4/0.0.0.0/tcp/4001")));
assert!(!is_dialable_addr(&ma("/ip4/127.0.0.1/tcp/4001")));
assert!(!is_dialable_addr(&ma("/ip6/::/tcp/4001")));
assert!(!is_dialable_addr(&ma("/ip6/::1/tcp/4001")));
assert!(!is_dialable_addr(&ma("/ip4/1.2.3.4/tcp/0")));
}
#[test]
fn accepts_private_and_public_hosts() {
assert!(is_dialable_addr(&ma("/ip4/172.18.0.5/tcp/4001")));
assert!(is_dialable_addr(&ma("/ip4/1.2.3.4/tcp/4001")));
assert!(is_dialable_addr(&ma("/dns4/peer.example.com/tcp/4001")));
}
#[test]
fn rejects_addr_without_a_host_component() {
assert!(!is_dialable_addr(&ma(
"/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"
)));
}
}
+421
View File
@@ -0,0 +1,421 @@
//! libp2p swarm — composite `NetworkBehaviour`, command channel, and the
//! tokio task that drives them.
//!
//! The Swarm itself is `!Send` once it owns the libp2p connection state, so
//! we follow the standard pattern: the Swarm is owned by a single tokio task
//! that loops over events and a command receiver. The HTTP layer talks to
//! the swarm by sending [`Command`] messages and awaiting oneshot replies.
//!
//! The behaviour is a `NetworkBehaviour`-derive composite of:
//! - `identify` — exchanges listen addresses on connection. Load-bearing for
//! bitswap reachability: without it the listener side of a connection only
//! sees the dialer's ephemeral source port, so peers can't be re-dialed for
//! block transfer.
//! - `kad` — Kademlia DHT in `Mode::Client` against the public IPFS DHT.
//! Two responsibilities: peer discovery via namespace-key provider records,
//! and cid → peer lookup for the metadata fetch path. See ADR 0001
//! §"Operational discovery in v0.2".
//! - `mdns` — local-network discovery (`Toggle`'d so it can be cheaply
//! disabled when the network blocks multicast).
//!
//! Submodule layout:
//! - [`peer_directory`] — `peer_id → PeerInfo` shared with the HTTP layer.
//! - [`identify_agent`] — the `agent_version` overload (baseUrl).
//! - [`kad_discovery`] — kad config, namespace-key, DHT helpers.
//! - [`bootstrap`] — the auto-redial machine for operator-configured
//! multiaddrs (distinct from kad — see the module doc).
//! - [`event_loop`] — the `SwarmTask` struct + per-arm handler methods.
use std::time::Duration;
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use libp2p::connection_limits::{self, ConnectionLimits};
use libp2p::kad::{self, store::MemoryStore};
use libp2p::mdns::tokio::Behaviour as MdnsBehaviour;
use libp2p::mdns::Config as MdnsConfig;
use libp2p::swarm::behaviour::toggle::Toggle;
use libp2p::swarm::NetworkBehaviour;
use libp2p::{identify, identity, Multiaddr, PeerId, StreamProtocol, Swarm};
use tokio::sync::{mpsc, oneshot};
use tracing::warn;
use crate::blockstore::{GatedBlockstore, IngressRouter, MAX_MULTIHASH_SIZE};
use crate::filestore::SharedBlockstore;
/// Concrete bitswap behaviour alias — fixes the const generic so the
/// `#[derive(NetworkBehaviour)]` macro on `Behaviour` doesn't need to
/// carry generic params of its own. `MAX_MULTIHASH_SIZE = 64` matches
/// the gateway side; both peers must agree because the const flows
/// through bitswap's wire-format multihash truncation.
///
/// The store is three layers, outermost first:
///
/// 1. [`IngressRouter`](crate::blockstore::IngressRouter) — decides where an
/// *arriving* block goes: into the file an in-flight fetch is materialising,
/// or (for small unattributed objects) the block table. Phase 4.
/// 2. [`GatedBlockstore`](crate::blockstore::GatedBlockstore) — meters blocks
/// *leaving* over bitswap, the only place outbound bitswap bytes can be
/// throttled while this peer's own viewer is watching something.
/// 3. [`SharedBlockstore`](crate::filestore::SharedBlockstore) — the nocopy
/// filestore, which resolves a leaf ref (or an in-flight leaf) to real bytes.
///
/// The order is load-bearing: routing must happen before the gate (a write is
/// not egress and must not be metered as one), and the gate must wrap the
/// filestore so a ref-resolved or in-flight leaf served to a stranger is
/// throttled like any other block. See `crate::ingress`, `crate::filestore`,
/// `crate::focus`.
pub type Bitswap =
beetswap::Behaviour<MAX_MULTIHASH_SIZE, IngressRouter<GatedBlockstore<SharedBlockstore>>>;
/// Custom `Multihasher` for the meta-hash `midhash256` codec (0x1000).
/// Without this, beetswap rejects BLOCK responses keyed by midhash
/// with `Unknown multihash code: 4096` and the WANT silently times
/// out. The arithmetic is the same as `crate::hash::compute_midhash256`
/// but applied to the verbatim block bytes — beetswap doesn't care
/// what the bytes are, only that the multihash round-trips.
///
/// **Important caveat**: beetswap hashes whatever bytes it received
/// from the wire. For midhash, the CID's digest is computed over
/// `[size:u64-be][middle-1MiB slice]`, **not** the raw record bytes.
/// So calling `Sha256(record_bytes)` here would not match the
/// midhash digest we keyed the block by. To make midhash blocks
/// bitswap-verifiable we'd need beetswap to hash a transformed
/// input — which beetswap's `Multihasher` trait does not expose.
///
/// In practice this isn't a problem today because every meta-core
/// record carries a sha-family CID alongside its midhash (the
/// `fullhash` plugin runs unconditionally) and
/// `canonical_cid_from_metadata` ranks sha-family above midhash. So
/// the canonical CID Kamilata announces is sha-family, and bitswap
/// only ever needs sha2/sha3 verification — which the
/// `multihash-codetable` features unlock cleanly.
///
/// The midhash multihasher below exists for completeness (so the
/// codec is *registered* and beetswap's error mode is "verify
/// mismatch", a deliberate signal that the design needs revisiting,
/// rather than "unknown code", which looks like a missing dependency).
/// If a record ever ends up with midhash as its canonical CID, the
/// resulting bitswap fetch will fail with a verify mismatch and the
/// `/api/file/{cid}` handler will return 404 — same observable
/// behavior as if midhash had been rejected up-front, just clearer
/// in the logs.
struct MidhashMultihasher;
impl beetswap::multihasher::Multihasher<MAX_MULTIHASH_SIZE> for MidhashMultihasher {
async fn hash(
&self,
multihash_code: u64,
input: &[u8],
) -> Result<libp2p::multihash::Multihash<MAX_MULTIHASH_SIZE>, beetswap::multihasher::MultihasherError> {
// 0x1000 = the meta-hash midhash256 codec (see hash.rs).
if multihash_code != 0x1000 {
return Err(beetswap::multihasher::MultihasherError::UnknownMultihashCode);
}
use sha2::{Digest, Sha256};
let digest: [u8; 32] = Sha256::digest(input).into();
// CIDv1 multihash field is `[code varint][len][digest]`; the
// Multihash<S> type holds just `(code, len, digest_bytes)`.
libp2p::multihash::Multihash::<MAX_MULTIHASH_SIZE>::wrap(0x1000, &digest)
.map_err(|_| beetswap::multihasher::MultihasherError::InvalidMultihashSize)
}
}
mod bitswap_client;
mod bootstrap;
mod event_loop;
mod identify_agent;
mod kad_discovery;
mod peer_directory;
pub use bitswap_client::bitswap_get_block;
pub use identify_agent::build_agent_version;
pub use kad_discovery::{is_dialable_addr, KadConfig, DEFAULT_KAD_BOOTSTRAPS};
pub use peer_directory::PeerDirectory;
use identify_agent::IDENTIFY_PROTOCOL;
use kad_discovery::IPFS_KAD_PROTOCOL;
/// Composite behaviour: identify for address exchange, kad for namespace-key
/// based peer discovery on the public IPFS DHT, mdns for local-network
/// discovery (`Toggle`'d so it can be cheaply disabled when the network
/// blocks multicast), and bitswap for IPFS block transfer. meta-share is
/// wire-level pure content transport — the kamilata federated-search routing
/// primitive was removed, so there is no search/gateway/gossipsub behaviour.
#[derive(NetworkBehaviour)]
pub struct Behaviour {
pub identify: identify::Behaviour,
pub kad: kad::Behaviour<MemoryStore>,
pub mdns: Toggle<MdnsBehaviour>,
/// Protocol-level connection ceiling. A pure guard behaviour (it emits no
/// events — the derived `BehaviourEvent::Conns` variant is uninhabited and
/// falls through the `_ => {}` arm in `event_loop::default_log`): it vetoes
/// new connections once the swarm is at capacity, so process RSS — which
/// scales with noise + yamux + per-substream buffers per connection —
/// stays bounded without capping meta-share's HTTP / ingest / seeding work.
/// Limits from [`connection_limits_config`] (`META_SHARE_MAX_CONNS`).
pub conns: connection_limits::Behaviour,
/// M12: IPFS bitswap. Client-side use case: the M13
/// `/ipfs/{cid}` HTTP endpoint sends a `BitswapGet` command to
/// the swarm task, which calls `bitswap.get(cid)`; bitswap
/// negotiates with connected peers (gateway or other consumers
/// that already cached the cid) and surfaces the bytes via
/// `Event::GetQueryResponse`. The shared
/// `Arc<RedbBlockstore>` also makes us a candidate provider for
/// every cid we've fetched once — multi-source pulls emerge.
pub bitswap: Bitswap,
}
/// Messages from the HTTP layer to the swarm task. Each variant carries a
/// oneshot reply channel so the caller can `await` the result.
pub enum Command {
/// Returns the connected peer count and our own peer-id.
Peers {
reply: oneshot::Sender<PeersInfo>,
},
/// M12: fetch a single block via bitswap. The swarm task calls
/// `bitswap.get(&cid)`, registers the resulting `QueryId` with
/// `BitswapInflight`, and fires `reply` on `Event::GetQueryResponse`
/// / `GetQueryError`. The HTTP layer wraps this with a timeout
/// (per-block deadline) in [`bitswap_get_block`].
BitswapGet {
cid: crate::blockstore::MsCid,
reply: oneshot::Sender<Result<Vec<u8>, BitswapGetError>>,
},
/// Announce that this peer provides `cid` on the public IPFS DHT
/// (`kad.start_providing`, keyed by the CID's multihash so external
/// IPFS nodes resolve it). Fire-and-forget — no reply; the outcome is
/// logged by the `StartProviding` arm in `default_log`. Sent from the
/// seed-manifest upsert path when `seed_dht_provide` is on.
Provide {
cid: crate::blockstore::MsCid,
},
/// Stop announcing `cid` (`kad.stop_providing`). Fire-and-forget. Sent
/// from the DELETE /api/seeds/:cid teardown for an IPFS seed.
StopProviding {
cid: crate::blockstore::MsCid,
},
}
/// Failure modes for the consumer-side `BitswapGet` round-trip.
#[derive(Debug, thiserror::Error)]
pub enum BitswapGetError {
/// beetswap's `GetQueryError`. Wraps the original error string —
/// the typed enum lives in beetswap and isn't worth re-exporting.
#[error("bitswap query error: {0}")]
QueryFailed(String),
/// The swarm task dropped the reply channel (process shutting
/// down).
#[error("swarm reply channel closed")]
ReplyChannelClosed,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PeersInfo {
pub local_peer_id: String,
pub connected: Vec<String>,
}
/// Default ceiling on total established connections. Each libp2p connection
/// carries a noise session + yamux state + per-substream buffers, so process
/// RSS grows with the connection count. 128 is generous for a transport /
/// seeding node (bootstraps + content peers) while bounding that growth.
/// Override with `META_SHARE_MAX_CONNS`.
const DEFAULT_MAX_ESTABLISHED_CONNS: u32 = 128;
/// Build the [`ConnectionLimits`] for the `connection_limits` guard behaviour.
///
/// - `max_established` (total, across all peers) is the primary RSS lever,
/// env-tunable via `META_SHARE_MAX_CONNS`.
/// - per-peer is capped at 3: enough for TCP + QUIC to the same peer plus one
/// in-flight address migration, but no runaway fan-out from a single peer.
/// - pending-incoming is capped so half-open inbound handshakes (each holding
/// a noise buffer) can't pile up.
fn connection_limits_config() -> ConnectionLimits {
let max_established = std::env::var("META_SHARE_MAX_CONNS")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_ESTABLISHED_CONNS);
ConnectionLimits::default()
.with_max_established(Some(max_established))
.with_max_established_per_peer(Some(3))
.with_max_pending_incoming(Some(32))
}
/// Build the libp2p Swarm — TCP + QUIC + DNS, noise auth, yamux mux,
/// identify + kad + mdns + bitswap.
///
/// QUIC was added in v0.2 because the public IPFS DHT bootstraps that kad
/// rides advertise `/quic-v1` and `/wss` multiaddrs (no plain `/tcp` after
/// dnsaddr resolution). Without QUIC the kad layer can't reach the public
/// bootstraps.
///
/// `enable_mdns` controls whether multicast-DNS local discovery is started.
/// Default `true` — purely additive, automatically finds peers on the same
/// LAN/docker network without any per-cohort bootstrap config. Set false
/// only on networks that actively block multicast (some VPNs, some
/// mDNS-isolating switches). When false, `Toggle::default()` makes the
/// behaviour an inert no-op.
///
/// `agent_version` becomes the identify-protocol `agent_version` string.
/// Build it via [`build_agent_version`] so other peers can parse our HTTP
/// API URL out of it for cross-peer file fetches.
pub fn build_swarm(
keypair: identity::Keypair,
listen_addr: &Multiaddr,
enable_mdns: bool,
agent_version: String,
blockstore: Arc<SharedBlockstore>,
focus: Arc<meta_feeder_sdk::transport::FocusView>,
ingress: Arc<crate::ingress::IngressRegistry>,
) -> Result<(Swarm<Behaviour>, PeerId)> {
let local_peer_id = PeerId::from(keypair.public());
let mut swarm = libp2p::SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_tcp(
libp2p::tcp::Config::default(),
libp2p::noise::Config::new,
libp2p::yamux::Config::default,
)
.context("tcp transport")?
.with_quic()
// .with_dns() wraps the transport so `/dns/host/tcp/port` and
// `/dnsaddr/.../p2p/<id>` multiaddrs resolve via the system resolver
// before dial. Without this, the raw transport sees a /dns/... or
// /dnsaddr/... multiaddr as "unsupported" and fails — surfaced when
// dialing docker DNS names AND when riding the public IPFS DHT
// bootstraps.
.with_dns()
.context("dns transport")?
.with_behaviour(|keypair| {
let kad_store = MemoryStore::new(local_peer_id);
let kad_config = kad::Config::new(StreamProtocol::new(IPFS_KAD_PROTOCOL));
let mut kad_behaviour = kad::Behaviour::with_config(local_peer_id, kad_store, kad_config);
// Mode::Client: we use the public DHT to publish/find provider
// records but don't serve queries for unrelated IPFS traffic.
// Keeps the bandwidth/CPU footprint small and avoids becoming a
// routing node for the world. ADR §"Operational discovery in v0.2".
kad_behaviour.set_mode(Some(kad::Mode::Client));
let mdns_behaviour: Toggle<MdnsBehaviour> = if enable_mdns {
match MdnsBehaviour::new(MdnsConfig::default(), local_peer_id) {
Ok(b) => Some(b).into(),
Err(e) => {
warn!(error = %e,
"mdns: failed to start (network may not support multicast); continuing without it");
None.into()
}
}
} else {
None.into()
};
// M12: bitswap. The Behaviour is both client (`.get(cid)`
// calls fan out WANT-HAVE to connected peers — gateways +
// siblings) AND server (peers we serve `.put_keyed` blocks
// to from the shared `RedbBlockstore`).
//
// The builder registers `MidhashMultihasher` on top of
// beetswap's `StandardMultihasher`. `StandardMultihasher`
// covers sha2 + sha3 (both unlocked by `multihash-codetable`
// features in our Cargo.toml — required because beetswap
// pulls multihash-codetable in with no features by default,
// which would leave sha3-256 unrecognised and silently drop
// every BLOCK response that uses it). `MidhashMultihasher`
// is the meta-hash custom codec 0x1000.
//
// Without these registrations, every Kamilata-discovered
// record fetch failed with "Unknown multihash code: N" on
// the receive side (root-caused via beetswap=trace logs in
// the 2026-05-25 ADR 0002 follow-up session).
// Hand bitswap the *gated* view of the store — the throttle point for
// blocks this peer serves to others while its own viewer is watching
// something. `blockstore` itself (the raw handle) is untouched and
// still backs every local read.
let bitswap = beetswap::Behaviour::builder(Arc::new(IngressRouter::new(
Arc::new(GatedBlockstore::new(
Arc::clone(&blockstore),
Arc::clone(&focus),
)),
Arc::clone(&ingress),
)))
.register_multihasher(MidhashMultihasher)
.build();
Behaviour {
identify: identify::Behaviour::new(
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), keypair.public())
.with_agent_version(agent_version.clone()),
),
kad: kad_behaviour,
mdns: mdns_behaviour,
bitswap,
conns: connection_limits::Behaviour::new(connection_limits_config()),
}
})
.map_err(|e| anyhow!("behaviour stage: {e}"))?
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(120)))
.build();
swarm
.listen_on(listen_addr.clone())
.with_context(|| format!("listen on {listen_addr}"))?;
Ok((swarm, local_peer_id))
}
/// Spawn the swarm event-loop task. Returns the command sender the HTTP
/// layer uses to talk to the swarm.
///
/// `bootstrap_peers` are dialed once on startup, then re-dialed every
/// `redial_interval` for any entry whose target peer is not currently
/// connected. Pass an empty list (or zero interval) to disable auto-redial.
///
/// `kad_config` configures the libp2p-kad-based peer discovery loop: kad
/// bootstrap addresses are added to the routing table, an initial bootstrap
/// query is issued, and the namespace key is periodically (re)provided and
/// looked up. Discovered providers are dialed; identify finishes the
/// connection handshake.
///
/// `http` is the process-wide client, cloned into the task for the gateway
/// capability fetch (`GET {baseUrl}/api/gateway/plugins`) that runs off an
/// identify from a meta-gateway peer. That fetch is plain HTTP by design — it
/// adds no libp2p behaviour to the transport-only swarm.
#[allow(clippy::too_many_arguments)]
pub fn spawn(
swarm: Swarm<Behaviour>,
local_peer_id: PeerId,
bootstrap_peers: Vec<Multiaddr>,
redial_interval: Duration,
kad_config: KadConfig,
peer_directory: PeerDirectory,
http: reqwest::Client,
gateway_caps: GatewayCapsConfig,
) -> mpsc::Sender<Command> {
let (tx, rx) = mpsc::channel::<Command>(64);
let task = event_loop::SwarmTask::new(
local_peer_id,
bootstrap_peers,
redial_interval,
kad_config,
peer_directory,
http,
gateway_caps,
);
tokio::spawn(task.run(swarm, rx));
tx
}
/// Knobs for the gateway capability fetch triggered off identify.
#[derive(Clone, Copy, Debug)]
pub struct GatewayCapsConfig {
/// Per-request timeout for `GET {baseUrl}/api/gateway/plugins`.
pub fetch_timeout: Duration,
/// Don't re-fetch a peer's capabilities more often than this. Sized to
/// identify's ~5-minute re-announce cadence, so a connected gateway is
/// re-stamped roughly once per identify and stays inside the directory's
/// 600s freshness TTL without any extra timer.
pub refresh_after: Duration,
}
+585
View File
@@ -0,0 +1,585 @@
//! Per-peer state learned from libp2p identify exchanges. The directory
//! is shared between the swarm task (writer) and the HTTP layer (readers
//! — `/api/peers`, cross-peer file fetches).
//!
//! Held behind a `std::sync::RwLock` (not `tokio::sync::RwLock`) because
//! every access is a brief read/write of a `HashMap<PeerId, _>` —
//! contention is essentially nil at our scale and we don't want to await
//! across the lock.
//!
//! Since the gateway tier moved into its own service, the directory's job
//! is narrow: map a peer id to its HTTP API URL (parsed from identify's
//! `baseUrl=` token) so the cross-peer byte-fetch path can reach it.
//! [`PeerDirectory::forget_peer`] drops a peer's state on its last
//! `ConnectionClosed`.
// The freshness queries (`gateways_redeeming`, `nzb_fetch_gateways`, …) are
// answered by the hull's replica of this directory (`GET /ipfs-tier/directory`);
// they stay here, tested, as the reference the replica mirrors.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use libp2p::PeerId;
use crate::gateway_discovery::{RedeemClaim, REDEEM_NZB_RELEASE};
/// How long an identify-sourced fact stays fresh. Identify re-announces on
/// libp2p's default 5-minute cadence, so a live peer re-stamps its facts well
/// inside this window; a peer that goes quiet ages out rather than lingering as
/// a forever-stale routing target. (Disconnected peers are usually dropped
/// sooner, by `forget_peer` on the last `ConnectionClosed`.)
const IDENTIFY_TTL: Duration = Duration::from_secs(600);
/// Per-peer state learned from libp2p identify exchanges: the peer's HTTP API
/// URL (for cross-peer file fetches) plus, for meta-gateway peers, the
/// gateway capabilities fetched off that URL.
#[derive(Clone, Debug, Default)]
pub struct PeerInfo {
/// HTTP API URL parsed from identify's `baseUrl=` token. `None` when
/// the peer didn't advertise one (consumer-only, BASE_URL unset).
pub base_url: Option<String>,
/// When this peer last advertised `nzbFetch: true` on
/// `/api/gateway/plugins` — i.e. it fronts a credentialed meta-share and
/// can materialise + serve `nzb-release` bytes. `None` = never, or it
/// stopped. Read accessors treat the capability as gone once older than
/// [`IDENTIFY_TTL`]. Powers [`PeerDirectory::nzb_fetch_gateways`].
nzb_fetch_at: Option<Instant>,
/// The locator families this gateway's feeders can redeem, from the same
/// capability fetch. Fresh while `caps_fetched_at` is. Powers
/// [`PeerDirectory::gateways_redeeming`] and, with `nzb_fetch_at`,
/// [`PeerDirectory::gateway_can_redeem`].
redeems: Vec<RedeemClaim>,
/// When we last *completed* a capability fetch against this peer, whatever
/// the answer was.
///
/// ⚠ This is deliberately separate from `nzb_fetch_at`. Debouncing off the
/// capability stamp would mean a gateway answering `nzbFetch: false` (which
/// leaves `nzb_fetch_at = None`) looks identical to one never fetched — so
/// every single identify would re-fire the HTTP call, forever. The
/// attempt stamp is what makes the debounce terminate.
caps_fetched_at: Option<Instant>,
}
/// Shared, read-mostly directory of `peer_id → PeerInfo`. Populated by the
/// swarm task on every identify event and read by the HTTP API for
/// cross-peer fetches. Not behind tokio::sync::RwLock — locks are always
/// brief and identify events are infrequent.
#[derive(Clone, Default)]
pub struct PeerDirectory(Arc<RwLock<HashMap<PeerId, PeerInfo>>>);
impl PeerDirectory {
/// Build a fresh, empty directory. Use this once at startup and clone
/// the handle into both `spawn` and the AppState; the `Arc` inside
/// shares the underlying map.
pub fn new() -> Self {
Self::default()
}
/// Just the peer's API URL. `None` when the peer advertised no URL or
/// hasn't been seen yet.
pub fn base_url(&self, pid: &PeerId) -> Option<String> {
self.0
.read()
.expect("peer directory poisoned")
.get(pid)
.and_then(|info| info.base_url.clone())
}
/// Atomically update a peer's info from an identify exchange.
///
/// `base_url = Some(_)` replaces; `base_url = None` preserves any
/// previously-learned URL — identify events from later peer versions
/// may legitimately omit the token, and the previous URL is still
/// valid until the peer reconnects with a different one.
///
/// Returns a `PeerInfoUpdate` summarising what changed.
pub fn upsert_from_identify(
&self,
peer_id: PeerId,
base_url: Option<String>,
) -> PeerInfoUpdate {
let mut map = self.0.write().expect("peer directory poisoned");
let entry = map.entry(peer_id).or_default();
let mut update = PeerInfoUpdate::default();
if let Some(url) = base_url {
if entry.base_url.as_deref() != Some(url.as_str()) {
entry.base_url = Some(url.clone());
update.base_url_changed = Some(url);
}
}
update
}
/// Record the outcome of a `/api/gateway/plugins` capability fetch.
/// `capable` stamps the `nzbFetch` liveness; `false` clears it (the gateway
/// dropped its credentialed meta-share). `redeems` replaces the gateway's
/// redeem claims, which don't depend on `nzbFetch` — redeeming a locator
/// needs the gateway's feeder, not an NNTP pool. Either way the *attempt* is
/// stamped, which is what [`Self::needs_cap_refresh`] debounces on.
///
/// Only called after a fetch that actually answered — a failed fetch leaves
/// every stamp alone, so the prior value ages out on its own TTL and the
/// next identify retries.
pub fn record_gateway_caps(
&self,
peer_id: PeerId,
capable: bool,
redeems: Vec<RedeemClaim>,
) {
self.record_gateway_caps_at(peer_id, capable, redeems, Instant::now());
}
fn record_gateway_caps_at(
&self,
peer_id: PeerId,
capable: bool,
redeems: Vec<RedeemClaim>,
now: Instant,
) {
let mut map = self.0.write().expect("peer directory poisoned");
let entry = map.entry(peer_id).or_default();
entry.nzb_fetch_at = capable.then_some(now);
entry.redeems = redeems;
entry.caps_fetched_at = Some(now);
}
/// Base URLs of the gateways whose feeders can redeem `key` for `codec` right
/// now — an `nzb-release` indexer host, or a `provider-file` source — from a
/// fresh capability fetch, with a `base_url` to call. The targets for
/// `crate::redeem`. Deduplicated; order unspecified.
pub fn gateways_redeeming(&self, codec: &str, key: &str) -> Vec<String> {
self.gateways_redeeming_at(codec, key, Instant::now())
}
fn gateways_redeeming_at(&self, codec: &str, key: &str, now: Instant) -> Vec<String> {
let map = self.0.read().expect("peer directory poisoned");
let mut out: Vec<String> = Vec::new();
for info in map.values() {
let fresh = info
.caps_fetched_at
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
let Some(url) = info.base_url.as_deref() else {
continue;
};
if fresh && info.redeems.iter().any(|c| c.covers(codec, key)) {
let url = url.trim_end_matches('/').to_string();
if !out.contains(&url) {
out.push(url);
}
}
}
out
}
/// Has a capability fetch against *any* reachable gateway completed recently?
///
/// Tells "no gateway on this swarm redeems that" — a statement worth a `400`
/// — apart from "we haven't found a gateway yet", which is a warming state
/// worth a retryable `503`.
pub fn knows_a_gateway(&self) -> bool {
self.knows_a_gateway_at(Instant::now())
}
fn knows_a_gateway_at(&self, now: Instant) -> bool {
let map = self.0.read().expect("peer directory poisoned");
map.values().any(|info| {
info.base_url.is_some()
&& info
.caps_fetched_at
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL)
})
}
/// Can any gateway on this swarm redeem a release from `indexer_host` right
/// now — a *fresh* `nzbFetch` capability on a gateway whose feeders claim
/// that host?
///
/// The availability predicate behind `/api/file/:cid/probe` for a Usenet cid.
/// It costs nothing: the host comes out of the cid's own multihash and the
/// claims come from the directory (refreshed on identify, 600 s TTL). Probing
/// a Usenet title must never touch NNTP — `bridge::ensure` downloads the
/// *whole* posting, so a "free" availability check used to start one full
/// download per probed variant.
pub fn gateway_can_redeem(&self, indexer_host: &str) -> bool {
self.gateway_can_redeem_at(indexer_host, Instant::now())
}
fn gateway_can_redeem_at(&self, indexer_host: &str, now: Instant) -> bool {
let map = self.0.read().expect("peer directory poisoned");
map.values().any(|info| {
let fresh = info
.nzb_fetch_at
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
fresh
&& info.base_url.is_some()
&& info.redeems.iter().any(|c| c.covers(REDEEM_NZB_RELEASE, indexer_host))
})
}
/// Can any gateway on this swarm fetch from Usenet **at all**, regardless of
/// which indexers it holds keys for?
///
/// This is [`gateway_can_redeem`](Self::gateway_can_redeem) minus the
/// indexer-host match, and it is the right question for a **self-scanned
/// `nzb-posting` (`0x1003`)** cid: that cid is a digest over the article
/// Message-ID set and names no indexer, so a plain NNTP provider is the only
/// thing needed to fetch it. Asking the host-scoped question about a posting
/// would answer `false` for every gateway — including ones that can serve it
/// perfectly well — and the title would grade `red` and never render.
pub fn gateway_can_fetch_nzb(&self) -> bool {
self.gateway_can_fetch_nzb_at(Instant::now())
}
fn gateway_can_fetch_nzb_at(&self, now: Instant) -> bool {
let map = self.0.read().expect("peer directory poisoned");
map.values().any(|info| {
info.nzb_fetch_at
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL)
&& info.base_url.is_some()
})
}
/// Whether the swarm task should (re)fetch this peer's gateway capabilities.
/// True when we've never completed a fetch, or the last one is older than
/// `refresh_after`. Debounces the per-identify fetch so a burst of identify
/// events (one per connection) doesn't spawn redundant HTTP calls.
pub fn needs_cap_refresh(&self, peer_id: &PeerId, refresh_after: Duration) -> bool {
self.needs_cap_refresh_at(peer_id, refresh_after, Instant::now())
}
fn needs_cap_refresh_at(&self, peer_id: &PeerId, refresh_after: Duration, now: Instant) -> bool {
let map = self.0.read().expect("peer directory poisoned");
match map.get(peer_id).and_then(|info| info.caps_fetched_at) {
None => true,
Some(at) => now.saturating_duration_since(at) > refresh_after,
}
}
/// Gateways that can serve `nzb-release` bytes right now: peers with a
/// *fresh* `nzbFetch` capability **and** an HTTP `base_url` to proxy to.
/// Returns `(peer_id, base_url)` — the forward-target list for the Usenet
/// byte path.
pub fn nzb_fetch_gateways(&self) -> Vec<(PeerId, String)> {
self.nzb_fetch_gateways_at(Instant::now())
}
fn nzb_fetch_gateways_at(&self, now: Instant) -> Vec<(PeerId, String)> {
let map = self.0.read().expect("peer directory poisoned");
map.iter()
.filter_map(|(pid, info)| {
let fresh = info
.nzb_fetch_at
.is_some_and(|t| now.saturating_duration_since(t) <= IDENTIFY_TTL);
match (fresh, &info.base_url) {
(true, Some(url)) => Some((*pid, url.clone())),
_ => None,
}
})
.collect()
}
/// Drop all state for `peer_id` (base URL + gateway capabilities). Called
/// by the swarm task on the *last* `ConnectionClosed` for a peer. Returns
/// `true` when an entry was actually present.
/// The whole directory as the hull's replica wants it: freshness as ages,
/// claims as the JSON the gateway served. `GET /ipfs-tier/directory`.
pub fn snapshot(&self) -> Vec<meta_feeder_sdk::transport::ipfs::DirectoryPeer> {
let now = Instant::now();
let age = |t: Option<Instant>| t.map(|t| now.saturating_duration_since(t).as_millis() as u64);
self.0
.read()
.expect("peer directory poisoned")
.iter()
.map(|(pid, info)| meta_feeder_sdk::transport::ipfs::DirectoryPeer {
peer_id: pid.to_string(),
base_url: info.base_url.clone(),
nzb_fetch_age_ms: age(info.nzb_fetch_at),
caps_age_ms: age(info.caps_fetched_at),
redeems: info
.redeems
.iter()
.map(|c| {
serde_json::json!({
"codec": c.codec, "field": c.field,
"hosts": c.hosts, "sources": c.sources,
})
})
.collect(),
})
.collect()
}
pub fn forget_peer(&self, peer_id: &PeerId) -> bool {
self.0
.write()
.expect("peer directory poisoned")
.remove(peer_id)
.is_some()
}
}
/// What changed on a `PeerDirectory::upsert_from_identify` call. `None`
/// on a no-op refresh (peer re-identified with the same data). Used by
/// the swarm task to emit per-field debug logs only when something
/// actually moved.
#[derive(Default, Debug)]
pub struct PeerInfoUpdate {
pub base_url_changed: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn pid_n(n: u8) -> PeerId {
// Deterministic peer-ids from a seed keypair — we just need
// distinct values, not real keypair material.
let kp = libp2p::identity::Keypair::ed25519_from_bytes([n; 32]).expect("keypair");
PeerId::from_public_key(&kp.public())
}
#[test]
fn a_gateway_only_redeems_the_indexers_it_advertises() {
// The Usenet availability predicate: an nzb-release cid names its indexer
// host, and only a gateway holding a key for THAT host can play it. Answered
// from the directory — no NNTP, no .nzb grab, no download.
let dir = PeerDirectory::new();
let gw = PeerId::random();
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
dir.record_gateway_caps(gw, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
assert!(dir.gateway_can_redeem("api.nzb.life"));
// A release from an indexer nobody has a key for cannot play → red.
assert!(!dir.gateway_can_redeem("api.other.example"));
}
#[test]
fn a_gateway_that_lost_its_credentials_redeems_nothing() {
// nzbFetch:false withdraws the capability, so a gateway whose meta-share
// dropped its NNTP credentials stops grading releases playable — even
// though its feeder still claims the host, no pool behind it can fetch
// the articles.
let dir = PeerDirectory::new();
let gw = PeerId::random();
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
dir.record_gateway_caps(gw, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
assert!(dir.gateway_can_redeem("api.nzb.life"));
dir.record_gateway_caps(gw, false, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
assert!(!dir.gateway_can_redeem("api.nzb.life"));
}
/// ⚠ THE SELF-SCANNED-POSTING PROBE. `gateway_can_fetch_nzb` must answer
/// `true` for a gateway that advertises Usenet fetch capability with **no**
/// indexer hosts at all — a self-hosted-scanner gateway has none to
/// advertise, since a `0x1003` posting names no indexer. If this regresses
/// to requiring an `nzb-release` redeem claim, self-scanned titles grade
/// red and never render even though the gateway can serve them fine.
#[test]
fn any_gateway_with_nzb_capability_answers_the_posting_probe() {
let dir = PeerDirectory::new();
let gw = PeerId::random();
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
// Note: no redeem claims — this gateway holds no third-party keys,
// exactly the shape a pure self-hosted-scanner gateway has.
dir.record_gateway_caps(gw, true, vec![]);
assert!(dir.gateway_can_fetch_nzb());
// But it correctly still cannot redeem a HOST-BOUND locator it has no
// key for — the two questions must stay distinct.
assert!(!dir.gateway_can_redeem("api.nzb.life"));
}
#[test]
fn no_gateway_advertising_nzb_capability_fails_the_posting_probe() {
let dir = PeerDirectory::new();
let gw = PeerId::random();
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
// Never called record_gateway_caps — no capability advertised at all.
assert!(!dir.gateway_can_fetch_nzb());
}
#[test]
fn a_gateway_that_lost_nzb_capability_fails_the_posting_probe_too() {
let dir = PeerDirectory::new();
let gw = PeerId::random();
dir.upsert_from_identify(gw, Some("http://gw:3000".into()));
dir.record_gateway_caps(gw, true, vec![]);
assert!(dir.gateway_can_fetch_nzb());
dir.record_gateway_caps(gw, false, vec![]);
assert!(!dir.gateway_can_fetch_nzb());
}
fn claim(codec: &str, hosts: &[&str], sources: &[&str]) -> RedeemClaim {
RedeemClaim {
codec: codec.into(),
field: "file".into(),
hosts: hosts.iter().map(|s| s.to_string()).collect(),
sources: sources.iter().map(|s| s.to_string()).collect(),
}
}
/// Redeem targets come from claims, not from `nzbFetch`: redeeming a locator
/// needs the gateway's feeder, and a gateway with no NNTP pool behind it can
/// still grab a `.nzb` or download a subtitle.
#[test]
fn redeem_targets_are_the_gateways_whose_claims_cover_the_key() {
use crate::gateway_discovery::{REDEEM_NZB_RELEASE, REDEEM_PROVIDER_FILE};
let dir = PeerDirectory::new();
let subs = pid_n(30);
let usenet = pid_n(31);
let no_url = pid_n(32);
dir.upsert_from_identify(subs, Some("http://subs:3000/".into()));
dir.upsert_from_identify(usenet, Some("http://usenet:3000".into()));
dir.record_gateway_caps(subs, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])]);
dir.record_gateway_caps(usenet, false, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
// Claims, but nowhere to POST to.
dir.record_gateway_caps(no_url, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])]);
assert_eq!(
dir.gateways_redeeming(REDEEM_PROVIDER_FILE, "opensubtitles"),
vec!["http://subs:3000".to_string()]
);
assert_eq!(
dir.gateways_redeeming(REDEEM_NZB_RELEASE, "api.nzb.life"),
vec!["http://usenet:3000".to_string()]
);
assert!(dir.gateways_redeeming(REDEEM_NZB_RELEASE, "api.other.example").is_empty());
assert!(dir.gateways_redeeming(REDEEM_PROVIDER_FILE, "subscene").is_empty());
}
#[test]
fn redeem_claims_and_gateway_knowledge_expire_with_the_caps_fetch() {
use crate::gateway_discovery::REDEEM_PROVIDER_FILE;
let dir = PeerDirectory::new();
let p = pid_n(33);
let t0 = Instant::now();
assert!(!dir.knows_a_gateway_at(t0), "empty directory knows nothing");
dir.upsert_from_identify(p, Some("http://gw:9".into()));
dir.record_gateway_caps_at(p, false, vec![claim(REDEEM_PROVIDER_FILE, &[], &["opensubtitles"])], t0);
assert!(dir.knows_a_gateway_at(t0 + IDENTIFY_TTL));
assert_eq!(dir.gateways_redeeming_at(REDEEM_PROVIDER_FILE, "opensubtitles", t0 + IDENTIFY_TTL).len(), 1);
let late = t0 + IDENTIFY_TTL + Duration::from_secs(1);
assert!(!dir.knows_a_gateway_at(late));
assert!(dir.gateways_redeeming_at(REDEEM_PROVIDER_FILE, "opensubtitles", late).is_empty());
}
#[test]
fn upsert_records_base_url() {
let dir = PeerDirectory::new();
let p = pid_n(1);
let u = dir.upsert_from_identify(p, Some("http://gateway-1:9".into()));
assert_eq!(u.base_url_changed.as_deref(), Some("http://gateway-1:9"));
assert_eq!(dir.base_url(&p).as_deref(), Some("http://gateway-1:9"));
// No-op refresh.
let u2 = dir.upsert_from_identify(p, Some("http://gateway-1:9".into()));
assert!(u2.base_url_changed.is_none());
}
#[test]
fn upsert_preserves_base_url_on_none() {
let dir = PeerDirectory::new();
let p = pid_n(2);
dir.upsert_from_identify(p, Some("http://x:9".into()));
// A later identify without a baseUrl token keeps the old URL.
let u = dir.upsert_from_identify(p, None);
assert!(u.base_url_changed.is_none());
assert_eq!(dir.base_url(&p).as_deref(), Some("http://x:9"));
}
#[test]
fn forget_peer_removes_all_state() {
let dir = PeerDirectory::new();
let p = pid_n(23);
dir.upsert_from_identify(p, Some("http://g:9".into()));
dir.record_gateway_caps(p, true, vec![claim(REDEEM_NZB_RELEASE, &["api.nzb.life"], &[])]);
assert_eq!(dir.base_url(&p).as_deref(), Some("http://g:9"));
assert_eq!(dir.nzb_fetch_gateways().len(), 1);
assert!(dir.forget_peer(&p), "peer was present");
assert_eq!(dir.base_url(&p), None);
assert!(
dir.nzb_fetch_gateways().is_empty(),
"forget_peer must drop the gateway capability too"
);
assert!(!dir.forget_peer(&p), "second forget is a no-op");
}
#[test]
fn nzb_gateway_listed_only_with_both_capability_and_base_url() {
let dir = PeerDirectory::new();
let with_url = pid_n(3);
let no_url = pid_n(4);
let not_capable = pid_n(5);
dir.upsert_from_identify(with_url, Some("http://gw:9".into()));
dir.record_gateway_caps(with_url, true, vec![]);
// Capable, but advertised no baseUrl — there's nowhere to proxy to.
dir.record_gateway_caps(no_url, true, vec![]);
// Has a URL but declines nzb bytes (no credentialed meta-share behind it).
dir.upsert_from_identify(not_capable, Some("http://plain:9".into()));
dir.record_gateway_caps(not_capable, false, vec![]);
assert_eq!(
dir.nzb_fetch_gateways(),
vec![(with_url, "http://gw:9".to_string())]
);
}
#[test]
fn nzb_capability_expires_after_identify_ttl() {
let dir = PeerDirectory::new();
let p = pid_n(6);
let t0 = Instant::now();
dir.upsert_from_identify(p, Some("http://gw:9".into()));
dir.record_gateway_caps_at(p, true, vec![], t0);
// Still fresh at the TTL boundary...
assert_eq!(dir.nzb_fetch_gateways_at(t0 + IDENTIFY_TTL).len(), 1);
// ...gone just past it. A gateway that stopped identifying must not
// stay a forward target forever.
assert!(dir
.nzb_fetch_gateways_at(t0 + IDENTIFY_TTL + Duration::from_secs(1))
.is_empty());
}
#[test]
fn cap_refresh_debounces_even_when_gateway_reports_not_capable() {
// The trap: `nzbFetch: false` leaves no capability stamp. If the
// debounce keyed off that, every identify (one per connection, then
// every ~5 min) would re-fire the HTTP fetch forever. It must key off
// the *attempt* stamp instead.
let dir = PeerDirectory::new();
let p = pid_n(7);
let refresh = Duration::from_secs(300);
let t0 = Instant::now();
assert!(
dir.needs_cap_refresh_at(&p, refresh, t0),
"never fetched → must fetch"
);
dir.record_gateway_caps_at(p, false, vec![], t0);
assert!(
!dir.needs_cap_refresh_at(&p, refresh, t0 + Duration::from_secs(1)),
"a `false` answer must still debounce the next identify"
);
assert!(
!dir.needs_cap_refresh_at(&p, refresh, t0 + refresh),
"still inside the refresh window"
);
assert!(
dir.needs_cap_refresh_at(&p, refresh, t0 + refresh + Duration::from_secs(1)),
"past the refresh window → re-fetch"
);
}
}