diff --git a/.gitignore b/.gitignore index bf82198..c6b3eb0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,7 @@ examples/pairchat/pairchat examples/simple-pipe/simple-pipe examples/echosrv/echosrv examples/echosrv/dumbpipe +examples/echo-endpoint/echo-endpoint +examples/echo-endpoint/dumbpipe examples/timeserve/timeserve iroh-go/target/ diff --git a/examples/README.md b/examples/README.md index 6f4ecc7..8bd6e38 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,5 +20,9 @@ that leverages iroh's functionality. la netcat to send files over iroh. * [`echosrv`](./echosrv) shows how to set up a simple HTTP echo server that is served only over iroh. +* [`echo-endpoint`](./echo-endpoint) sets up a HTTP echo server that can gate its HTTP + routes based on the Iroh public key making requests. + + [tickets]: https://docs.iroh.computer/connect-two-endpoints#what-is-a-ticket diff --git a/examples/echo-endpoint/README.md b/examples/echo-endpoint/README.md new file mode 100644 index 0000000..00fea61 --- /dev/null +++ b/examples/echo-endpoint/README.md @@ -0,0 +1,72 @@ +# echo-endpoint + +Demonstrates how to set up a simple HTTP echo server that is served only* over iroh. + +This example is more complex than `echosrv`. It ties the incoming endpoint ID to the HTTP +request that is handled by the http server. In effect, the underlying http server can gate +resources and responses depending on which iroh keypair is connecting and sending requests. + +To run this example, you will need to use [cURL](https://curl.se/) (a multitool of a program for anything HTTP) and also download Number0's program [`dumbpipe`](https://www.dumbpipe.dev/) (Iroh forwarding tool). For dumbpipe binaries as of 2026-10, see [n0-computer/dumbpipe/releases](https://github.com/n0-computer/dumbpipe/releases). + +We will use dumbpipe together with cURL to achieve sending http client traffic over Iroh to our Iroh-available HTTP server. + +\* *technically, the http server is also available locally on the opened port* + +## Build a binary + +``` +go build --ldflags '-linkmode external -extldflags=-static -s -w' ./echo-endpoint.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"' ./echo-endpoint.go +``` + +## Terminal 1 (http server) + +``` +./echo-endpoint +// outputs, among other things, an endpoint ticket you need to provide in terminal 2 +``` + +## Terminal 2 (iroh client forwarding) + +You can install `dumbpipe` [here](https://www.dumbpipe.dev). + +``` +./dumbpipe connect-tcp \ + --addr 127.0.0.1:8099 \ + --custom-alpn "utf8:iroh-go-echo-endpoint/0" \ + endpoint +``` + +A few notes: + +* `--addr` - the ip and port has to be same as what you will use in Terminal 3. + It is completely unrelated to the ip and port use by the http server in + terminal 1 (we're connecting to that over Iroh!!). +* Note the use of `--custom-alpn`. Specifically, the prefix "utf8:" allows us + to use an utf8 string, otherwise the alpn would be interpreted differnetly + and not work. +* `endpoint` should be the ticket output from Terminal 1. + +## Terminal 3 (http client) + +Finally, we send a HTTP POST request to our http server through the `dumbpipe` +proxied iroh connection in Terminal 2. Take care to use the same ip and port as +used in Terminal 2. + +``` +curl 127.0.0.1:8099/ --data "hello" +``` +You should see the string "hello" echoed back out to you, prefixed with the public key of the +iroh endpoint establishing the connection in Terminal 2. + +If you restart the process running in Terminal 2, you should see a different key being +returned as the prefix in the echo server's response. diff --git a/examples/echo-endpoint/echo-endpoint.go b/examples/echo-endpoint/echo-endpoint.go new file mode 100644 index 0000000..d754662 --- /dev/null +++ b/examples/echo-endpoint/echo-endpoint.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "crypto/sha256" + "sync" + "errors" + "fmt" + "io" + "flag" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "gomod.cblgh.org/cerca/util/eout" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" +) + +var ( + alpn = []byte("iroh-go-echo-endpoint/0") + conns sync.Map +) + +type SimplePipe struct { + recv *iroh.RecvStream + send *iroh.SendStream + totalBytesRead int +} + +func (sp *SimplePipe) setRecv(recv *iroh.RecvStream) { + sp.recv = recv +} + +func (sp *SimplePipe) setSend(send *iroh.SendStream) { + sp.send = send +} + +func (sp *SimplePipe) Read(buf []byte) (int, error) { + if len(buf) == 0 { + return 0, nil + } + + retbuf, err := sp.recv.Read(uint32(len(buf))) + + // signal end of file + if sp.totalBytesRead > 0 && len(retbuf) == 0 { + return 0, io.EOF + } + + sp.totalBytesRead += len(retbuf) + + copyBytes := func() { + for i, d := range retbuf { + buf[i] = d + } + } + + if err != nil { + copyBytes() + return len(retbuf), err + } + copyBytes() + return len(retbuf), err +} + +func (sp *SimplePipe) Write(buf []byte) (int, error) { + n64, err := sp.send.Write(buf) + n := int(n64) + + if n < len(buf) { + return n, errors.New("SimplePipe.Write exited before writing all bytes") + } + + return n, err +} + +var done = make(chan os.Signal, 1) + +func awaitInterrupt() { + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + <-done + os.Exit(0) +} + +var PORT int +var verbose bool +func log(s string) { + if verbose { + fmt.Println(s) + } +} + +type ITCP struct { + LocalListener net.Listener + Endpoint *iroh.Endpoint + ch chan string +} + + +// create an iroh endpoint +func newITCP() *ITCP { + var t ITCP + t.ch = make(chan string) + + preset := iroh.PresetN0() + opts := iroh.EndpointOptions{ + Preset: &preset, + Alpns: &[][]byte{alpn}, + } + + // NOTE (2026-10-19): static endpoint key being set + digest := sha256.Sum256([]byte("echo-endpoint")) + secret := digest[:] + opts.SecretKey = &secret + + endpoint, err := iroh.EndpointBind(opts) + if err := iroh.As(err); err != nil { + panic(err) + } + + endpoint.Online() + fmt.Printf("online as %s\n", endpoint.Id()) + + ticket, err := iroh.EndpointTicketFromAddr(endpoint.Addr()) + eout.Check(err, "format ticket") + fmt.Println("\nticket:") + fmt.Println(ticket, "\n") + + t.Endpoint = endpoint + return &t +} + +func getHost () string { + return fmt.Sprintf(":%d", PORT) +} + +func (t *ITCP) Start(ctx context.Context) { + tcphost := getHost() + var err error + log(fmt.Sprintf("setting up local listener on %s", tcphost)) + t.LocalListener, err = net.Listen("tcp", tcphost) + eout.Check(err, "set up local listener") + + go t.handleIncoming(tcphost) +} + +// implements net.Listener +func (t *ITCP) Accept() (net.Conn, error) { + c, err := t.LocalListener.Accept() + // receive endpoint id from t.handleIncoming goroutine + endpointId := <- t.ch + conns.Store(c, endpointId) + return c, err +} + +// implements net.Listener +func (t *ITCP) Close() error { + // TODO (2026-10-21) + return t.LocalListener.Close() +} +// implements net.Listener +func (t *ITCP) Addr() net.Addr { + return t.LocalListener.Addr() +} + + +func (t *ITCP) handleIncoming(tcphost string) { + for { + incoming := t.Endpoint.AcceptNext() + log("incoming") + accepting, err := (*incoming).Accept() + if err := iroh.As(err); err != nil { + eout.Check(err, "incoming.Accept") + } + log("accepted") + conn, err := accepting.Connect() + if err := iroh.As(err); err != nil { + eout.Check(err, "incoming.Connect") + } + log("connected") + stream, err := conn.AcceptBi() + if err := iroh.As(err); err != nil { + eout.Check(err, "incoming.AcceptBi") + panic(err) + } + log("bidi stream") + + // send endpoint public key to be received by accept handler + go func (endpointId string) { + t.ch <- endpointId + }(conn.RemoteId().String()) + + recv := stream.Recv() + send := stream.Send() + + var sp SimplePipe + sp.setRecv(recv) + sp.setSend(send) + + log("pre dial") + dialedConn, err := net.Dial("tcp", tcphost) + eout.Check(err, "net.dial") + go io.Copy(dialedConn, &sp) + go io.Copy(&sp, dialedConn) + log("post dial") + } +} + +func parseFlags() { + flag.BoolVar(&verbose, "v", false, "output more log messages") + flag.IntVar(&PORT, "p", 9899, "port for net.Listener to run on") + flag.Parse() +} + +func main() { + parseFlags() + tcpServe := newITCP() + mux := http.NewServeMux() + mux.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) { + log("received req") + body, err := io.ReadAll(req.Body) + // access the endpoint id of the remote sending the request via req.Context set by server.ConnContext + v := req.Context().Value("endpointId") + // return early, endpointId isn't set + if v == nil || v == "" { + log("request did not have endpointId on context") + res.Write(body) + return + } + log(fmt.Sprintf("request context for key 'endpointId' %q", v)) + eout.Check(err, "read body") + log(fmt.Sprintf("data %q\n", string(body))) + res.Write([]byte(fmt.Sprintf("[%s] %s", v, body))) + }) + + server := http.Server{ + Addr: getHost(), + // provide an extra key `endpointId` to all incoming http.Request's such that each handler can access the key + // for a request as `req.Context().Value('endpointId')` + ConnContext: func (ctx context.Context, c net.Conn) context.Context { + var s string + // sync.map 'conns' retrieves incoming net.Conn from map and then deletes it to prevent memory leak + v, ok := conns.LoadAndDelete(c) + if ok { + s = v.(string) + } else { + log("sync.map 'conns' could not retrieve incoming net.Conn from map") + } + // we set the key 'endpointId' on the context, which means we can retrieve it from any handler's request as + // `req.Context().Value('endpointId')` + return context.WithValue(ctx, "endpointId", s) + }, + Handler: mux, + } + + tcpServe.Start(context.Background()) + fmt.Println("started iroh http server") + err := server.Serve(tcpServe) + eout.Check(err, "http.Serve(tcpServe)") + awaitInterrupt() +} diff --git a/examples/echo-endpoint/go.mod b/examples/echo-endpoint/go.mod new file mode 100644 index 0000000..5b64f43 --- /dev/null +++ b/examples/echo-endpoint/go.mod @@ -0,0 +1,9 @@ +module git.coopcloud.tech/decentral1se/iroh-go/examples/echo-endpoint + +go 1.26.1 + +require git.coopcloud.tech/decentral1se/iroh-go v0.0.0-20260717110820-68ad06fe2a25 + +require gomod.cblgh.org/cerca v0.3.3 // indirect + +replace git.coopcloud.tech/decentral1se/iroh-go => ../../ diff --git a/examples/echo-endpoint/go.sum b/examples/echo-endpoint/go.sum new file mode 100644 index 0000000..a350675 --- /dev/null +++ b/examples/echo-endpoint/go.sum @@ -0,0 +1,2 @@ +gomod.cblgh.org/cerca v0.3.3 h1:NaDO+J/cHn5nIsmLhjBUfWb/4RkNg6Dn3frrbX7TvHc= +gomod.cblgh.org/cerca v0.3.3/go.mod h1:ZLFyHtI0Dd9GohYRn2Y+HpY3vpHQHa21efOTeSXmWvo= diff --git a/examples/echo-endpoint/makefile b/examples/echo-endpoint/makefile new file mode 100644 index 0000000..aa931af --- /dev/null +++ b/examples/echo-endpoint/makefile @@ -0,0 +1,2 @@ +default: + @go build -v ./...