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-web3summit.

This will give you the code for all steps.

Connect

Creating an endpoint:

let endpoint = Endpoint::builder()
    .bind(0)
    .await?;

Connecting

const WEB3_ALPN: &[u8] = b"WEB3_2024";
let connection = endpoint.connect(addr, WEB3_ALPN).await?;

(Works only if the remote accepts WEB3_ALPN)

Opening a stream

let (send, recv) = connection.open_bi().await?;

Copy stdin to send and recv to stdout

    tokio::spawn(copy_to_stdout(remote, recv));
    copy_stdin_to(send).await?;

Accept

Creating an endpoint:

For accept we must provide the set of ALPNs

const WEB3_ALPN: &[u8] = b"WEB3_2024";
let endpoint = Endpoint::builder()
    .alpns(vec![WEB3_ALPN.to_vec()])
    .bind(0)
    .await?;

Print ticket:

let addr = endpoint.node_addr().await?;
println!("I am {}", addr.node_id);
println!("Listening on {:#?}", addr.info);
println!("ticket: {}", NodeTicket::new(addr)?);

Accept loop:

while let Some(connecting) = endpoint.accept().await {
    // handle each incoming connection in separate tasks.
}

For each accept:

let alpn = connecting.alpn().await?;
let connection = connecting.await?;
let remote_node_id = endpoint::get_remote_node_id(&connection)?;
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));

Polish

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.

wait_for_relay(&endpoint).await?;

Wait for the node to figure out it's own relay URL

Let's try it out

One terminal

cargo run -p pipe1
cargo run -p pipe1 

Use iroh DNS node discovery

https://www.iroh.computer/blog/iroh-dns

on the connect side, I want to look up node ids using the default iroh dns server

let discovery = DnsDiscovery::n0_dns();
let endpoint = Endpoint::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

let discovery = PkarrPublisher::n0_dns(secret_key.clone());
let endpoint = Endpoint::builder()
    .secret_key(secret_key)
    .discovery(Box::new(discovery))
    ...

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 = Endpoint::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

let discovery = PkarrNodeDiscovery::builder()
    .secret_key(secret_key.clone())
    .build()?;
let endpoint = Endpoint::builder()
    .secret_key(secret_key)
    .discovery(Box::new(discovery))
    ...
cargo run -p pipe3

Publish full addresses, not just relay URL

let discovery = PkarrNodeDiscovery::builder()
    .secret_key(secret_key.clone())
    .include_direct_addresses(true)
    .build()?;
let endpoint = Endpoint::builder()
    .secret_key(secret_key)
    .discovery(Box::new(discovery))
    ...
cargo run -p pipe4

Exercise 2: Group chat

We will write a command line group chat.

Project setup

We need an additional dependency

# iroh crate
iroh = { version = "0.22" }

Create the iroh node

We use an in-memory node for the example

// create a new Iroh node, giving it the secret key
let iroh = iroh::node::Node::memory()
    .secret_key(secret_key)
    .spawn()
    .await?;

Add the info from the addresses

    let mut bootstrap = Vec::new();
    for ticket in &args.tickets {
        let addr = ticket.node_addr();
        iroh.endpoint().add_node_addr(addr.clone()).ok();
        bootstrap.push(addr.node_id);
    }

Subscribe to a hardcoded topic with the collected bootstrap nodes

    // hardcoded topic
    let topic = [0u8; 32];
    // subscribe to the topic, giving the bootstrap nodes
    // if the tickets contained additional info, this is available in the address book of the endpoint
    let (mut sink, mut stream) = iroh.gossip().subscribe(topic, bootstrap).await?;

Send stdin to gossip

    line = stdin.next_line() => {
        if let Ok(Some(line)) = line {
            // got a line from stdin
            match parse_as_command(line).await {
                Ok(cmd) => {
                    if let Some(cmd) = cmd {
                        sink.send(cmd).await?;
                    }
                }
                Err(cause) => {
                    tracing::warn!("error parsing command: {}", cause);
                }
            }
        }
    }
}
async fn parse_as_command(text: String) -> anyhow::Result<Option<Command>> {
    let cmd = Command::Broadcast(text.as_bytes().to_vec().into());
    Ok(Some(cmd))
}
select! {
    message = stream.next() => {
        // got a message from the gossip network
        if let Some(Ok(event)) = message {
            if let Err(cause) = handle_event(event).await {
                tracing::warn!("error handling message: {}", cause);
            }
        } else {
            break;
        }
    }
async fn handle_event(event: Event) -> anyhow::Result<()> {
    if let Event::Gossip(GossipEvent::Received(msg)) = event {
        println!(
            "Received message from node {}: {:?}",
            msg.delivered_from, msg.content
        );
    } else {
        tracing::info!("Got other event: {:?}", event);
    }
    Ok(())
}

We got a working chat!

cargo run -p chat1

A proper protocol

We send around signed messages, so we know whom they are from!

#[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

    let msg = Message::Message { text };
    let signed = SignedMessage::sign_and_encode(secret_key, &msg)?;
    let cmd = Command::Broadcast(signed.into());
}

Handle the incoming messages (only one message type for now):

        let Ok((from, msg)) = SignedMessage::verify_and_decode(&msg.content) else {
            tracing::warn!("Failed to verify message: {:?}", msg.content);
            return Ok(());
        };
cargo run -p chat2

Encrypted direct messages

Extend the Message enum

enum Message {
    Message { text: String },
    Direct { to: PublicKey, encrypted: Vec<u8> },
    // more message types will be added later
}

Encryption:

Support /for <publickey> <message> syntax

let msg = if let Some(private) = text.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 {
        anyhow::bail!("missing recipient");
    };
    let Some(msg) = parts.next() else {
        anyhow::bail!("missing message");
    };
    let Ok(to) = PublicKey::from_str(to) else {
        anyhow::bail!("invalid recipient");
    };
    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:

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

Homework: support sending files

Syntax:

/share <file>

This involves using iroh-bytes and handling two different ALPNs, GOSSIP_ALPN and iroh_bytes::protocol::ALPN

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.