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
11 changes: 11 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
41 changes: 26 additions & 15 deletions packages/rs/cairn-p2p/src/api/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/rs/cairn-p2p/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ pub struct CairnConfig {
pub pairing_password: Option<String>,
/// Optional human-readable message attached to pairing requests.
pub pairing_message: Option<String>,
/// 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<Vec<String>>,
}

/// PIN format configuration.
Expand Down Expand Up @@ -306,6 +312,7 @@ impl Default for CairnConfig {
auto_approve_pairing: false,
pairing_password: None,
pairing_message: None,
listen_addresses: None,
}
}
}
Expand Down Expand Up @@ -481,6 +488,11 @@ impl CairnConfigBuilder {
self
}

pub fn listen_addresses(mut self, addrs: Vec<String>) -> Self {
self.config.listen_addresses = Some(addrs);
self
}

pub fn manifest_config(mut self, config: ManifestConfig) -> Self {
self.config.manifest_config = Some(config);
self
Expand Down
4 changes: 4 additions & 0 deletions packages/rs/cairn-p2p/src/transport/swarm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<peer_id>` component of a multiaddr.
Expand Down
52 changes: 43 additions & 9 deletions packages/ts/cairn-p2p/src/transport/libp2p-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
// circuit relay and WebRTC) and optionally bootstrap for DHT discovery.
const services: Record<string, unknown> = {};
const peerDiscovery: unknown[] = [];
const bootstrapPeerIds = new Set<string>();
if (!isNodeEnvironment()) {
const { identify } = await import('@libp2p/identify');
services.identify = identify();
Expand All @@ -156,15 +157,21 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
// Required for the browser's Kademlia routing table to have peers to query.
try {
const { bootstrap } = await import('@libp2p/bootstrap');
peerDiscovery.push(bootstrap({
list: [
'/dns/sv15.bootstrap.libp2p.io/tcp/443/wss/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN',
'/dns/ny5.bootstrap.libp2p.io/tcp/443/wss/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa',
'/dns/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb',
'/dns/sg1.bootstrap.libp2p.io/tcp/443/wss/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt',
],
}));
} catch { /* @libp2p/bootstrap not available */ }
const bootstrapList = [
'/dns/sv15.bootstrap.libp2p.io/tcp/443/wss/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN',
'/dns/ny5.bootstrap.libp2p.io/tcp/443/wss/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa',
'/dns/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb',
'/dns/sg1.bootstrap.libp2p.io/tcp/443/wss/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt',
];
for (const addr of bootstrapList) {
const peerId = addr.split('/p2p/')[1];
if (peerId) bootstrapPeerIds.add(peerId);
}
peerDiscovery.push(bootstrap({ list: bootstrapList }));
console.log('[cairn] Bootstrap configured with', bootstrapList.length, 'WSS peers');
} catch (e) {
console.warn('[cairn] Bootstrap module not available:', e);
}
}

const node = await createLibp2p({
Expand All @@ -184,6 +191,33 @@ export async function createCairnNode(options?: CreateNodeOptions): Promise<Libp
},
});

// Diagnostic logging for bootstrap peer connectivity (browser only).
if (bootstrapPeerIds.size > 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;
}

Expand Down
14 changes: 14 additions & 0 deletions services/bootstrap/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions services/bootstrap/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
45 changes: 45 additions & 0 deletions services/bootstrap/README.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading