examples: add http-dialer using net/http:Client #24

Open
cblgh wants to merge 5 commits from examples-http-client into main
8 changed files with 603 additions and 0 deletions
+2
View File
@@ -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/
+2
View File
@@ -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
+47
View File
@@ -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.
+275
View File
@@ -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))
}
+9
View File
@@ -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 => ../../
+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=
+264
View File
@@ -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()
}
+2
View File
@@ -0,0 +1,2 @@
default:
@go build -v ./...