Skip to content
Open
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
168 changes: 168 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/gitlawb-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }

[dev-dependencies]
mockito = "1"
Expand Down
78 changes: 71 additions & 7 deletions crates/gitlawb-node/src/api/peers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ pub async fn announce(
));
}

// Reject self-announcements: a peer row whose http_url is our own public
// URL makes the HTTP-notify path fan out to ourselves. Seen in prod when
// misconfigured dev nodes announce with their upstream's URL.
// prune_self_peers clears stale rows at boot; this stops new ones.
if let Some(self_url) = state.config.public_url.as_deref() {
if req.http_url.trim_end_matches('/') == self_url.trim_end_matches('/') {
return Err(AppError::BadRequest(
"http_url is this node's own public URL; refusing to register self as peer".into(),
));
}
}
if announced_did.to_string() == state.node_did.to_string() {
return Err(AppError::BadRequest(
"did is this node's own DID; refusing to register self as peer".into(),
));
}

state.db.upsert_peer(&req.did, &req.http_url).await?;

tracing::info!(did = %req.did, url = %req.http_url, "peer announced");
Expand Down Expand Up @@ -124,16 +141,30 @@ pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_js
continue;
}
let url = format!("{}/api/v1/repos", peer.http_url.trim_end_matches('/'));
let result =
tokio::time::timeout(std::time::Duration::from_secs(5), client.get(&url).send()).await;
// 30s with the body read inside the timeout: 5s only covered the
// response headers, so canonical nodes serving large unpaginated repo
// lists (and transpacific round trips) aborted mid-body.
let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("peer returned status {}", resp.status()));
}
let repos: Vec<serde_json::Value> = resp.json().await?;
Ok::<_, anyhow::Error>(repos)
})
.await;

let repos: Vec<serde_json::Value> = match result {
Ok(Ok(resp)) if resp.status().is_success() => {
let repos = match result {
Ok(Ok(repos)) => {
peers_reached += 1;
resp.json().await.unwrap_or_default()
repos
}
Ok(Err(e)) => {
tracing::warn!(peer = %peer.did, err = %e, "trigger_sync: peer fetch failed");
continue;
}
_ => {
tracing::warn!(peer = %peer.did, "trigger_sync: could not reach peer");
Err(_) => {
tracing::warn!(peer = %peer.did, "trigger_sync: peer timed out");
continue;
Comment on lines +147 to 168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the node-side shared reqwest client construction and any configured request timeouts.
rg -n --type rust -C4 'http_client|reqwest::Client::builder|Client::builder\(|\.timeout\(' crates/gitlawb-node/src

Repository: Gitlawb/node

Length of output: 13556


Timeout diagnostics split likely masked by shared reqwest timeout
crates/gitlawb-node/src/main.rs constructs state.http_client with reqwest::Client::builder().timeout(Duration::from_secs(10)), which will typically make client.get(...).send().await? fail before the outer tokio::time::timeout(30s, ...) can fire—so slow peers will be logged as peer fetch failed (Ok(Err(_))) rather than peer timed out (Err(_)). There’s also an alternate AppState path in crates/gitlawb-node/src/auth/mod.rs that uses reqwest::Client::new() (no explicit timeout), so the outer timeout branch may only be reachable when that state is used for trigger_sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/peers.rs` around lines 147 - 168, The outer
tokio::time::timeout(30s, ...) in trigger_sync is being masked by a shorter
client-level timeout on state.http_client, so slow peers surface as "peer fetch
failed" instead of the intended "peer timed out"; update the request to set a
per-request timeout that is >= the outer timeout (e.g.,
client.get(&url).timeout(std::time::Duration::from_secs(31))) or construct the
Client without a global timeout and rely on the outer tokio::time::timeout;
change the code around client.get(&url).send().await? in trigger_sync (and
ensure state.http_client construction is adjusted if you choose the
global-client approach) so the outer tokio timeout is effective.

}
};
Expand Down Expand Up @@ -183,6 +214,18 @@ pub struct NotifyRequest {
pub ref_name: String,
pub new_sha: String,
pub node_did: String,
// Optional fields — older senders only included the four above. New
// senders include these so received_ref_updates has full provenance
// even when the libp2p mesh isn't delivering and the HTTP fallback
// is the only path that fired.
#[serde(default)]
pub pusher_did: Option<String>,
#[serde(default)]
pub old_sha: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub cert_id: Option<String>,
}

pub async fn notify_sync(
Expand Down Expand Up @@ -218,6 +261,27 @@ pub async fn notify_sync(
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
.await?;

// Mirror the gossipsub-receive handler: insert the same record we'd
// get from the libp2p path, so /api/v1/events/ref-updates reflects
// pushes that arrive over either transport.
let now = chrono::Utc::now().to_rfc3339();
let update = crate::db::ReceivedRefUpdate {
id: uuid::Uuid::new_v4().to_string(),
node_did: req.node_did.clone(),
pusher_did: req.pusher_did.clone().unwrap_or_default(),
repo: req.repo.clone(),
ref_name: req.ref_name.clone(),
old_sha: req.old_sha.clone().unwrap_or_default(),
new_sha: req.new_sha.clone(),
timestamp: req.timestamp.clone().unwrap_or_else(|| now.clone()),
cert_id: req.cert_id.clone(),
received_at: now,
from_peer: format!("http:{}", req.node_did),
};
if let Err(e) = state.db.insert_ref_update(&update).await {
tracing::warn!(err = %e, repo = %req.repo, "failed to insert ref-update from sync notify");
}
Comment on lines +264 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find push fanout code that emits ref updates.
rg -n --type rust -C4 'publish_ref_update\s*\(|sync/notify|insert_ref_update\s*\(' crates

# Inspect readers of received_ref_updates to see whether they dedupe by content/source.
rg -n --type rust -C4 'received_ref_updates|from_peer' crates/gitlawb-node/src

Repository: Gitlawb/node

Length of output: 18423


Prevent duplicate received_ref_updates rows for one logical push across HTTP notify + gossipsub

  • Both the gossipsub receive path (crates/gitlawb-node/src/p2p/mod.rs) and the HTTP handler (crates/gitlawb-node/src/api/peers.rs) insert a ReceivedRefUpdate using a fresh id: Uuid::new_v4().
  • received_ref_updates only dedupes on id (PRIMARY KEY (id) and INSERT ... ON CONFLICT(id) DO NOTHING), so two different UUIDs will both persist; there’s no reader-side dedupe in list_ref_updates.
  • As a result, a single logical ref update can persist twice when a peer receives both the gossipsub delivery and the /api/v1/sync/notify fallback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gitlawb-node/src/api/peers.rs` around lines 264 - 283, Currently both
the gossipsub path (crates::gitlawb_node::p2p::mod::/* handler */) and the HTTP
handler in api::peers.rs create ReceivedRefUpdate rows with fresh UUIDs, so the
DB dedupe on id fails and the same logical push can be stored twice; change id
generation so both paths produce the same deterministic id for the same logical
update (instead of Uuid::new_v4()). Implement a stable ID function (called from
both places) that computes a collision-resistant id from the canonical fields of
ReceivedRefUpdate (e.g., repo, ref_name, new_sha, old_sha, pusher_did,
timestamp) using a stable hash/UUID v5 strategy, then use that id when
constructing ReceivedRefUpdate before calling state.db.insert_ref_update; update
both the gossipsub handler and api::peers.rs to call this new id generator and
ensure insert_ref_update behavior remains unchanged.


tracing::info!(
repo = %req.repo,
peer = %req.node_did,
Expand Down
9 changes: 8 additions & 1 deletion crates/gitlawb-node/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,14 @@ impl Db {
"WITH deduped AS (
SELECT DISTINCT ON (split_part(owner_did, ':', -1), name)
id, name, owner_did, description, is_public, default_branch,
created_at, updated_at, disk_path, forked_from, machine_id
created_at,
-- group MAX, not the canonical row's own value: pushes that
-- arrive via gossip touch only the mirror row, so the
-- canonical updated_at goes stale
MAX(updated_at) OVER (
PARTITION BY split_part(owner_did, ':', -1), name
) AS updated_at,
disk_path, forked_from, machine_id
FROM repos
WHERE ($1::text IS NULL OR owner_did = $1 OR owner_did LIKE '%:' || $1)
ORDER BY split_part(owner_did, ':', -1), name,
Expand Down
Loading
Loading