examples: add http-dialer using net/http:Client #24
@@ -11,5 +11,7 @@ examples/echosrv/echosrv
|
||||
examples/echosrv/dumbpipe
|
||||
examples/echo-endpoint/echo-endpoint
|
||||
examples/echo-endpoint/dumbpipe
|
||||
examples/http-dialer/http-dialer
|
||||
examples/http-dialer/http-client
|
||||
examples/timeserve/timeserve
|
||||
iroh-go/target/
|
||||
|
||||
@@ -22,6 +22,8 @@ that leverages iroh's functionality.
|
||||
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.
|
||||
* [`http-dialer`](./http-dialer) shows how to send a POST request over iroh using the `net/http`
|
||||
standard library approach
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# http-dialer
|
||||
|
||||
Demonstrates an http client in go that dials an http server, also in go, over Iroh using
|
||||
the standard library's `http.Client`.
|
||||
|
||||
This example builds on `echo-endpoint` and does away with any external program dependencies;
|
||||
everything is done in go.
|
||||
|
||||
## Build a binary
|
||||
|
||||
```
|
||||
go build --ldflags '-linkmode external -extldflags=-static -s -w' ./http-dialer.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"' ./http-dialer.go
|
||||
```
|
||||
|
||||
## Terminal 1 (http server)
|
||||
|
||||
```
|
||||
go build ./http-dialer.go
|
||||
./http-dialer
|
||||
// outputs, among other things, an endpoint ticket you need to provide in Terminal 2
|
||||
```
|
||||
|
||||
## Terminal 2 (http client)
|
||||
|
||||
Send an HTTP POST request to our http server in Terminal 1 over Iroh using the program `http-client`
|
||||
from the `client/` sub-directory of this example:
|
||||
|
||||
```
|
||||
go build ./client/http-client.go
|
||||
./http-client --address <endpoint from Terminal 1> hello hello
|
||||
```
|
||||
|
||||
You should see the string "hello hello" echoed back out to you, prefixed with the
|
||||
public key of the Iroh endpoint establishing the connection in Terminal 1.
|
||||
|
||||
If you run the program again, you will see another prefixed public key. This is because
|
||||
`http-client` creates a new Iroh identity each time it runs.
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gomod.cblgh.org/cerca/util/eout"
|
||||
|
||||
iroh "git.coopcloud.tech/decentral1se/iroh-go"
|
||||
)
|
||||
|
||||
var (
|
||||
alpn = []byte("iroh-go-http-dialer/0")
|
||||
)
|
||||
|
||||
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 verbose bool
|
||||
var httpServerAddress string
|
||||
var additionalInput string
|
||||
|
||||
func log(s string) {
|
||||
if verbose {
|
||||
fmt.Println(s)
|
||||
}
|
||||
}
|
||||
|
||||
// IrohConn implements net.Conn (Read,Write,Close + Local/RemoteAddr + Deadline methods)
|
||||
type IrohConn struct {
|
||||
sp *SimplePipe
|
||||
addrLocal *IrohAddr
|
||||
addrRemote *IrohAddr
|
||||
}
|
||||
|
||||
type IrohAddr struct {
|
||||
internalAddr string
|
||||
}
|
||||
func (addr *IrohAddr) String() string {
|
||||
return addr.internalAddr
|
||||
}
|
||||
func (addr *IrohAddr) Network() string {
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
func newIrohConn(remoteEndpoint, localEndpoint string) *IrohConn {
|
||||
ic := IrohConn{
|
||||
sp: &SimplePipe{},
|
||||
addrLocal: &IrohAddr{localEndpoint},
|
||||
addrRemote: &IrohAddr{remoteEndpoint},
|
||||
}
|
||||
return &ic
|
||||
}
|
||||
|
||||
// Read reads data from the connection.
|
||||
// Read can be made to time out and return an error after a fixed
|
||||
// time limit; see SetDeadline and SetReadDeadline.
|
||||
func (ic *IrohConn) Read(b []byte) (n int, err error) {
|
||||
return ic.sp.Read(b)
|
||||
}
|
||||
|
||||
// Write writes data to the connection.
|
||||
// Write can be made to time out and return an error after a fixed
|
||||
// time limit; see SetDeadline and SetWriteDeadline.
|
||||
func (ic *IrohConn) Write(b []byte) (n int, err error) {
|
||||
return ic.sp.Write(b)
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
// Any blocked Read or Write operations will be unblocked and return errors.
|
||||
// Close may or may not block until any buffered data is sent;
|
||||
// for TCP connections see [*TCPConn.SetLinger].
|
||||
func (ic *IrohConn) Close() error {
|
||||
return fmt.Errorf("TODO Close()")
|
||||
}
|
||||
|
||||
// LocalAddr returns the local network address, if known.
|
||||
func (ic *IrohConn) LocalAddr() net.Addr {
|
||||
return ic.addrLocal
|
||||
}
|
||||
|
||||
// RemoteAddr returns the remote network address, if known.
|
||||
func (ic *IrohConn) RemoteAddr() net.Addr {
|
||||
return ic.addrRemote
|
||||
}
|
||||
|
||||
// SetDeadline sets the read and write deadlines associated
|
||||
// with the connection. It is equivalent to calling both
|
||||
// SetReadDeadline and SetWriteDeadline.
|
||||
//
|
||||
// A deadline is an absolute time after which I/O operations
|
||||
// fail instead of blocking. The deadline applies to all future
|
||||
// and pending I/O, not just the immediately following call to
|
||||
// Read or Write. After a deadline has been exceeded, the
|
||||
// connection can be refreshed by setting a deadline in the future.
|
||||
//
|
||||
// If the deadline is exceeded a call to Read or Write or to other
|
||||
// I/O methods will return an error that wraps os.ErrDeadlineExceeded.
|
||||
// This can be tested using errors.Is(err, os.ErrDeadlineExceeded).
|
||||
// The error's Timeout method will return true, but note that there
|
||||
// are other possible errors for which the Timeout method will
|
||||
// return true even if the deadline has not been exceeded.
|
||||
//
|
||||
// An idle timeout can be implemented by repeatedly extending
|
||||
// the deadline after successful Read or Write calls.
|
||||
//
|
||||
// A zero value for t means I/O operations will not time out.
|
||||
func (ic *IrohConn) SetDeadline(t time.Time) error {
|
||||
return fmt.Errorf("TODO SetDeadline")
|
||||
}
|
||||
|
||||
// SetReadDeadline sets the deadline for future Read calls
|
||||
// and any currently-blocked Read call.
|
||||
// A zero value for t means Read will not time out.
|
||||
func (ic *IrohConn) SetReadDeadline(t time.Time) error {
|
||||
return fmt.Errorf("TODO SetReadDeadline")
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets the deadline for future Write calls
|
||||
// and any currently-blocked Write call.
|
||||
// Even if write times out, it may return n > 0, indicating that
|
||||
// some of the data was successfully written.
|
||||
// A zero value for t means Write will not time out.
|
||||
func (ic *IrohConn) SetWriteDeadline(t time.Time) error {
|
||||
return fmt.Errorf("TODO SetWriteDeadline")
|
||||
}
|
||||
|
||||
// Receives addressing information and sets up an iroh connection to the endpoint described by the addressing
|
||||
// information, and returns the receive/send streams in a format that golang's net.http ecosystem will accept.
|
||||
func DialIroh(network, address string) (net.Conn, error) {
|
||||
log(fmt.Sprintf("DialIroh: network %q address %q\n", network, address))
|
||||
remoteEndpointString := strings.Split(address, ":")[0]
|
||||
|
||||
var localEndpointString string
|
||||
|
||||
var remoteAddr *iroh.EndpointAddr
|
||||
// first try to parse as ticket
|
||||
if ticket, err := iroh.EndpointTicketFromString(remoteEndpointString); err == nil {
|
||||
remoteAddr = ticket.EndpointAddr()
|
||||
} else {
|
||||
// fallback to interpret as endpoint id
|
||||
remote, err := iroh.EndpointIdFromString(remoteEndpointString)
|
||||
if err := iroh.As(err); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
remoteAddr = iroh.NewEndpointAddr(remote, nil, nil)
|
||||
}
|
||||
|
||||
log(fmt.Sprintf("DialIroh: endpoint id %q ", remoteAddr.Id().String()))
|
||||
|
||||
preset := iroh.PresetN0()
|
||||
opts := iroh.EndpointOptions{
|
||||
Preset: &preset,
|
||||
Alpns: &[][]byte{alpn},
|
||||
}
|
||||
|
||||
e, err := iroh.EndpointBind(opts)
|
||||
if err := iroh.As(err); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
localEndpointString = e.Id().String()
|
||||
|
||||
// NOTE (2026-10-22): pretty sure program execution stops here if you can't
|
||||
// get a connection to a relay (e.g. if you are rate-limited for too many tries)
|
||||
e.Online()
|
||||
|
||||
log(fmt.Sprintf("online as %s\n", localEndpointString))
|
||||
|
||||
conn, err := e.Connect(remoteAddr, alpn)
|
||||
if err := iroh.As(err); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
stream, err := conn.OpenBi()
|
||||
if err := iroh.As(err); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
irohConn := newIrohConn(remoteEndpointString, localEndpointString)
|
||||
|
||||
irohConn.sp.setRecv(stream.Recv())
|
||||
irohConn.sp.setSend(stream.Send())
|
||||
|
||||
return irohConn, nil
|
||||
}
|
||||
|
||||
func parseFlags() {
|
||||
flag.BoolVar(&verbose, "v", false, "output more log messages")
|
||||
flag.StringVar(&httpServerAddress, "address", "", "iroh endpoint serving an http server")
|
||||
flag.Parse()
|
||||
additionalInput = strings.Join(flag.Args(), " ")
|
||||
}
|
||||
|
||||
func main() {
|
||||
parseFlags()
|
||||
if httpServerAddress == "" {
|
||||
fmt.Println("must provide --address")
|
||||
os.Exit(1)
|
||||
}
|
||||
// setup our dialer
|
||||
transport := http.Transport{
|
||||
Dial: DialIroh,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &transport,
|
||||
}
|
||||
createUrl := func (endpoint, urlpath string) string {
|
||||
return fmt.Sprintf("http://%s%s", endpoint, urlpath)
|
||||
}
|
||||
// try to dial and send a POST request
|
||||
resp, err := client.Post(createUrl(httpServerAddress, "/"), "text/plain", bytes.NewBufferString(additionalInput))
|
||||
eout.Check(err, "client.Post")
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
eout.Check(err, "read body")
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module git.coopcloud.tech/decentral1se/iroh-go/examples/http-dialer
|
||||
|
||||
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,264 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"gomod.cblgh.org/cerca/util/eout"
|
||||
|
||||
iroh "git.coopcloud.tech/decentral1se/iroh-go"
|
||||
)
|
||||
|
||||
var (
|
||||
alpn = []byte("iroh-go-http-dialer/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("http-dialer"))
|
||||
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,2 @@
|
||||
default:
|
||||
@go build -v ./...
|
||||
Reference in New Issue
Block a user