# Iroh JOnTheBeach Workshop Clone https://github.com/n0-computer/iroh-workshop-jonthebeach ``` git clone https://github.com/n0-computer/iroh-workshop-jonthebeach ``` Join https://discord.gg/Ddc38zsW WIFI: `on the beach` # Agenda In this workshop, we'll craft a ~~sleek~~, cross-platform chat application from scratch. Bring your devices—whether Linux, Windows, macOS, ~~or iOS~~ —as we ensure compatibility across the board. With a Rust toolchain and IDE in hand, we'll dive into the nitty-gritty details. ~~Fear not if you're not well-versed in Rust, as I'll provide minimal Python and Swift/iOS examples in a dedicated GitHub repository.~~ Throughout the session, you'll master the art of establishing direct connections using the iroh-net networking library, crafting custom application protocols via QUIC streams. Discover the joy o f simplifying complex networking tasks and gain insights into iroh's utilization of the tailscale DERP protocol for direct connections. Don't miss this opportunity to unlock the secrets of peer-to-peer communication in a fun and interactive setting! --- # Introduction - Who wrote a simple TCP server before? - Who has got experience with rust? - Who has cargo installed? --- # Exercise 1: Direct 1:1 connections We will write a tool similar to https://www.dumbpipe.dev/ that connects two devices anywhere in the world. ## Project setup Clone https://github.com/n0-computer/iroh-workshop-jonthebeach. This will give you the code for all steps. ## Dependecies [Project Setup](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/561d76bffc8b8494401238dc01d206281190af45) ## Connect Creating an endpoint: ```rust let endpoint = MagicEndpoint::builder() .bind(0) .await?; ``` Connecting ```rust const JOTB_ALPN: &[u8] = b"JOTB2024"; let connection = endpoint.connect(addr, JOTB_ALPN).await?; ``` (Works only if the remote accepts MY_ALPN) Opening a stream ```rust let (send, recv) = connection.open_bi().await?; ``` Commit: [Minimal connect](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/264511f140df717ce2d8399c26b33faede092930) Copy stdin to send and recv to stdout ```rust tokio::spawn(copy_to_stdout(remote, recv)); copy_stdin_to(send).await?; ``` Commit: [Copy from stdin and to stdout](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/9616dcd56ab7a7b990845ae971072f7ac0783b39) ## Accept Creating an endpoint: For accept we must provide the set of ALPNs ```rust const JOTB_ALPN: &[u8] = b"JOTB2024"; let endpoint = MagicEndpoint::builder() .alpns(vec![PIPE_ALPN.to_vec()]) .bind(0) .await?; ``` Print ticket: ```rust let addr = endpoint.my_addr().await?; println!("I am {}", addr.node_id); println!("Listening on {:#?}", addr.info); println!("ticket: {}", NodeTicket::new(addr)?); ``` Accept loop: ```rust while let Some(connecting) = endpoint.accept().await { // handle each incoming connection in separate tasks. } ``` [Accept endpoint creation](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/559409e90ce3c171ff8fee4156373ebad2008860) For each accept: ```rust let (remote_node_id, alpn, connection) = magic_endpoint::accept_conn(connecting).await?; let (send, recv) = connection.accept_bi().await?; let author = remote_node_id.to_string(); // Send a greeting to the remote node. send.write_all("hello\n".as_bytes()).await?; // Spawn two tasks to copy data in both directions. tokio::spawn(copy_stdin_to(send)); tokio::spawn(copy_to_stdout(author, recv)); ``` [Handle accept](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/8c0c2d3c51ece0fe915e9cb5c1714151825a9677) ## Polish ```rust let secret_key = get_or_create_secret()?; ``` Allows to specify the secret via an environment variable, to have a stable node id over multiple runs. ```rust wait_for_relay(&endpoint).await?; ``` Wait for the node to figure out it's own relay URL Commit: [Some polish](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/1c49836f6804ae5aeb8114e22db627e0fec6f0e4) ## Let's try it out One terminal ``` cargo run -p pipe1 ``` ``` cargo run -p pipe1 <ticket> ``` ## Use iroh DNS node discovery https://twitter.com/iroh_n0/status/1787847932084928655 on the connect side, I want to *look up* node ids using the default iroh dns server ```rust let discovery = DnsDiscovery::n0_dns(); let endpoint = MagicEndpoint::builder() .secret_key(secret_key) .discovery(Box::new(discovery)) ... ``` on the accept side, I want to *publish* node ids to the default iroh dns server ```rust let discovery = PkarrPublisher::n0_dns(secret_key.clone()); let endpoint = MagicEndpoint::builder() .secret_key(secret_key) .discovery(Box::new(discovery)) ... ``` [Use iroh DNS discovery](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/8220d606409c3b92a40f7b1abf2e7d562ee224e4) ``` cargo run -p pipe2 ``` https://www.diggui.com ## Use pkarr node discovery both use `PkarrNodeDiscovery`. on the connect side, we don't want to publish, so we don't need the secret key. ```rust let discovery = PkarrNodeDiscovery::default(); let endpoint = MagicEndpoint::builder() .secret_key(secret_key) .discovery(Box::new(discovery)) ... ``` on the accept side, we do want to publish, so we do need the secret key ```rust let discovery = PkarrNodeDiscovery::builder() .secret_key(secret_key.clone()) .build()?; let endpoint = MagicEndpoint::builder() .secret_key(secret_key) .discovery(Box::new(discovery)) ... ``` [PKARR discovery](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/3fbf7a270c6f4884a58ce9fbcf7d7758c44a59a1) ``` cargo run -p chat3 ``` ## Publish full addresses, not just relay URL ```rust let discovery = PkarrNodeDiscovery::builder() .secret_key(secret_key.clone()) .include_direct_addresses(true) .build()?; let endpoint = MagicEndpoint::builder() .secret_key(secret_key) .discovery(Box::new(discovery)) ... ``` [Also publish addresses](https://github.com/n0-computer/iroh-workshop-jonthebeach/commit/e18807bfa89b9b8debb50b1cfc4de5c4fcdd2798) ``` cargo run -p chat4 ``` # Exercise 2: Group chat We will write a command line group chat. ## Project setup We need an additional dependency ```toml # iroh gossip protocol iroh-gossip = { version = "0.15" } ``` ## Create the endpoint We need to allow the ALPN for the gossip protocol ```rust let endpoint = MagicEndpoint::builder() .secret_key(secret_key.clone()) .alpns(vec![iroh_gossip::net::GOSSIP_ALPN.to_vec()]) .bind(0) .await?; ``` ## Add the info from the addresses ```rust // add all the info from the tickets to the endpoint let mut ids = Vec::new(); for ticket in &args.tickets { let addr = ticket.node_addr(); endpoint.add_node_addr(addr.clone())?; ids.push(addr.node_id); } let gossip = Gossip::from_endpoint(... ``` ## Handle incoming connections We don't handle incoming connections manually. Instead we just feed them to the gossip. Since we are listening on only one ALPN we don't need to check the ALPN. ```rust while let Some(connecting) = endpoint.accept().await { let gossip = gossip.clone(); tokio::spawn(async move { let (_, _, connection) = magic_endpoint::accept_conn(connecting).await?; gossip.handle_connection(connection).await?; anyhow::Ok(()) }); } ``` ## Print incoming messages to stdout Subscribe the topic and print everything that we get. ```rust let mut stream = gossip.subscribe(topic).await?; while let Ok(event) = stream.recv().await { if let Event::Received(ev) = event { let text = String::from_utf8_lossy(&ev.content); println!("message {}", text); } } ``` ## Read messages from stdin and send them ```rust gossip.join(topic, ids).await?.await?; let mut stdin = BufReader::new(tokio::io::stdin()).lines(); while let Some(line) = stdin.next_line().await? { gossip.broadcast(topic, line.into_bytes().into()).await?; } ``` We got a working chat! ``` cargo run -p chat1 ``` ## A proper protocol We send around signed messages, so we know whom they are from! ```rust #[derive(Debug, Serialize, Deserialize)] enum Message { Message { text: String }, // more message types will be added later } #[derive(Debug, Serialize, Deserialize)] struct SignedMessage { from: PublicKey, data: Vec<u8>, signature: Signature, } impl SignedMessage { pub fn sign_and_encode(secret_key: &SecretKey, message: &Message) -> anyhow::Result<Vec<u8>> { let data = postcard::to_stdvec(&message)?; let signature = secret_key.sign(&data); let from = secret_key.public(); let signed_message = Self { from, data, signature, }; let encoded = postcard::to_stdvec(&signed_message)?; Ok(encoded) } pub fn verify_and_decode(bytes: &[u8]) -> anyhow::Result<(PublicKey, Message)> { let signed_message: Self = postcard::from_bytes(bytes)?; let key = signed_message.from; key.verify(&signed_message.data, &signed_message.signature)?; let message: Message = postcard::from_bytes(&signed_message.data)?; Ok((signed_message.from, message)) } } ``` ## Wiring it up Verify and decode incoming messages. Silently ignore non-verified messages ```rust let Ok((from, msg)) = SignedMessage::verify_and_decode(&ev.content) else { continue; }; ``` Handle the incoming messages (only one message type for now): ```rust= match msg { Message::Message { text } => { println!("{}> {}", from, text); } // more message types will be added later } ``` ``` cargo run -p chat2 `````` cargo run -p chat1 ``` ## Encrypted direct messages Extend the Message enum ```rust enum Message { Message { text: String }, Direct { to: PublicKey, encrypted: Vec<u8> }, } ``` Encryption: Support `/for <publickey> <message>` syntax ```rust let msg = if let Some(private) = line.strip_prefix("/for ") { // yeah yeah, there are nicer ways to do this, sue me... let mut parts = private.splitn(2, ' '); let Some(to) = parts.next() else { continue; }; let Some(msg) = parts.next() else { continue; }; let Ok(to) = PublicKey::from_str(to) else { continue; }; let mut encrypted = msg.as_bytes().to_vec(); // encrypt the data in place secret_key.shared(&to).seal(&mut encrypted); Message::Direct { to, encrypted } } else { ... ``` Decryption: ```rust match msg { ... Message::Direct { to, encrypted } => { if to != secret_key.public() { // not for us return Ok(()); } let mut buffer = encrypted; secret_key.shared(&from).open(&mut buffer)?; let message = std::str::from_utf8(&buffer)?; println!("got encrypted message from {}: {}", from, message); } } ``` ``` cargo run -p chat3 ``` ## Why do duplicate messages get swallowed? https://docs.rs/iroh-gossip/latest/iroh_gossip/net/struct.Gossip.html#method.broadcast Messages with the same content are only delivered once. ```rust struct SignedMessage { uid: u128, // just add a big random number. Crude, but works! from: PublicKey, data: Vec<u8>, signature: Signature, } ``` ``` cargo run -p chat4 ``` ## Homework: support sending files Syntax: `/share <file>` This involves using iroh-bytes and handling two different ALPNs, [GOSSIP_ALPN](https://docs.rs/iroh-gossip/latest/iroh_gossip/net/constant.GOSSIP_ALPN.html) and [iroh_bytes::protocol::ALPN](https://docs.rs/iroh-bytes/latest/iroh_bytes/protocol/constant.ALPN.html) Depending on the incoming ALPN you have to dispatch to gossip or bytes. ## Homework: aliases Syntax: `/alias <alias>` User can define an alias. All receivers of this alias from then on refer to the user just as `<alias>` instead of by node id. This requires changing the code to have common mutable state between send and receive.