examples: add echo-endpoint #23
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 => ../../
|
||||
@@ -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=
|
||||
@@ -0,0 +1,2 @@
|
||||
default:
|
||||
@go build -v ./...
|
||||
Reference in New Issue
Block a user