Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion fabric/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ impl Announcement {
/// Information about a peer, returned from GET /peers.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeerInfo {
/// The IP address that announced this peer.
/// The IP address that announced this peer. Informational only: a peers
/// list is remote-controlled, so receivers must not trust this field for
/// anything (defaults to the unspecified address when absent).
#[serde(default = "unspecified_ip")]
pub source_ip: IpAddr,
/// The URL where this peer can be reached.
pub url: String,
Expand All @@ -274,6 +277,10 @@ impl PeerInfo {
}
}

fn unspecified_ip() -> IpAddr {
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
}

/// A reverse record mapping a numeric identity to its human-readable name.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReverseRecord {
Expand Down
12 changes: 9 additions & 3 deletions relay/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@ pub async fn run(
));

// Peer-table maintenance: proactive refresh of verified peers, candidate
// verification, and rate-limiter map cleanup
// verification, rate-limiter map cleanup, and standing seed candidates
// (so a fleet-wide restart can't strand a relay with an empty table)
tokio::spawn(crate::sync::run_peer_maintenance_loop(
relay.state().clone(),
std::time::Duration::from_secs(10),
3,
BOOTSTRAP_RELAYS.iter().map(|s| s.to_string()).collect(),
));

// Pull-based propagation: periodically sync stored handles from peers,
Expand All @@ -266,9 +268,13 @@ pub async fn run(
tokio::spawn({
let state = relay.state().clone();
async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(20 * 60));
loop {
interval.tick().await;
// Jittered so a fleet restarted together doesn't hit the
// seeds in lockstep every sweep. (Startup discovery is
// handled by bootstrap() and the maintenance loop's standing
// seed candidates, so no immediate first sweep is needed.)
let jitter = std::time::Duration::from_millis(rand::random_range(0..120_000));
tokio::time::sleep(std::time::Duration::from_secs(20 * 60) + jitter).await;
let mut urls: Vec<String> = {
let peers = state.peers.lock().await;
peers.peers().iter().map(|s| s.to_string()).collect()
Expand Down
41 changes: 39 additions & 2 deletions relay/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,16 @@ impl Handler {
}

// Rate limit per space (100 handle updates/min) and per handle
// (1 per 5 min) — churn only: first insert of a handle is free.
if charge_content_limits && !self.dev_mode && stored.is_some() {
// (1 per 5 min). Charge only cheap off-chain record churn: a
// replacement at the same-or-lower commitment epoch. A first
// insert (stored.is_none()) is free, and so is a commitment
// finalize / new epoch — that advances epoch_height and is
// already gated by on-chain transaction cost, so a daily batch
// finalizing every issued handle is never throttled by space_rate.
if charge_content_limits
&& !self.dev_mode
&& charges_churn(stored.map(|s| s.0), epoch_height)
{
if self.space_rate.check_key(&space).is_err() {
tracing::warn!("{}: space rate limited, skipping", space);
return None;
Expand Down Expand Up @@ -474,3 +482,32 @@ fn epoch_hint_verifiable_by(hint: &resolver::EpochHint, zone: &Zone) -> bool {
false
}
}

/// Whether an ingested record incurs the per-space / per-handle velocity limits.
///
/// Only cheap off-chain record churn is charged: a replacement at the
/// same-or-lower commitment epoch. A first insert (`stored_epoch` is `None`) is
/// free, and so is a commitment finalize / new epoch — that advances the epoch
/// and is already gated by on-chain transaction cost, so a batch finalizing
/// every issued handle is never throttled by `space_rate`.
fn charges_churn(stored_epoch: Option<u32>, incoming_epoch: u32) -> bool {
stored_epoch.is_some_and(|e| incoming_epoch <= e)
}

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

#[test]
fn charges_only_same_or_lower_epoch_churn() {
// First insert of a handle: free.
assert!(!charges_churn(None, 100));
// Commitment finalize / new epoch (epoch advances): free.
assert!(!charges_churn(Some(100), 101));
assert!(!charges_churn(Some(100), 500));
// Off-chain record churn at the same commitment epoch: charged.
assert!(charges_churn(Some(100), 100));
// Defensive: a lower epoch is charged (is_better_than rejects it anyway).
assert!(charges_churn(Some(100), 99));
}
}
8 changes: 8 additions & 0 deletions relay/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,14 @@ pub async fn bootstrap_from(
p.url.len() <= 256
&& crate::peer::validate_peer_url(&p.url, state.allow_private_peers).is_ok()
});
// The claimed source_ip in a peers list is remote-controlled and
// unverifiable — coerce it to the unspecified address before ANY use
// (including the returned vec) so it can never claim an IP slot or
// displace entries attributed to real client IPs (see
// PeerTable::announce).
for peer in &mut peers {
peer.source_ip = std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
}

// Add discovered peers to our table
{
Expand Down
Loading
Loading