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/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/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 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; } 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/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 new file mode 100644 index 0000000..72a44dd --- /dev/null +++ b/services/bootstrap/src/main.rs @@ -0,0 +1,129 @@ +//! 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, + + /// 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, +} + +#[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), + }; + + 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) + ), + 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") +}