examples: add echo-endpoint #23

Merged
decentral1se merged 1 commits from examples-echo-endpoint into main 2026-10-21 14:46:46 +00:00
7 changed files with 355 additions and 0 deletions
+2
View File
@@ -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/
+4
View File
@@ -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
+72
View File
@@ -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<YOURENDPOINT-HERE>
```
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<YOURENDPOINT-HERE>` 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.
+264
View File
@@ -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()
}
+9
View File
@@ -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 => ../../
+2
View File
@@ -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=
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...