meta-transport-ipfs 1.0.0: ipfs tier split out of meta-share
Publish image / publish (push) Failing after 14s
Publish image / publish (push) Failing after 14s
This commit is contained in:
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user