From 3fed30e007872b7000e5efafb2c8197e046f23cd Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 12 Jun 2026 19:38:38 +0800 Subject: [PATCH 1/3] fix: backport production fixes from monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that landed on the production node but never made it here: - trigger_sync: 30s timeout with the body read inside it (was 5s covering headers only — large unpaginated repo lists from canonical nodes and transpacific peers aborted mid-body), plus distinct logging for non-2xx vs timeout - announce: reject self-announcements by public URL or own DID; complements the existing boot-time prune_self_peers so self-loop rows never enter the peers table in the first place - list_all_repos_paged: report MAX(updated_at) across each logical repo group instead of the canonical row's own value — gossip pushes touch only the mirror row, so canonical timestamps go stale Monorepo refs: 8c2fe1b, c1dc33e, ef21c75. Co-Authored-By: Claude Fable 5 --- crates/gitlawb-node/src/api/peers.rs | 45 +++++++++++++++++++++++----- crates/gitlawb-node/src/db/mod.rs | 9 +++++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/gitlawb-node/src/api/peers.rs b/crates/gitlawb-node/src/api/peers.rs index 70b3cbc..6ecb52f 100644 --- a/crates/gitlawb-node/src/api/peers.rs +++ b/crates/gitlawb-node/src/api/peers.rs @@ -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"); @@ -124,16 +141,30 @@ pub async fn trigger_sync(State(state): State) -> Result = resp.json().await?; + Ok::<_, anyhow::Error>(repos) + }) + .await; - let repos: Vec = 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; } }; diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 6af5ee8..b00c861 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -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, From 9d769f323b3ea4d790e60f56ff9684fe2e2f9f16 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 12 Jun 2026 19:41:58 +0800 Subject: [PATCH 2/3] fix(p2p): dual-stack QUIC listen + DNS multiaddr dialing for Fly 6PN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backports the monorepo TCP-era p2p fixes (9f875b6, 267d371) adapted to the QUIC transport: - listen on /ip6/:: as well as /ip4/0.0.0.0 — Fly's 6PN inter-app network is IPv6-only (.internal resolves to AAAA records), so without an IPv6 socket peers on the private network can't reach us - wrap the QUIC transport in libp2p-dns so bootstrap multiaddrs can use /dns6/.internal — dialing peers through the public anycast edge breaks the handshake when the proxy closes the connection mid-stream Co-Authored-By: Claude Fable 5 --- Cargo.lock | 168 +++++++++++++++++++++++++++++ crates/gitlawb-node/Cargo.toml | 1 + crates/gitlawb-node/src/p2p/mod.rs | 25 +++-- 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc8e18a..3cde378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2231,6 +2231,30 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -2782,6 +2806,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -3260,6 +3296,7 @@ dependencies = [ "hmac", "http-body-util", "libp2p-core", + "libp2p-dns", "libp2p-gossipsub", "libp2p-identify", "libp2p-identity", @@ -3464,6 +3501,52 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3836,6 +3919,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3987,6 +4083,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "libp2p-dns" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot", + "smallvec", + "tracing", +] + [[package]] name = "libp2p-gossipsub" version = "0.49.4" @@ -4341,6 +4453,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multer" version = "3.1.0" @@ -4671,6 +4800,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -4927,6 +5060,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -5374,6 +5513,12 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.3.1" @@ -6401,6 +6546,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -7178,6 +7329,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -7283,6 +7440,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/gitlawb-node/Cargo.toml b/crates/gitlawb-node/Cargo.toml index 9cc3ba1..5f10ec9 100644 --- a/crates/gitlawb-node/Cargo.toml +++ b/crates/gitlawb-node/Cargo.toml @@ -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" diff --git a/crates/gitlawb-node/src/p2p/mod.rs b/crates/gitlawb-node/src/p2p/mod.rs index 12c0bc6..5a6992b 100644 --- a/crates/gitlawb-node/src/p2p/mod.rs +++ b/crates/gitlawb-node/src/p2p/mod.rs @@ -228,9 +228,14 @@ pub async fn start( gossipsub, identify, }; - let transport = libp2p_quic::tokio::Transport::new(libp2p_quic::Config::new(&local_key)) - .map(|(peer_id, muxer), _| (peer_id, StreamMuxerBox::new(muxer))) - .boxed(); + // DNS wraps QUIC so multiaddrs like /dns6/.internal/udp/…/quic-v1 + // resolve at dial time. On Fly, peer nodes must dial each other over the + // private 6PN network via .internal hostnames — dialing through the + // public anycast edge breaks the handshake (the proxy closes the + // connection mid-stream). + let quic = libp2p_quic::tokio::Transport::new(libp2p_quic::Config::new(&local_key)) + .map(|(peer_id, muxer), _| (peer_id, StreamMuxerBox::new(muxer))); + let transport = libp2p_dns::tokio::Transport::system(quic)?.boxed(); let mut swarm = Swarm::new( transport, behaviour, @@ -242,9 +247,17 @@ pub async fn start( let topic = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC); swarm.behaviour_mut().gossipsub.subscribe(&topic)?; - // Listen - let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/udp/{listen_port}/quic-v1").parse()?; - swarm.listen_on(listen_addr)?; + // Listen on both IPv4 (local/mDNS + any IPv4 dials) and IPv6 (required + // for Fly's 6PN inter-app network — .internal DNS only returns AAAA + // records, so peers dial us via IPv6 and need a matching IPv6 socket). + let v4: Multiaddr = format!("/ip4/0.0.0.0/udp/{listen_port}/quic-v1").parse()?; + if let Err(e) = swarm.listen_on(v4) { + warn!(err = %e, "failed to listen on IPv4"); + } + let v6: Multiaddr = format!("/ip6/::/udp/{listen_port}/quic-v1").parse()?; + if let Err(e) = swarm.listen_on(v6) { + warn!(err = %e, "failed to listen on IPv6"); + } // Bootstrap Kademlia with known peers for addr in bootstrap_addrs { From ad27ca9ea75e8f616eb4eaa05c156d95fa1cb722 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 12 Jun 2026 19:45:00 +0800 Subject: [PATCH 3/3] fix(sync): HTTP notify records received_ref_updates like the gossip path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A push propagated via the /api/v1/sync/notify HTTP fallback replicated the repo but left no trace in received_ref_updates — only the libp2p gossip path recorded it, so /api/v1/events/ref-updates went blind whenever the mesh wasn't delivering. Insert the same record from the notify handler, with optional provenance fields (pusher_did, old_sha, timestamp, cert_id) that new senders already include. Monorepo ref: 087eeac. Co-Authored-By: Claude Fable 5 --- crates/gitlawb-node/src/api/peers.rs | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/gitlawb-node/src/api/peers.rs b/crates/gitlawb-node/src/api/peers.rs index 6ecb52f..351c4d6 100644 --- a/crates/gitlawb-node/src/api/peers.rs +++ b/crates/gitlawb-node/src/api/peers.rs @@ -214,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, + #[serde(default)] + pub old_sha: Option, + #[serde(default)] + pub timestamp: Option, + #[serde(default)] + pub cert_id: Option, } pub async fn notify_sync( @@ -249,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"); + } + tracing::info!( repo = %req.repo, peer = %req.node_did,