From a0028938f69f6960abf6fb52a49a7c1b10cc2b7c Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:22:38 +0200 Subject: [PATCH 1/5] feat(p2p): add WSS bootstrap addresses and browser DHT diagnostics Add /tcp/443/wss/ addresses for all 4 IPFS bootstrap nodes in the Rust DEFAULT_BOOTSTRAP_NODES so the host publishes provider records to peers reachable by browsers via WebSocket. Add bootstrap connection diagnostic logging in the browser transport: peer:connect/disconnect events for bootstrap peers, 15s health check summary. This makes it visible when the browser's DHT routing table stays empty. --- packages/rs/cairn-p2p/src/transport/swarm.rs | 4 ++ .../ts/cairn-p2p/src/transport/libp2p-node.ts | 52 +++++++++++++++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/rs/cairn-p2p/src/transport/swarm.rs b/packages/rs/cairn-p2p/src/transport/swarm.rs index 5c9609b..342ccb9 100644 --- a/packages/rs/cairn-p2p/src/transport/swarm.rs +++ b/packages/rs/cairn-p2p/src/transport/swarm.rs @@ -539,15 +539,19 @@ pub const DEFAULT_BOOTSTRAP_NODES: &[&str] = &[ // sv15 — San Jose "/dns/sv15.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", "/dns/sv15.bootstrap.libp2p.io/tcp/4001/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dns/sv15.bootstrap.libp2p.io/tcp/443/wss/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", // ny5 — New York "/dns/ny5.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", "/dns/ny5.bootstrap.libp2p.io/tcp/4001/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", + "/dns/ny5.bootstrap.libp2p.io/tcp/443/wss/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", // am6 — Amsterdam "/dns/am6.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", "/dns/am6.bootstrap.libp2p.io/tcp/4001/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", + "/dns/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", // sg1 — Singapore "/dns/sg1.bootstrap.libp2p.io/udp/4001/quic-v1/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", "/dns/sg1.bootstrap.libp2p.io/tcp/4001/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", + "/dns/sg1.bootstrap.libp2p.io/tcp/443/wss/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", ]; /// Extract the PeerId from the last `/p2p/` component of a multiaddr. diff --git a/packages/ts/cairn-p2p/src/transport/libp2p-node.ts b/packages/ts/cairn-p2p/src/transport/libp2p-node.ts index ea3d77b..734b379 100644 --- a/packages/ts/cairn-p2p/src/transport/libp2p-node.ts +++ b/packages/ts/cairn-p2p/src/transport/libp2p-node.ts @@ -142,6 +142,7 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise = {}; const peerDiscovery: unknown[] = []; + const bootstrapPeerIds = new Set(); if (!isNodeEnvironment()) { const { identify } = await import('@libp2p/identify'); services.identify = identify(); @@ -156,15 +157,21 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise 0) { + let connectedBootstrap = 0; + node.addEventListener('peer:connect', (evt: any) => { + const peerId = evt.detail?.toString?.() ?? String(evt.detail); + if (bootstrapPeerIds.has(peerId)) { + connectedBootstrap++; + console.log(`[cairn] Bootstrap peer connected: ${peerId.slice(0, 16)}... (${connectedBootstrap}/${bootstrapPeerIds.size})`); + } + }); + node.addEventListener('peer:disconnect', (evt: any) => { + const peerId = evt.detail?.toString?.() ?? String(evt.detail); + if (bootstrapPeerIds.has(peerId)) { + connectedBootstrap = Math.max(0, connectedBootstrap - 1); + console.warn(`[cairn] Bootstrap peer disconnected: ${peerId.slice(0, 16)}... (${connectedBootstrap}/${bootstrapPeerIds.size} remaining)`); + } + }); + // Log a summary after a short delay to catch early failures. + setTimeout(() => { + if (connectedBootstrap === 0) { + console.warn('[cairn] No bootstrap peers connected after 15s — DHT discovery will not work'); + } else { + console.log(`[cairn] DHT bootstrap healthy: ${connectedBootstrap}/${bootstrapPeerIds.size} peers connected`); + } + }, 15_000); + } + return node; } From 274e0dfc0ef31ef147ce9ec5d1d46c593bebab9b Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:25:32 +0200 Subject: [PATCH 2/5] feat(config): add listen_addresses option to CairnConfig Optional Vec of explicit multiaddr listen addresses. When set, cairn binds only to those addresses instead of 0.0.0.0 on all interfaces. Allows callers to skip unwanted interfaces (e.g., Docker bridges) that slow down startup with mDNS on 25+ interfaces. --- packages/rs/cairn-p2p/src/api/node.rs | 41 +++++++++++++++++---------- packages/rs/cairn-p2p/src/config.rs | 12 ++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/rs/cairn-p2p/src/api/node.rs b/packages/rs/cairn-p2p/src/api/node.rs index 1ea2d1a..aefc8ad 100644 --- a/packages/rs/cairn-p2p/src/api/node.rs +++ b/packages/rs/cairn-p2p/src/api/node.rs @@ -204,21 +204,32 @@ impl ApiNode { ) -> Result<()> { let mut controller = build_swarm(&self.identity, &transport_config).await?; - // Listen on enabled transports with ephemeral ports. - if transport_config.tcp_enabled { - controller - .listen_on("/ip4/0.0.0.0/tcp/0".parse().unwrap()) - .await?; - } - if transport_config.quic_enabled { - controller - .listen_on("/ip4/0.0.0.0/udp/0/quic-v1".parse().unwrap()) - .await?; - } - if transport_config.websocket_enabled { - controller - .listen_on("/ip4/0.0.0.0/tcp/0/ws".parse().unwrap()) - .await?; + // Listen on enabled transports. + if let Some(ref explicit_addrs) = self.config.listen_addresses { + // Caller specified explicit listen addresses — use those only. + for addr_str in explicit_addrs { + let addr: libp2p::Multiaddr = addr_str.parse().map_err(|e| { + CairnError::Transport(format!("invalid listen address '{addr_str}': {e}")) + })?; + controller.listen_on(addr).await?; + } + } else { + // Default: listen on all interfaces with ephemeral ports. + if transport_config.tcp_enabled { + controller + .listen_on("/ip4/0.0.0.0/tcp/0".parse().unwrap()) + .await?; + } + if transport_config.quic_enabled { + controller + .listen_on("/ip4/0.0.0.0/udp/0/quic-v1".parse().unwrap()) + .await?; + } + if transport_config.websocket_enabled { + controller + .listen_on("/ip4/0.0.0.0/tcp/0/ws".parse().unwrap()) + .await?; + } } // Collect initial listen addresses. diff --git a/packages/rs/cairn-p2p/src/config.rs b/packages/rs/cairn-p2p/src/config.rs index 3159f47..072b65a 100644 --- a/packages/rs/cairn-p2p/src/config.rs +++ b/packages/rs/cairn-p2p/src/config.rs @@ -244,6 +244,12 @@ pub struct CairnConfig { pub pairing_password: Option, /// Optional human-readable message attached to pairing requests. pub pairing_message: Option, + /// Optional explicit listen addresses (multiaddr format). + /// When set, cairn listens only on these addresses instead of the default + /// `0.0.0.0` (all interfaces). Useful for skipping unwanted interfaces + /// (e.g., Docker bridges) that slow down startup. + /// Example: `["/ip4/192.168.1.10/tcp/0", "/ip4/192.168.1.10/udp/0/quic-v1"]` + pub listen_addresses: Option>, } /// PIN format configuration. @@ -306,6 +312,7 @@ impl Default for CairnConfig { auto_approve_pairing: false, pairing_password: None, pairing_message: None, + listen_addresses: None, } } } @@ -481,6 +488,11 @@ impl CairnConfigBuilder { self } + pub fn listen_addresses(mut self, addrs: Vec) -> Self { + self.config.listen_addresses = Some(addrs); + self + } + pub fn manifest_config(mut self, config: ManifestConfig) -> Self { self.config.manifest_config = Some(config); self From f64009b0e0ef41a323b5c775de29b03aa29d1b64 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:31:32 +0200 Subject: [PATCH 3/5] feat(bootstrap): add Kademlia DHT bootstrap node service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight cairn-p2p node that listens on TCP, QUIC, and WebSocket, participates in the Kademlia DHT, and bridges the TCP↔WSS overlay gap. Provider records published by native hosts (TCP/QUIC) become discoverable by browser clients (WSS) through this bootstrap node. Uses Ed25519 keys for universal client compatibility. --- Cargo.toml | 2 +- services/bootstrap/Cargo.toml | 14 +++++ services/bootstrap/Dockerfile | 10 +++ services/bootstrap/src/main.rs | 109 +++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 services/bootstrap/Cargo.toml create mode 100644 services/bootstrap/Dockerfile create mode 100644 services/bootstrap/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 58b293a..0b5856e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["packages/rs/cairn-p2p", "services/signaling", "services/relay", "conformance/runners/rust-runner"] +members = ["packages/rs/cairn-p2p", "services/signaling", "services/relay", "services/bootstrap", "conformance/runners/rust-runner"] exclude = ["demo/messaging/rust", "demo/folder-sync/rust", "demo/server-node", "tools/gen-vectors", "tools/gen-vectors-extra"] resolver = "2" diff --git a/services/bootstrap/Cargo.toml b/services/bootstrap/Cargo.toml new file mode 100644 index 0000000..94a5b26 --- /dev/null +++ b/services/bootstrap/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cairn-bootstrap" +version = "0.1.0" +description = "Lightweight Kademlia DHT bootstrap node for cairn P2P" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +cairn-p2p = { path = "../../packages/rs/cairn-p2p" } +clap.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/services/bootstrap/Dockerfile b/services/bootstrap/Dockerfile new file mode 100644 index 0000000..77662a3 --- /dev/null +++ b/services/bootstrap/Dockerfile @@ -0,0 +1,10 @@ +FROM rust:1.82 AS builder +WORKDIR /build +COPY . . +RUN cargo build --release -p cairn-bootstrap + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /build/target/release/cairn-bootstrap /usr/local/bin/ +EXPOSE 4001/tcp 4001/udp 4002/tcp +ENTRYPOINT ["cairn-bootstrap"] diff --git a/services/bootstrap/src/main.rs b/services/bootstrap/src/main.rs new file mode 100644 index 0000000..a385cb9 --- /dev/null +++ b/services/bootstrap/src/main.rs @@ -0,0 +1,109 @@ +//! cairn-bootstrap: lightweight Kademlia DHT bootstrap node. +//! +//! Runs a cairn-p2p node that listens on TCP, QUIC, and WebSocket, +//! participates in the Kademlia DHT, and acts as a bootstrap peer for +//! both native (TCP/QUIC) and browser (WSS) clients. +//! +//! This bridges the TCP↔WSS DHT overlay gap: provider records published +//! by native hosts become discoverable by browser clients through this node. + +use clap::Parser; +use tracing::{info, warn}; + +/// cairn-bootstrap: Kademlia DHT bootstrap node +#[derive(Parser, Debug)] +#[command(name = "cairn-bootstrap", about = "DHT bootstrap node for cairn P2P")] +struct Args { + /// TCP listen address + #[arg(long, env = "CAIRN_BOOTSTRAP_TCP", default_value = "0.0.0.0:4001")] + tcp_addr: String, + + /// QUIC (UDP) listen address + #[arg(long, env = "CAIRN_BOOTSTRAP_QUIC", default_value = "0.0.0.0:4001")] + quic_addr: String, + + /// WebSocket listen address + #[arg(long, env = "CAIRN_BOOTSTRAP_WS", default_value = "0.0.0.0:4002")] + ws_addr: String, + + /// Data directory for identity persistence + #[arg(long, env = "CAIRN_BOOTSTRAP_DATA", default_value = ".cairn-bootstrap")] + data_dir: String, + + /// Log level (trace, debug, info, warn, error) + #[arg(long, env = "RUST_LOG", default_value = "info")] + log_level: String, +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| args.log_level.parse().unwrap_or_default()), + ) + .init(); + + info!("cairn-bootstrap starting..."); + + let mut config = cairn_p2p::CairnConfig::default(); + config.server_mode = true; + config.storage_backend = cairn_p2p::StorageBackend::Filesystem { + path: std::path::PathBuf::from(&args.data_dir), + }; + + // Listen on explicit addresses for all three transports. + config.listen_addresses = Some(vec![ + format!("/ip4/{}/tcp/{}", parse_host(&args.tcp_addr), parse_port(&args.tcp_addr)), + format!("/ip4/{}/udp/{}/quic-v1", parse_host(&args.quic_addr), parse_port(&args.quic_addr)), + format!("/ip4/{}/tcp/{}/ws", parse_host(&args.ws_addr), parse_port(&args.ws_addr)), + ]); + + let node = match cairn_p2p::create_and_start_with_config(config).await { + Ok(n) => n, + Err(e) => { + eprintln!("Failed to start cairn node: {e}"); + std::process::exit(1); + } + }; + + let addrs = node.listen_addresses().await; + let peer_id = node + .libp2p_peer_id() + .map(|p| p.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + info!("PeerId: {peer_id}"); + info!("Listening on {} addresses:", addrs.len()); + for addr in &addrs { + info!(" {addr}/p2p/{peer_id}"); + } + info!("Bootstrap node ready — Ctrl+C to stop"); + + // Run forever, processing DHT queries. + loop { + match node.recv_event().await { + Some(cairn_p2p::Event::StateChanged { peer_id, state }) => { + tracing::debug!("Peer {peer_id}: {state:?}"); + } + Some(cairn_p2p::Event::Error { error }) => { + tracing::trace!("Transport error: {error}"); + } + Some(_) => {} + None => { + warn!("Event channel closed, shutting down"); + break; + } + } + } +} + +fn parse_host(addr: &str) -> &str { + addr.rsplit_once(':').map(|(h, _)| h).unwrap_or("0.0.0.0") +} + +fn parse_port(addr: &str) -> &str { + addr.rsplit_once(':').map(|(_, p)| p).unwrap_or("4001") +} From 28fb79545311c4e147858dedacf843bf8d792238 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:02:30 +0200 Subject: [PATCH 4/5] docs(bootstrap): add README and --bootstrap-peers CLI arg --- services/bootstrap/README.md | 45 ++++++++++++++++++++++++++++++++++ services/bootstrap/src/main.rs | 8 ++++++ 2 files changed, 53 insertions(+) create mode 100644 services/bootstrap/README.md diff --git a/services/bootstrap/README.md b/services/bootstrap/README.md new file mode 100644 index 0000000..c6315ab --- /dev/null +++ b/services/bootstrap/README.md @@ -0,0 +1,45 @@ +# cairn-bootstrap + +Lightweight Kademlia DHT bootstrap node for cairn P2P. + +Bridges the TCP/QUIC and WebSocket DHT overlay gap by listening on all three transports and participating in the Kademlia DHT. Provider records published by native hosts (TCP/QUIC) become discoverable by browser clients (WSS) through this node. + +## Usage + +```bash +cairn-bootstrap \ + --tcp-addr 0.0.0.0:4001 \ + --quic-addr 0.0.0.0:4001 \ + --ws-addr 0.0.0.0:4002 \ + --data-dir /var/lib/cairn-bootstrap \ + --bootstrap-peers "/dns/sv15.bootstrap.libp2p.io/tcp/4001/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN" +``` + +## Docker + +```bash +docker build -t cairn-bootstrap -f services/bootstrap/Dockerfile . +docker run -p 4001:4001/tcp -p 4001:4001/udp -p 4002:4002/tcp cairn-bootstrap +``` + +## Configuration + +All options can be set via CLI flags or environment variables: + +| Flag | Env | Default | Description | +|------|-----|---------|-------------| +| `--tcp-addr` | `CAIRN_BOOTSTRAP_TCP` | `0.0.0.0:4001` | TCP listen address | +| `--quic-addr` | `CAIRN_BOOTSTRAP_QUIC` | `0.0.0.0:4001` | QUIC (UDP) listen address | +| `--ws-addr` | `CAIRN_BOOTSTRAP_WS` | `0.0.0.0:4002` | WebSocket listen address | +| `--data-dir` | `CAIRN_BOOTSTRAP_DATA` | `.cairn-bootstrap` | Data directory for identity | +| `--bootstrap-peers` | `CAIRN_BOOTSTRAP_PEERS` | (IPFS defaults) | Additional bootstrap peers (comma-separated multiaddrs) | +| `--log-level` | `RUST_LOG` | `info` | Log level | + +## How it works + +1. Starts a cairn-p2p node with Ed25519 identity +2. Listens on TCP (port 4001), QUIC (port 4001), and WebSocket (port 4002) +3. Joins the Kademlia DHT via IPFS bootstrap nodes (or custom `--bootstrap-peers`) +4. Native hosts publish provider records via TCP/QUIC +5. Browser clients query via WebSocket and find those same records +6. PeerId is printed at startup for use as a bootstrap address diff --git a/services/bootstrap/src/main.rs b/services/bootstrap/src/main.rs index a385cb9..8a07378 100644 --- a/services/bootstrap/src/main.rs +++ b/services/bootstrap/src/main.rs @@ -30,6 +30,10 @@ struct Args { #[arg(long, env = "CAIRN_BOOTSTRAP_DATA", default_value = ".cairn-bootstrap")] data_dir: String, + /// Additional bootstrap peers to connect to (multiaddr format) + #[arg(long, env = "CAIRN_BOOTSTRAP_PEERS", value_delimiter = ',')] + bootstrap_peers: Vec, + /// Log level (trace, debug, info, warn, error) #[arg(long, env = "RUST_LOG", default_value = "info")] log_level: String, @@ -54,6 +58,10 @@ async fn main() { path: std::path::PathBuf::from(&args.data_dir), }; + if !args.bootstrap_peers.is_empty() { + config.bootstrap_nodes = args.bootstrap_peers; + } + // Listen on explicit addresses for all three transports. config.listen_addresses = Some(vec![ format!("/ip4/{}/tcp/{}", parse_host(&args.tcp_addr), parse_port(&args.tcp_addr)), From d76804493e6bf539055866375e419acbcc0423be Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:20:29 +0200 Subject: [PATCH 5/5] style(bootstrap): cargo fmt --- Cargo.lock | 11 +++++++++++ services/bootstrap/src/main.rs | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7756f3b..01b02fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,6 +413,17 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cairn-bootstrap" +version = "0.1.0" +dependencies = [ + "cairn-p2p", + "clap", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cairn-conformance-runner" version = "0.1.0" diff --git a/services/bootstrap/src/main.rs b/services/bootstrap/src/main.rs index 8a07378..72a44dd 100644 --- a/services/bootstrap/src/main.rs +++ b/services/bootstrap/src/main.rs @@ -64,9 +64,21 @@ async fn main() { // Listen on explicit addresses for all three transports. config.listen_addresses = Some(vec![ - format!("/ip4/{}/tcp/{}", parse_host(&args.tcp_addr), parse_port(&args.tcp_addr)), - format!("/ip4/{}/udp/{}/quic-v1", parse_host(&args.quic_addr), parse_port(&args.quic_addr)), - format!("/ip4/{}/tcp/{}/ws", parse_host(&args.ws_addr), parse_port(&args.ws_addr)), + format!( + "/ip4/{}/tcp/{}", + parse_host(&args.tcp_addr), + parse_port(&args.tcp_addr) + ), + format!( + "/ip4/{}/udp/{}/quic-v1", + parse_host(&args.quic_addr), + parse_port(&args.quic_addr) + ), + format!( + "/ip4/{}/tcp/{}/ws", + parse_host(&args.ws_addr), + parse_port(&args.ws_addr) + ), ]); let node = match cairn_p2p::create_and_start_with_config(config).await {