From ca17fd30a9133aeef511ebdcaa7eb5aa66e96826 Mon Sep 17 00:00:00 2001 From: cblgh Date: Tue, 13 Oct 2026 14:40:06 +0200 Subject: [PATCH 1/3] examples: add allowlist example --- examples/allowlist/README.md | 81 +++++++++ examples/allowlist/allowlist.go | 285 ++++++++++++++++++++++++++++++++ examples/allowlist/go.mod | 7 + examples/allowlist/makefile | 2 + 4 files changed, 375 insertions(+) create mode 100644 examples/allowlist/README.md create mode 100644 examples/allowlist/allowlist.go create mode 100644 examples/allowlist/go.mod create mode 100644 examples/allowlist/makefile diff --git a/examples/allowlist/README.md b/examples/allowlist/README.md new file mode 100644 index 0000000..e8b37ac --- /dev/null +++ b/examples/allowlist/README.md @@ -0,0 +1,81 @@ +# allowlist + +This example demonstrates a time server where the listener endpoint serving the timecan +strictly limit who can connect to it by using an explicit allowlist (listing which endpoints +are allowed to connect, and rejecting all others). + +It also demonstrates how to derive a static identity, simplifying the setup of testing an allowlist. + +## Build a binary + +``` +go build --ldflags '-linkmode external -extldflags=-static -s -w' ./allowlist.go +``` + +If you run into `glibc` incompatibility, you can compile with `zig`/`musl`. + +``` +CC="zig cc -target x86_64-linux-musl -lunwind" \ +CGO_ENABLED=1 \ +CGO_LDFLAGS="-static" \ +GOOS=linux GOARCH=amd64 \ +go build -v -a -ldflags '-extldflags "-static"' ./allowlist.go +``` + +## Terminal 1 (listener) +Start up a time server with a static identity, determined by `-secret`, and only allow a +particular keypair to connect to it, defined by `-allow`. + +``` +./allowlist + -secret + -allow +``` + +For example, passing `-secret hihi` derives a keypair for the listener gives us the +following information we can pass to the receiver in terminal 2: + +``` +-endpoint 608964f7ed721580d2a0a7674da37e79970c4ab60768f27501bc937d9f62c9be +``` + +To get a concrete value for the allowlist, on the receiver side we derive a different identity by passing passing `-secret cool`: + +``` +-allow 2d6577f847d25ddefca000b026fbf95101dc153950ad70dbdc20f15a80d02e07 +``` + +Combined, we get the following that we can run: + +``` +./allowlist + -secret hihi + -allow 2d6577f847d25ddefca000b026fbf95101dc153950ad70dbdc20f15a80d02e07 +``` + + +## Terminal 2 (receiver) + +Attempt to the listener defined by `-endpoint` and derive an identity based on `-secret`. + +``` +./allowlist -endpoint 608964f7ed721580d2a0a7674da37e79970c4ab60768f27501bc937d9f62c9be -secret cool +``` + +Running the above will cause the time server to work as expected, because we derive the correct +identity allowlisted by the listener. If you want to verify that another id will not work, +change `-secret` or omit it completely: + +``` +./allowlist -endpoint 608964f7ed721580d2a0a7674da37e79970c4ab60768f27501bc937d9f62c9be -secret not-cool +``` + +**NOTE**: depending on your circumstances and testing context, you might have use the `-ticket` +information output by the listener to make a connection (includes more information on how to +make a connection). When connecting using a ticket, things look like this instead: + +``` +./allowlist -secret not-cool -ticket endpointxxx.. +``` + +* `xxx..` replaced with actual encoded ticket information, which among other things includes your observable IP addresses. diff --git a/examples/allowlist/allowlist.go b/examples/allowlist/allowlist.go new file mode 100644 index 0000000..5284355 --- /dev/null +++ b/examples/allowlist/allowlist.go @@ -0,0 +1,285 @@ +package main + +import ( + "crypto/sha256" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" +) + +var ( + alpn = []byte("iroh-go-allowlist/0") + frameSize uint32 = 256 + endpointPeer *string + allowedPeer *string + peerSecret *string + ticketArg *string +) + +func awaitInterrupt() { + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + <-done + os.Exit(0) +} + +func parseFlags() { + endpointPeer = flag.String("endpoint", "", "endpoint id of peer") + allowedPeer = flag.String("allow", "", "endpoint id of allowlisted peer") + peerSecret = flag.String("secret", "", "string that will be hashed to construct secret from which to derive iroh keypair") + ticketArg = flag.String("ticket", "", "endpoint ticket address") + flag.Parse() +} + +func receiver(e *iroh.Endpoint, id, ticketStr string) { + var addr *iroh.EndpointAddr + if ticketStr != "" { + ticket, err := iroh.EndpointTicketFromString(ticketStr) + if err := iroh.As(err); err != nil { + panic(err) + } + addr = ticket.EndpointAddr() + } else { + remote, err := iroh.EndpointIdFromString(id) + if err := iroh.As(err); err != nil { + panic(err) + } + addr = iroh.NewEndpointAddr(remote, nil, []string{"127.0.0.1:8992"}) + } + + conn, err := e.Connect(addr, alpn) + if err != nil && errors.Is(err, iroh.BindError) { + fmt.Println("experienced a bind error when dialing endpoint") + os.Exit(1) + } + if err != nil && errors.Is(err, iroh.ConnectError) { + fmt.Println("experienced a connect error when dialing endpoint") + fmt.Println("(expected behaviour for this allowlist example!:)") + os.Exit(1) + } + + if err := iroh.As(err); err != nil { + panic(err) + } + + // from docs: "The peer that calls open_bi must write to its SendStream + // before the peer Connection is able to accept the stream using accept_bi()" + // https://docs.rs/iroh/latest/iroh/endpoint/struct.Connection.html#method.accept_bi + stream, err := conn.OpenBi() + if err := iroh.As(err); err != nil { + panic(err) + } + + // NOTE: for the other peer to realize the bidi stream is open, you have to + // send some data! the data sent cannot be the empty string ""; empty string + // == no data + err = stream.Send().WriteAll([]byte{1}) + if err := iroh.As(err); err != nil { + panic(err) + } + + handleReading(stream.Recv()) +} + +func handleReading(recv *iroh.RecvStream) { + firstLoop := true + for { + frame, err := recv.Read(frameSize) + if err != nil && errors.Is(err, iroh.TimeoutError) { + fmt.Println("lost connection") + break + } + + // NOTE (2026-10-13): when the receiver has a direct connection to the listener (the direct connection mode, not + // via relay), and listener closes the connection during the connecting phase, + // the error we get back does not seem to watch any of the existing iroh-go error sentinels + // (i tried all!) + if err := iroh.As(err); err != nil { + // only want to emit this friendly error message on the first loop of reading, which + // is when we would detect that a direct connection has been closed + if firstLoop { + fmt.Println("experienced an error after having dialed endpoint") + fmt.Println("(very likely expected behaviour for this allowlist example!:)") + os.Exit(1) + } else { + panic(err) + } + } + firstLoop = false + fmt.Println(string(frame)) + } +} + +func listen(e *iroh.Endpoint) { + for { + incoming := *e.AcceptNext() + fmt.Println("new connection") + go handleIncoming(incoming) + } +} + +func isAllowed(remoteId *iroh.EndpointId) bool { + if *allowedPeer == "" { + return true + } + if remoteId.String() != *allowedPeer { + return false + } + return true +} + +func handleIncoming(incoming *iroh.Incoming) { + var raddr iroh.IncomingAddrRelay + v, err := incoming.RemoteAddr() + if err = iroh.As(err); err != nil { + panic(err) + } + raddr, fromRelay := v.(iroh.IncomingAddrRelay) + if fromRelay { + // for reference, here is how the javascript bindings define the interface for value returned by incoming.RemoteAddr() + // + // interface IncomingAddr { + // addr?: string; + // description?: string; + // endpointId?: string; + // kind: string; + // } + // https://n0-computer.github.io/iroh-ffi/js/interfaces/IncomingAddr.html + + // if the connection is indirect (i.e. relayed), we can at this early stage detect the remote party's iroh + // public key and stop any other parts of the connection process from happening. + // at this stage, if detected not to be in the allowlist, we can either silently + // attempt (incoming.Ignore()) or explicitly refuse it (incoming.Refuse()). + // + // Ignore(): the remote party will stay open until a timeout. this has the benefit that there is no information + // leakage (the other party doesn't know that they have been rejected, or that the endpoint they are trying is + // valid). + // + // NOTE: if you choose Ignore, the remote party's connection process may fire off manhy connection attempts + // until finally shutting down to a timeout + // + // Refuse(): the connection attempt is immediately terminated for the remote party. the remote party knows that + // the endpoint they tried is valid, but that they were not granted access. + if !isAllowed(raddr.EndpointId) { + fmt.Printf("[accepting] rejected a connection not in allowlist (%s)\n", raddr.EndpointId) + err = incoming.Refuse() + if err = iroh.As(err); err != nil { + panic(err) + } + return + } + } else { + // a quick check to see the incoming connection is of the other type we expect (i.e. direct IP connection) + _, ok := v.(iroh.IncomingAddrIp) + if !ok { + panic("wasn't IncomingAddrIp either") + } + } + + accepting, err := incoming.Accept() + if err = iroh.As(err); err != nil { + panic(err) + } + + conn, err := accepting.Connect() + if err := iroh.As(err); err != nil { + panic(err) + } + + // if the connection is direct (i.e. not relayed), we can't --- vis-a-vis the current API --- know what the remote party's iroh + // public key is until the connection has been established. + // + // at this stage, if detected not to be in the allowlist, we close the connection with a reason. + if !isAllowed(conn.RemoteId()) { + fmt.Printf("[connecting] rejected a connection not in allowlist (%s)\n", conn.RemoteId()) + err = conn.Close(1, []byte("not in allowlist")) + if err = iroh.As(err); err != nil { + panic(err) + } + return + } + + /* peer is in the allowlist: proceed as normal */ + + stream, err := conn.AcceptBi() + if err := iroh.As(err); err != nil { + panic(err) + } + + for { + err := stream.Send().WriteAll([]byte(time.Now().Format(time.DateTime))) + if err != nil && errors.Is(err, iroh.TimeoutError) { + fmt.Println("lost connection") + break + } + + if err != nil && errors.Is(err, iroh.StreamError) { + fmt.Println("stream error") + break + } + + if err := iroh.As(err); err != nil { + panic(err) + } + + // prints every two seconds (not for any technical reason, 1/sec just feels + // hectic!) + <-time.After(2 * time.Second) + } +} + +func main() { + parseFlags() + isListener := *endpointPeer == "" && *ticketArg == "" + + preset := iroh.PresetN0() + opts := iroh.EndpointOptions{ + Preset: &preset, + Alpns: &[][]byte{alpn}, + } + // if -secret is passed, setup static identity + if *peerSecret != "" { + fmt.Println("deriving static secret based on -secret") + sum := sha256.Sum256([]byte(*peerSecret)) + // quick re-convert from [32]byte to []byte + secretBytes := sum[:] + opts.SecretKey = &secretBytes + } + + endpoint, err := iroh.EndpointBind(opts) + if err := iroh.As(err); err != nil { + panic(err) + } + fmt.Printf("endpoint's id:\n %s\n", endpoint.Id()) + endpoint.Online() + + if isListener { + fmt.Println("this endpoint is online and listening!\n\nconnect using endpoint id or via ticket:\n") + fmt.Printf("allowlist\n -endpoint %s\n", endpoint.Id()) + ticket, err := iroh.EndpointTicketFromAddr(endpoint.Addr()) + + if err := iroh.As(err); err != nil { + panic(err) + } + + fmt.Printf("\nallowlist\n -ticket %s\n\n", ticket) + + if *allowedPeer == "" { + fmt.Println("\nallowing all peers to connect") + } else { + fmt.Printf("\nallowlist detected: limiting connections to %s\n", *allowedPeer) + } + + go func() { listen(endpoint) }() + } else { + go func() { receiver(endpoint, *endpointPeer, *ticketArg) }() + } + + awaitInterrupt() +} diff --git a/examples/allowlist/go.mod b/examples/allowlist/go.mod new file mode 100644 index 0000000..18e07d6 --- /dev/null +++ b/examples/allowlist/go.mod @@ -0,0 +1,7 @@ +module git.coopcloud.tech/decentral1se/iroh-go/examples/allowlist + +go 1.26.1 + +require git.coopcloud.tech/decentral1se/iroh-go v0.0.0-20260717110820-68ad06fe2a25 + +replace git.coopcloud.tech/decentral1se/iroh-go => ../../ diff --git a/examples/allowlist/makefile b/examples/allowlist/makefile new file mode 100644 index 0000000..aa931af --- /dev/null +++ b/examples/allowlist/makefile @@ -0,0 +1,2 @@ +default: + @go build -v ./... -- 2.54.0 From 6331f54ccf61374492d31917fba2b66adfc27d41 Mon Sep 17 00:00:00 2001 From: cblgh Date: Tue, 13 Oct 2026 15:50:50 +0200 Subject: [PATCH 2/3] examples: add readme and briefly describe each example --- examples/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/README.md diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..08207a1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Examples + +The examples in this directory show different parts of making an application that leverages +iroh's functionality. + +[`timeserve`](./timeserve) demonstrates how one listener can serve many clients at once + +[`allowlist`](./allowlist) shows how to set up an allowlist, limiting connections to only those +explicitly approved. The example also shows how you can derive a static identity (iroh public +keypair) and how to interact with iroh's [tickets][tickets] mechanism of exchanging address +information. + +[`connect`](./connect) is a barebones example showing off how to send and read data. + +[`connect-desktop`](./connect-desktop) shows how to build a go-based desktop application using iroh + +[`pairchat`](./pairchat) demonstrates how to setup a pairwise connection where both parties can +read and receive data sent over an iroh connection. -- 2.54.0 From 0d4253b27136710c558fcb8d55772bc90c340468 Mon Sep 17 00:00:00 2001 From: cblgh Date: Tue, 13 Oct 2026 15:52:19 +0200 Subject: [PATCH 3/3] examples: readme - add tickets doc --- examples/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/README.md b/examples/README.md index 08207a1..c8f66db 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,16 +3,18 @@ The examples in this directory show different parts of making an application that leverages iroh's functionality. -[`timeserve`](./timeserve) demonstrates how one listener can serve many clients at once +[`timeserve`](./timeserve) demonstrates how one listener can serve many clients at once. [`allowlist`](./allowlist) shows how to set up an allowlist, limiting connections to only those -explicitly approved. The example also shows how you can derive a static identity (iroh public -keypair) and how to interact with iroh's [tickets][tickets] mechanism of exchanging address -information. +explicitly approved. The example also shows how you can derive an identity that will stay the +same between runs (static iroh public keypair) and how to interact with iroh's +[tickets][tickets] mechanism of exchanging address information. -[`connect`](./connect) is a barebones example showing off how to send and read data. +[`connect`](./connect) is a barebones example showing how to send and read data. -[`connect-desktop`](./connect-desktop) shows how to build a go-based desktop application using iroh +[`connect-desktop`](./connect-desktop) shows how to build a go-based desktop application using iroh. [`pairchat`](./pairchat) demonstrates how to setup a pairwise connection where both parties can read and receive data sent over an iroh connection. + +[tickets]: https://docs.iroh.computer/connect-two-endpoints#what-is-a-ticket -- 2.54.0