Repository: libp2p/go-libp2p
Pull request #2438 (2023-07-20 17:58:36)

feat: Implement HTTP spec
A MVP for libp2p HTTP spec https://github.com/libp2p/specs/pull/508. 

This adds an `Host` for doing request-response with HTTP semantics. The libp2phttp.Host contrasts with a "stream host" (basichost or `host.Host` interface). It is capable of doing HTTP over an HTTP transport and/or a stream transport (provided by the stream host).

What works in this MVP:
- `.well-known/libp2p`
- A helper func to give you the best roundtripper given a list of multiaddrs and an optional stream host.
- A way of listening for HTTP on stream transports as well as a native HTTP transport.

My thoughts after implementing the spec is that the spec is a nice minimal set of constraints that are relatively easy to implement. Even making a compliant node using the the stock HTTP library is fairly straightforward.

If this looks good after a review, I would suggest merging this as an experimental feature to allow folks to start using it and getting feedback. I may try to integrate this into the IPNI stack to see how it feels.

Note: This branch targets `marco/gostream` which pulls in https://github.com/libp2p/go-libp2p-gostream into the monorepo.

---

Update Aug 2:
I've removed the libp2p-noise auth from the http spec, and replaced it with this spec focused only on auth: https://github.com/libp2p/specs/pull/564.

I think for the mvp we can merge this without doing any peer id auth, since peer id auth is optional anyways.

BigLep: @MarcoPolo : for the "libp2p and HTTP" effort, what will the testing story be?   I'm not saying that all needs to be in this PR, but I'm curious about where we're aiming to end up.  
MarcoPolo: > @MarcoPolo : for the "libp2p and HTTP" effort, what will the testing story be? I'm not saying that all needs to be in this PR, but I'm curious about where we're aiming to end up.

This PR adds basic tests that test end-to-end communication on top of stream transports or an HTTP transport. It tests that a stock HTTP client/server can interoperate. It also adds tests around peer id authentication. It doesn't currently, but will include a test that tests the SNI+TLS flow for peer id authentication, and tests that incorrect auth steps correctly fail (these are noted in before merge list in the PR body).

Once we have multiple implementations of the spec, we will leverage the multidim interop tester to test this as well. It should just work, with no changes needed to the test runner.

Was there a test you had in mind that was missing here? Or something that you feel should be highlighted?

BigLep: > Was there a test you had in mind that was missing here? Or something that you feel should be highlighted?

Thanks for explaining.  I didn't have anything else in mind.  I had missed the tests you described when I took a quick scan earlier.  My bad for not catching those.

Concerning multidimen interop running, we'll also need that for testing version compatibility as well right (even if go-libp2p is the only implementation to support this functionality for awhile)?
MarcoPolo: I've removed the libp2p-noise auth from the http spec, and replaced it with this spec focused only on auth: https://github.com/libp2p/specs/pull/564. I've also removed auth from this PR.

I think for the mvp we can merge this without doing any peer id auth, since peer id auth is optional anyways.
MarcoPolo: @gammazero I have a surprise for you: https://github.com/ipni/go-libipni/pull/102
MarcoPolo: > Lots of my comments come from looking at the GoDoc (`godoc -http=:6060`), and putting myself into the shoes of a user of this package.

Nice! Thanks!


> The following code doesn't work:
> 
> ```go
> host, err := libp2phttp.New(libp2phttp.ListenAddrs([]ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/8080/http")}))
> if err != nil {
> 	log.Fatal(err)
> }
> host.SetHttpHandler("/website", http.FileServer(http.Dir("/tmp")))
> log.Fatal(host.Serve())
> ```

When you go to "/website" the fileserver is seeing the full path "/website" and you get a 404 because you don't have a `/tmp/website` folder. This is the exact same behavior you get with a servemux (`m.Handle("/website/", http.FileServer(http.Dir("/tmp")))`). 

You should strip the prefix of the path:
```
host.SetHttpHandler("/website", http.StripPrefix("/website", http.FileServer(http.Dir("/tmp"))))
```

Maybe this should be the default behavior for SetHttpHandler?
MarcoPolo: I've changed the behavior of SetHTTPHandler to strip the prefix for the handler automatically. I can't think of use cases where you wouldn't want that. You can still avoid it by setting a handler on the underlying servemux directly.

Patch:
diff --git a/p2p/http/libp2phttp.go b/p2p/http/libp2phttp.go
new file mode 100644
index 00000000..42c4333b
--- /dev/null
+++ b/p2p/http/libp2phttp.go
@@ -0,0 +1,804 @@
+// HTTP semantics with libp2p. Can use a libp2p stream transport or stock HTTP
+// transports. This API is experimental and will likely change soon. Implements [libp2p spec #508](https://github.com/libp2p/specs/pull/508).
+package libp2phttp
+
+import (
+	"bufio"
+	"crypto/tls"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"sync"
+
+	lru "github.com/hashicorp/golang-lru/v2"
+	logging "github.com/ipfs/go-log/v2"
+	host "github.com/libp2p/go-libp2p/core/host"
+	"github.com/libp2p/go-libp2p/core/network"
+	"github.com/libp2p/go-libp2p/core/peer"
+	"github.com/libp2p/go-libp2p/core/peerstore"
+	"github.com/libp2p/go-libp2p/core/protocol"
+	gostream "github.com/libp2p/go-libp2p/p2p/net/gostream"
+	ma "github.com/multiformats/go-multiaddr"
+)
+
+var log = logging.Logger("libp2phttp")
+
+const ProtocolIDForMultistreamSelect = "/http/1.1"
+const peerMetadataLimit = 8 << 10 // 8KB
+const peerMetadataLRUSize = 256   // How many different peer's metadata to keep in our LRU cache
+
+// ProtocolMeta is metadata about a protocol.
+type ProtocolMeta struct {
+	// Path defines the HTTP Path prefix used for this protocol
+	Path string `json:"path"`
+}
+
+type PeerMeta map[protocol.ID]ProtocolMeta
+
+// WellKnownHandler is an http.Handler that serves the .well-known/libp2p resource
+type WellKnownHandler struct {
+	wellknownMapMu   sync.Mutex
+	wellKnownMapping PeerMeta
+}
+
+// streamHostListen retuns a net.Listener that listens on libp2p streams for HTTP/1.1 messages.
+func streamHostListen(streamHost host.Host) (net.Listener, error) {
+	return gostream.Listen(streamHost, ProtocolIDForMultistreamSelect)
+}
+
+func (h *WellKnownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// Check if the requests accepts JSON
+	accepts := r.Header.Get("Accept")
+	if accepts != "" && !(strings.Contains(accepts, "application/json") || strings.Contains(accepts, "*/*")) {
+		http.Error(w, "Only application/json is supported", http.StatusNotAcceptable)
+		return
+	}
+
+	if r.Method != http.MethodGet {
+		http.Error(w, "Only GET requests are supported", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Return a JSON object with the well-known protocols
+	h.wellknownMapMu.Lock()
+	mapping, err := json.Marshal(h.wellKnownMapping)
+	h.wellknownMapMu.Unlock()
+	if err != nil {
+		http.Error(w, "Marshal error", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Add("Content-Type", "application/json")
+	w.Header().Add("Content-Length", strconv.Itoa(len(mapping)))
+	w.Write(mapping)
+}
+
+func (h *WellKnownHandler) AddProtocolMeta(p protocol.ID, protocolMeta ProtocolMeta) {
+	h.wellknownMapMu.Lock()
+	if h.wellKnownMapping == nil {
+		h.wellKnownMapping = make(map[protocol.ID]ProtocolMeta)
+	}
+	h.wellKnownMapping[p] = protocolMeta
+	h.wellknownMapMu.Unlock()
+}
+
+func (h *WellKnownHandler) RemoveProtocolMeta(p protocol.ID) {
+	h.wellknownMapMu.Lock()
+	if h.wellKnownMapping != nil {
+		delete(h.wellKnownMapping, p)
+	}
+	h.wellknownMapMu.Unlock()
+}
+
+// Host is a libp2p host for request/responses with HTTP semantics. This is
+// in contrast to a stream-oriented host like the core host.Host interface. Its
+// zero-value (&Host{}) is usable. Do not copy by value.
+// See examples for usage.
+//
+//	Warning, this is experimental. The API will likely change.
+type Host struct {
+	// StreamHost is a stream based libp2p host used to do HTTP over libp2p streams. May be nil
+	StreamHost host.Host
+	// ListenAddrs are the requested addresses to listen on. Multiaddrs must be
+	// valid HTTP(s) multiaddr. Only multiaddrs for an HTTP transport are
+	// supported (must end with /http or /https).
+	ListenAddrs []ma.Multiaddr
+	// TLSConfig is the TLS config for the server to use
+	TLSConfig *tls.Config
+	// InsecureAllowHTTP indicates if the server is allowed to serve unencrypted
+	// HTTP requests over TCP.
+	InsecureAllowHTTP bool
+	// ServeMux is the http.ServeMux used by the server to serve requests. If
+	// nil, a new serve mux will be created. Users may manually add handlers to
+	// this mux instead of using `SetHTTPHandler`, but if they do, they should
+	// also update the WellKnownHandler's protocol mapping.
+	ServeMux           *http.ServeMux
+	initializeServeMux sync.Once
+
+	// DefaultClientRoundTripper is the default http.RoundTripper for clients to
+	// use when making requests over an HTTP transport. This must be an
+	// `*http.Transport` type so that the transport can be cloned and the
+	// `TLSClientConfig` field can be configured. If unset, it will create a new
+	// `http.Transport` on first use.
+	DefaultClientRoundTripper *http.Transport
+
+	// WellKnownHandler is the http handler for the `.well-known/libp2p`
+	// resource. It is responsible for sharing this node's protocol metadata
+	// with other nodes. Users only care about this if they set their own
+	// ServeMux with pre-existing routes. By default, new protocols are added
+	// here when a user calls `SetHTTPHandler` or `SetHTTPHandlerAtPath`.
+	WellKnownHandler WellKnownHandler
+
+	// peerMetadata is an LRU cache of a peer's well-known protocol map.
+	peerMetadata *lru.Cache[peer.ID, PeerMeta]
+	// createHTTPTransport is used to lazily create the httpTransport in a thread-safe way.
+	createHTTPTransport sync.Once
+	// createDefaultClientRoundTripper is used to lazily create the default
+	// client round tripper in a thread-safe way.
+	createDefaultClientRoundTripper sync.Once
+	httpTransport                   *httpTransport
+}
+
+type httpTransport struct {
+	listenAddrs         []ma.Multiaddr
+	listeners           []net.Listener
+	closeListeners      chan struct{}
+	waitingForListeners chan struct{}
+}
+
+func newPeerMetadataCache() *lru.Cache[peer.ID, PeerMeta] {
+	peerMetadata, err := lru.New[peer.ID, PeerMeta](peerMetadataLRUSize)
+	if err != nil {
+		// Only happens if size is < 1. We make sure to not do that, so this should never happen.
+		panic(err)
+	}
+	return peerMetadata
+}
+
+func (h *Host) httpTransportInit() {
+	h.createHTTPTransport.Do(func() {
+		h.httpTransport = &httpTransport{
+			closeListeners:      make(chan struct{}),
+			waitingForListeners: make(chan struct{}),
+		}
+	})
+}
+
+func (h *Host) serveMuxInit() {
+	h.initializeServeMux.Do(func() {
+		h.ServeMux = http.NewServeMux()
+	})
+}
+
+func (h *Host) Addrs() []ma.Multiaddr {
+	h.httpTransportInit()
+	<-h.httpTransport.waitingForListeners
+	return h.httpTransport.listenAddrs
+}
+
+// ID returns the peer ID of the underlying stream host, or the zero value if there is no stream host.
+func (h *Host) PeerID() peer.ID {
+	if h.StreamHost != nil {
+		return h.StreamHost.ID()
+	}
+	return ""
+}
+
+var ErrNoListeners = errors.New("nothing to listen on")
+
+func (h *Host) setupListeners(listenerErrCh chan error) error {
+	for _, addr := range h.ListenAddrs {
+		parsedAddr := parseMultiaddr(addr)
+		// resolve the host
+		ipaddr, err := net.ResolveIPAddr("ip", parsedAddr.host)
+		if err != nil {
+			return err
+		}
+
+		host := ipaddr.String()
+		l, err := net.Listen("tcp", host+":"+parsedAddr.port)
+		if err != nil {
+			return err
+		}
+		h.httpTransport.listeners = append(h.httpTransport.listeners, l)
+
+		// get resolved port
+		_, port, err := net.SplitHostPort(l.Addr().String())
+		if err != nil {
+			return err
+		}
+
+		var listenAddr ma.Multiaddr
+		if parsedAddr.useHTTPS && parsedAddr.sni != "" && parsedAddr.sni != host {
+			listenAddr = ma.StringCast(fmt.Sprintf("/ip4/%s/tcp/%s/tls/sni/%s/http", host, port, parsedAddr.sni))
+		} else {
+			scheme := "http"
+			if parsedAddr.useHTTPS {
+				scheme = "https"
+			}
+			listenAddr = ma.StringCast(fmt.Sprintf("/ip4/%s/tcp/%s/%s", host, port, scheme))
+		}
+
+		if parsedAddr.useHTTPS {
+			go func() {
+				srv := http.Server{
+					Handler:   h.ServeMux,
+					TLSConfig: h.TLSConfig,
+				}
+				listenerErrCh <- srv.ServeTLS(l, "", "")
+			}()
+			h.httpTransport.listenAddrs = append(h.httpTransport.listenAddrs, listenAddr)
+		} else if h.InsecureAllowHTTP {
+			go func() {
+				listenerErrCh <- http.Serve(l, h.ServeMux)
+			}()
+			h.httpTransport.listenAddrs = append(h.httpTransport.listenAddrs, listenAddr)
+		} else {
+			// We are not serving insecure HTTP
+			log.Warnf("Not serving insecure HTTP on %s. Prefer an HTTPS endpoint.", listenAddr)
+		}
+	}
+	return nil
+}
+
+// Serve starts the HTTP transport listeners. Always returns a non-nil error.
+// If there are no listeners, returns ErrNoListeners.
+func (h *Host) Serve() error {
+	// assert that each addr contains a /http component
+	for _, addr := range h.ListenAddrs {
+		_, isHTTP := normalizeHTTPMultiaddr(addr)
+		if !isHTTP {
+			return fmt.Errorf("address %s does not contain a /http or /https component", addr)
+		}
+	}
+
+	h.serveMuxInit()
+	h.ServeMux.Handle("/.well-known/libp2p", &h.WellKnownHandler)
+
+	h.httpTransportInit()
+
+	closedWaitingForListeners := false
+	defer func() {
+		if !closedWaitingForListeners {
+			close(h.httpTransport.waitingForListeners)
+		}
+	}()
+
+	if len(h.ListenAddrs) == 0 && h.StreamHost == nil {
+		return ErrNoListeners
+	}
+
+	h.httpTransport.listeners = make([]net.Listener, 0, len(h.ListenAddrs)+1) // +1 for stream host
+
+	streamHostAddrsCount := 0
+	if h.StreamHost != nil {
+		streamHostAddrsCount = len(h.StreamHost.Addrs())
+	}
+	h.httpTransport.listenAddrs = make([]ma.Multiaddr, 0, len(h.ListenAddrs)+streamHostAddrsCount)
+
+	errCh := make(chan error)
+
+	if h.StreamHost != nil {
+		listener, err := streamHostListen(h.StreamHost)
+		if err != nil {
+			return err
+		}
+		h.httpTransport.listeners = append(h.httpTransport.listeners, listener)
+		h.httpTransport.listenAddrs = append(h.httpTransport.listenAddrs, h.StreamHost.Addrs()...)
+
+		go func() {
+			errCh <- http.Serve(listener, h.ServeMux)
+		}()
+	}
+
+	closeAllListeners := func() {
+		for _, l := range h.httpTransport.listeners {
+			l.Close()
+		}
+	}
+
+	err := h.setupListeners(errCh)
+	if err != nil {
+		closeAllListeners()
+		return err
+	}
+
+	close(h.httpTransport.waitingForListeners)
+	closedWaitingForListeners = true
+
+	if len(h.httpTransport.listeners) == 0 || len(h.httpTransport.listenAddrs) == 0 {
+		closeAllListeners()
+		return ErrNoListeners
+	}
+
+	expectedErrCount := len(h.httpTransport.listeners)
+	select {
+	case <-h.httpTransport.closeListeners:
+	case err = <-errCh:
+		expectedErrCount--
+	}
+
+	// Close all listeners
+	closeAllListeners()
+	for i := 0; i < expectedErrCount; i++ {
+		<-errCh
+	}
+	close(errCh)
+
+	return err
+}
+
+func (h *Host) Close() error {
+	h.httpTransportInit()
+	close(h.httpTransport.closeListeners)
+	return nil
+}
+
+// SetHTTPHandler sets the HTTP handler for a given protocol. Automatically
+// manages the .well-known/libp2p mapping.
+// http.StripPrefix is called on the handler, so the handler will be unaware of
+// its prefix path.
+func (h *Host) SetHTTPHandler(p protocol.ID, handler http.Handler) {
+	h.SetHTTPHandlerAtPath(p, string(p), handler)
+}
+
+// SetHTTPHandlerAtPath sets the HTTP handler for a given protocol using the
+// given path. Automatically manages the .well-known/libp2p mapping.
+// http.StripPrefix is called on the handler, so the handler will be unaware of
+// its prefix path.
+func (h *Host) SetHTTPHandlerAtPath(p protocol.ID, path string, handler http.Handler) {
+	if path == "" || path[len(path)-1] != '/' {
+		// We are nesting this handler under this path, so it should end with a slash.
+		path += "/"
+	}
+	h.WellKnownHandler.AddProtocolMeta(p, ProtocolMeta{Path: path})
+	h.serveMuxInit()
+	h.ServeMux.Handle(path, http.StripPrefix(path, handler))
+}
+
+// PeerMetadataGetter lets RoundTrippers implement a specific way of caching a peer's protocol mapping.
+type PeerMetadataGetter interface {
+	GetPeerMetadata() (PeerMeta, error)
+}
+
+type streamRoundTripper struct {
+	server      peer.ID
+	addrsAdded  sync.Once
+	serverAddrs []ma.Multiaddr
+	h           host.Host
+	httpHost    *Host
+}
+
+// streamReadCloser wraps an io.ReadCloser and closes the underlying stream when
+// closed (as well as closing the wrapped ReadCloser). This is necessary because
+// we have two things to close, the body and the stream. The stream isn't closed
+// by the body automatically, as hinted at by the fact that `http.ReadResponse`
+// takes a bufio.Reader.
+type streamReadCloser struct {
+	io.ReadCloser
+	s network.Stream
+}
+
+func (s *streamReadCloser) Close() error {
+	s.s.Close()
+	return s.ReadCloser.Close()
+}
+
+func (rt *streamRoundTripper) GetPeerMetadata() (PeerMeta, error) {
+	return rt.httpHost.getAndStorePeerMetadata(rt, rt.server)
+}
+
+// RoundTrip implements http.RoundTripper.
+func (rt *streamRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+	// Add the addresses we learned about for this server
+	rt.addrsAdded.Do(func() {
+		if len(rt.serverAddrs) > 0 {
+			rt.h.Peerstore().AddAddrs(rt.server, rt.serverAddrs, peerstore.TempAddrTTL)
+		}
+		rt.serverAddrs = nil // may as well cleanup
+	})
+
+	s, err := rt.h.NewStream(r.Context(), rt.server, ProtocolIDForMultistreamSelect)
+	if err != nil {
+		return nil, err
+	}
+
+	go func() {
+		defer s.CloseWrite()
+		r.Write(s)
+		if r.Body != nil {
+			r.Body.Close()
+		}
+	}()
+
+	// TODO: Adhere to the request.Context
+	resp, err := http.ReadResponse(bufio.NewReader(s), r)
+	if err != nil {
+		return nil, err
+	}
+	resp.Body = &streamReadCloser{resp.Body, s}
+
+	return resp, nil
+}
+
+// roundTripperForSpecificServer is an http.RoundTripper targets a specific server. Still reuses the underlying RoundTripper for the requests.
+// The underlying RoundTripper MUST be an HTTP Transport.
+type roundTripperForSpecificServer struct {
+	http.RoundTripper
+	ownRoundtripper  bool
+	httpHost         *Host
+	server           peer.ID
+	targetServerAddr string
+	sni              string
+	scheme           string
+	cachedProtos     PeerMeta
+}
+
+func (rt *roundTripperForSpecificServer) GetPeerMetadata() (PeerMeta, error) {
+	// Do we already have the peer's protocol mapping?
+	if rt.cachedProtos != nil {
+		return rt.cachedProtos, nil
+	}
+
+	// if the underlying roundtripper implements GetPeerMetadata, use that
+	if g, ok := rt.RoundTripper.(PeerMetadataGetter); ok {
+		wk, err := g.GetPeerMetadata()
+		if err == nil {
+			rt.cachedProtos = wk
+			return wk, nil
+		}
+	}
+
+	wk, err := rt.httpHost.getAndStorePeerMetadata(rt, rt.server)
+	if err == nil {
+		rt.cachedProtos = wk
+		return wk, nil
+	}
+	return wk, err
+}
+
+// RoundTrip implements http.RoundTripper.
+func (rt *roundTripperForSpecificServer) RoundTrip(r *http.Request) (*http.Response, error) {
+	if (r.URL.Scheme != "" && r.URL.Scheme != rt.scheme) || (r.URL.Host != "" && r.URL.Host != rt.targetServerAddr) {
+		return nil, fmt.Errorf("this transport is only for requests to %s://%s", rt.scheme, rt.targetServerAddr)
+	}
+	r.URL.Scheme = rt.scheme
+	r.URL.Host = rt.targetServerAddr
+	r.Host = rt.sni
+	return rt.RoundTripper.RoundTrip(r)
+}
+
+func (rt *roundTripperForSpecificServer) CloseIdleConnections() {
+	if rt.ownRoundtripper {
+		// Safe to close idle connections, since we own the RoundTripper. We
+		// aren't closing other's idle connections.
+		type closeIdler interface {
+			CloseIdleConnections()
+		}
+		if tr, ok := rt.RoundTripper.(closeIdler); ok {
+			tr.CloseIdleConnections()
+		}
+	}
+	// No-op, since we don't want users thinking they are closing idle
+	// connections for this server, when in fact they are closing all idle
+	// connections
+}
+
+// namespacedRoundTripper is a round tripper that prefixes all requests with a
+// given path prefix. It is used to namespace requests to a specific protocol.
+type namespacedRoundTripper struct {
+	http.RoundTripper
+	protocolPrefix    string
+	protocolPrefixRaw string
+}
+
+func (rt *namespacedRoundTripper) GetPeerMetadata() (PeerMeta, error) {
+	if g, ok := rt.RoundTripper.(PeerMetadataGetter); ok {
+		return g.GetPeerMetadata()
+	}
+
+	return nil, fmt.Errorf("can not get peer protocol map. Inner roundtripper does not implement GetPeerMetadata")
+}
+
+// RoundTrip implements http.RoundTripper.
+func (rt *namespacedRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+	if !strings.HasPrefix(r.URL.Path, rt.protocolPrefix) {
+		r.URL.Path = rt.protocolPrefix + r.URL.Path
+	}
+	if !strings.HasPrefix(r.URL.RawPath, rt.protocolPrefixRaw) {
+		r.URL.RawPath = rt.protocolPrefixRaw + r.URL.Path
+	}
+
+	return rt.RoundTripper.RoundTrip(r)
+}
+
+// NamespaceRoundTripper returns an http.RoundTripper that are scoped to the given protocol on the given server.
+func (h *Host) NamespaceRoundTripper(roundtripper http.RoundTripper, p protocol.ID, server peer.ID) (*namespacedRoundTripper, error) {
+	protos, err := h.getAndStorePeerMetadata(roundtripper, server)
+	if err != nil {
+		return &namespacedRoundTripper{}, err
+	}
+
+	v, ok := protos[p]
+	if !ok {
+		return &namespacedRoundTripper{}, fmt.Errorf("no protocol %s for server %s", p, server)
+	}
+
+	path := v.Path
+	if path[len(path)-1] == '/' {
+		// Trim the trailing slash, since it's common to make requests starting with a leading forward slash for the path
+		path = path[:len(path)-1]
+	}
+
+	u, err := url.Parse(path)
+	if err != nil {
+		return &namespacedRoundTripper{}, fmt.Errorf("invalid path %s for protocol %s for server %s", v.Path, p, server)
+	}
+
+	return &namespacedRoundTripper{
+		RoundTripper:      roundtripper,
+		protocolPrefix:    u.Path,
+		protocolPrefixRaw: u.RawPath,
+	}, nil
+}
+
+// NamespacedClient returns an http.Client that is scoped to the given protocol
+// on the given server. It creates a new RoundTripper for each call. If you are
+// creating many namespaced clients, consider creating a round tripper directly
+// and namespacing the roundripper yourself, then creating clients from the
+// namespace round tripper.
+func (h *Host) NamespacedClient(p protocol.ID, server peer.AddrInfo, opts ...RoundTripperOption) (http.Client, error) {
+	rt, err := h.NewConstrainedRoundTripper(server, opts...)
+	if err != nil {
+		return http.Client{}, err
+	}
+
+	nrt, err := h.NamespaceRoundTripper(rt, p, server.ID)
+	if err != nil {
+		return http.Client{}, err
+	}
+
+	return http.Client{Transport: nrt}, nil
+}
+
+// NewConstrainedRoundTripper returns an http.RoundTripper that can fulfill and HTTP
+// request to the given server. It may use an HTTP transport or a stream based
+// transport. It is valid to pass an empty server.ID.
+// If there are multiple addresses for the server, it will pick the best
+// transport (stream vs standard HTTP) using the following rules:
+//   - If PreferHTTPTransport is set, use the HTTP transport.
+//   - If ServerMustAuthenticatePeerID is set, use the stream transport, as the
+//     HTTP transport does not do peer id auth yet.
+//   - If we already have a connection on a stream transport, use that.
+//   - Otherwise, if we have both, use the HTTP transport.
+func (h *Host) NewConstrainedRoundTripper(server peer.AddrInfo, opts ...RoundTripperOption) (http.RoundTripper, error) {
+	options := roundTripperOpts{}
+	for _, o := range opts {
+		options = o(options)
+	}
+
+	if options.serverMustAuthenticatePeerID && server.ID == "" {
+		return nil, fmt.Errorf("server must authenticate peer ID, but no peer ID provided")
+	}
+
+	httpAddrs := make([]ma.Multiaddr, 0, 1) // The common case of a single http address
+	nonHTTPAddrs := make([]ma.Multiaddr, 0, len(server.Addrs))
+
+	firstAddrIsHTTP := false
+
+	for i, addr := range server.Addrs {
+		addr, isHTTP := normalizeHTTPMultiaddr(addr)
+		if isHTTP {
+			if i == 0 {
+				firstAddrIsHTTP = true
+			}
+			httpAddrs = append(httpAddrs, addr)
+		} else {
+			nonHTTPAddrs = append(nonHTTPAddrs, addr)
+		}
+	}
+
+	// Do we have an existing connection to this peer?
+	existingStreamConn := false
+	if server.ID != "" && h.StreamHost != nil {
+		existingStreamConn = len(h.StreamHost.Network().ConnsToPeer(server.ID)) > 0
+	}
+
+	// Currently the HTTP transport can not authenticate peer IDs.
+	if !options.serverMustAuthenticatePeerID && len(httpAddrs) > 0 && (options.preferHTTPTransport || (firstAddrIsHTTP && !existingStreamConn)) {
+		parsed := parseMultiaddr(httpAddrs[0])
+		scheme := "http"
+		if parsed.useHTTPS {
+			scheme = "https"
+		}
+
+		h.createDefaultClientRoundTripper.Do(func() {
+			if h.DefaultClientRoundTripper == nil {
+				h.DefaultClientRoundTripper = &http.Transport{}
+			}
+		})
+		rt := h.DefaultClientRoundTripper
+		ownRoundtripper := false
+		if parsed.sni != parsed.host {
+			// We have a different host and SNI (e.g. using an IP address but specifying a SNI)
+			// We need to make our own transport to support this.
+			rt = rt.Clone()
+			rt.TLSClientConfig.ServerName = parsed.sni
+			ownRoundtripper = true
+		}
+
+		return &roundTripperForSpecificServer{
+			RoundTripper:     rt,
+			ownRoundtripper:  ownRoundtripper,
+			httpHost:         h,
+			server:           server.ID,
+			targetServerAddr: parsed.host + ":" + parsed.port,
+			sni:              parsed.sni,
+			scheme:           scheme,
+		}, nil
+	}
+
+	// Otherwise use a stream based transport
+	if h.StreamHost == nil {
+		return nil, fmt.Errorf("can not use the HTTP transport (either no address or PeerID auth is required), and no stream host provided")
+	}
+	if !existingStreamConn {
+		if server.ID == "" {
+			return nil, fmt.Errorf("can not use the HTTP transport, and no server peer ID provided")
+		}
+	}
+
+	return &streamRoundTripper{h: h.StreamHost, server: server.ID, serverAddrs: nonHTTPAddrs, httpHost: h}, nil
+}
+
+type httpMultiaddr struct {
+	useHTTPS bool
+	host     string
+	port     string
+	sni      string
+}
+
+func parseMultiaddr(addr ma.Multiaddr) httpMultiaddr {
+	out := httpMultiaddr{}
+	ma.ForEach(addr, func(c ma.Component) bool {
+		switch c.Protocol().Code {
+		case ma.P_IP4, ma.P_IP6, ma.P_DNS, ma.P_DNS4, ma.P_DNS6:
+			out.host = c.Value()
+		case ma.P_TCP, ma.P_UDP:
+			out.port = c.Value()
+		case ma.P_TLS, ma.P_HTTPS:
+			out.useHTTPS = true
+		case ma.P_SNI:
+			out.sni = c.Value()
+
+		}
+		return out.host == "" || out.port == "" || !out.useHTTPS || out.sni == ""
+	})
+
+	if out.useHTTPS && out.sni == "" {
+		out.sni = out.host
+	}
+	return out
+}
+
+var httpComponent, _ = ma.NewComponent("http", "")
+var tlsComponent, _ = ma.NewComponent("tls", "")
+
+// normalizeHTTPMultiaddr converts an https multiaddr to a tls/http one.
+// Returns a bool indicating if the input multiaddr has an http (or https) component.
+func normalizeHTTPMultiaddr(addr ma.Multiaddr) (ma.Multiaddr, bool) {
+	isHTTPMultiaddr := false
+	beforeHTTPS, afterIncludingHTTPS := ma.SplitFunc(addr, func(c ma.Component) bool {
+		if c.Protocol().Code == ma.P_HTTP {
+			isHTTPMultiaddr = true
+		}
+
+		if c.Protocol().Code == ma.P_HTTPS {
+			isHTTPMultiaddr = true
+			return true
+		}
+		return false
+	})
+
+	if afterIncludingHTTPS == nil {
+		// No HTTPS component, just return the original
+		return addr, isHTTPMultiaddr
+	}
+
+	_, afterHTTPS := ma.SplitFirst(afterIncludingHTTPS)
+	if afterHTTPS == nil {
+		return ma.Join(beforeHTTPS, tlsComponent, httpComponent), isHTTPMultiaddr
+	}
+
+	return ma.Join(beforeHTTPS, tlsComponent, httpComponent, afterHTTPS), isHTTPMultiaddr
+}
+
+// ProtocolPathPrefix looks up the protocol path in the well-known mapping and
+// returns it. Will only store the peer's protocol mapping if the server ID is
+// provided.
+func (h *Host) getAndStorePeerMetadata(roundtripper http.RoundTripper, server peer.ID) (PeerMeta, error) {
+	if h.peerMetadata == nil {
+		h.peerMetadata = newPeerMetadataCache()
+	}
+	if meta, ok := h.peerMetadata.Get(server); server != "" && ok {
+		return meta, nil
+	}
+
+	req, err := http.NewRequest("GET", "/.well-known/libp2p", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+
+	client := http.Client{Transport: roundtripper}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+
+	meta := PeerMeta{}
+	err = json.NewDecoder(&io.LimitedReader{
+		R: resp.Body,
+		N: peerMetadataLimit,
+	}).Decode(&meta)
+	if err != nil {
+		return nil, err
+	}
+	if server != "" {
+		h.peerMetadata.Add(server, meta)
+	}
+
+	return meta, nil
+}
+
+// SetPeerMetadata adds a peer's protocol metadata to the http host. Useful if
+// you have out-of-band knowledge of a peer's protocol mapping.
+func (h *Host) SetPeerMetadata(server peer.ID, meta PeerMeta) {
+	if h.peerMetadata == nil {
+		h.peerMetadata = newPeerMetadataCache()
+	}
+	h.peerMetadata.Add(server, meta)
+}
+
+// AddPeerMetadata merges the given peer's protocol metadata to the http host. Useful if
+// you have out-of-band knowledge of a peer's protocol mapping.
+func (h *Host) AddPeerMetadata(server peer.ID, meta PeerMeta) {
+	if h.peerMetadata == nil {
+		h.peerMetadata = newPeerMetadataCache()
+	}
+	origMeta, ok := h.peerMetadata.Get(server)
+	if !ok {
+		h.peerMetadata.Add(server, meta)
+		return
+	}
+	for proto, m := range meta {
+		origMeta[proto] = m
+	}
+	h.peerMetadata.Add(server, origMeta)
+}
+
+// GetPeerMetadata gets a peer's cached protocol metadata from the http host.
+func (h *Host) GetPeerMetadata(server peer.ID) (PeerMeta, bool) {
+	if h.peerMetadata == nil {
+		return nil, false
+	}
+	return h.peerMetadata.Get(server)
+}
+
+// RemovePeerMetadata removes a peer's protocol metadata from the http host
+func (h *Host) RemovePeerMetadata(server peer.ID) {
+	if h.peerMetadata == nil {
+		return
+	}
+	h.peerMetadata.Remove(server)
+}
diff --git a/p2p/http/options.go b/p2p/http/options.go
new file mode 100644
index 00000000..0062e593
--- /dev/null
+++ b/p2p/http/options.go
@@ -0,0 +1,24 @@
+package libp2phttp
+
+type RoundTripperOption func(o roundTripperOpts) roundTripperOpts
+
+type roundTripperOpts struct {
+	preferHTTPTransport          bool
+	serverMustAuthenticatePeerID bool
+}
+
+// PreferHTTPTransport tells the roundtripper constructor to prefer using an
+// HTTP transport (as opposed to a libp2p stream transport). Useful, for
+// example, if you want to attempt to leverage HTTP caching.
+func PreferHTTPTransport(o roundTripperOpts) roundTripperOpts {
+	o.preferHTTPTransport = true
+	return o
+}
+
+// ServerMustAuthenticatePeerID tells the roundtripper constructor that we MUST
+// authenticate the Server's PeerID. Note: this currently means we can not use a
+// native HTTP transport (HTTP peer id authentication is not yet implemented: https://github.com/libp2p/specs/pull/564).
+func ServerMustAuthenticatePeerID(o roundTripperOpts) roundTripperOpts {
+	o.serverMustAuthenticatePeerID = true
+	return o
+}
diff --git a/p2p/http/ping/ping.go b/p2p/http/ping/ping.go
new file mode 100644
index 00000000..2c2ad80f
--- /dev/null
+++ b/p2p/http/ping/ping.go
@@ -0,0 +1,67 @@
+package httpping
+
+import (
+	"bytes"
+	"crypto/rand"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+)
+
+const pingSize = 32
+const PingProtocolID = "/http-ping/1"
+
+type Ping struct{}
+
+var _ http.Handler = Ping{}
+
+// ServeHTTP implements http.Handler.
+func (Ping) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	body := [pingSize]byte{}
+	_, err := io.ReadFull(r.Body, body[:])
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Header().Set("Content-Length", strconv.Itoa(pingSize))
+	w.Write(body[:])
+}
+
+// SendPing send an ping request over HTTP. The provided client should be namespaced to the Ping protocol.
+func SendPing(client http.Client) error {
+	body := [pingSize]byte{}
+	_, err := io.ReadFull(rand.Reader, body[:])
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequest("POST", "/", bytes.NewReader(body[:]))
+	req.Header.Set("Content-Type", "application/octet-stream")
+	req.Header.Set("Content-Length", strconv.Itoa(pingSize))
+	if err != nil {
+		return err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+
+	rBody := [pingSize]byte{}
+	_, err = io.ReadFull(resp.Body, rBody[:])
+	if err != nil {
+		return err
+	}
+
+	if !bytes.Equal(body[:], rBody[:]) {
+		return errors.New("ping body mismatch")
+	}
+	return nil
+}


Test patch:
diff --git a/p2p/http/example_test.go b/p2p/http/example_test.go
new file mode 100644
index 00000000..f0b49b0e
--- /dev/null
+++ b/p2p/http/example_test.go
@@ -0,0 +1,353 @@
+package libp2phttp_test
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"strings"
+
+	"github.com/libp2p/go-libp2p"
+	"github.com/libp2p/go-libp2p/core/peer"
+	libp2phttp "github.com/libp2p/go-libp2p/p2p/http"
+	ma "github.com/multiformats/go-multiaddr"
+)
+
+func ExampleHost_withAStockGoHTTPClient() {
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/http")},
+	}
+
+	// A server with a simple echo protocol
+	server.SetHTTPHandler("/echo/1.0.0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/octet-stream")
+		io.Copy(w, r.Body)
+	}))
+	go server.Serve()
+	defer server.Close()
+
+	var serverHTTPPort string
+	var err error
+	for _, a := range server.Addrs() {
+		serverHTTPPort, err = a.ValueForProtocol(ma.P_TCP)
+		if err == nil {
+			break
+		}
+	}
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Make an HTTP request using the Go standard library.
+	resp, err := http.Post("http://127.0.0.1:"+serverHTTPPort+"/echo/1.0.0/", "application/octet-stream", strings.NewReader("Hello HTTP"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(string(body))
+
+	// Output: Hello HTTP
+}
+
+func ExampleHost_listenOnHTTPTransportAndStreams() {
+	serverStreamHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/50124/quic-v1"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50124/http")},
+		StreamHost:        serverStreamHost,
+	}
+	go server.Serve()
+	defer server.Close()
+
+	fmt.Println("Server listening on:", server.Addrs())
+	// Output: Server listening on: [/ip4/127.0.0.1/udp/50124/quic-v1 /ip4/127.0.0.1/tcp/50124/http]
+}
+
+func ExampleHost_overLibp2pStreams() {
+	serverStreamHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/quic-v1"))
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	server := libp2phttp.Host{
+		StreamHost: serverStreamHost,
+	}
+
+	// A server with a simple echo protocol
+	server.SetHTTPHandler("/echo/1.0.0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/octet-stream")
+		io.Copy(w, r.Body)
+	}))
+	go server.Serve()
+	defer server.Close()
+
+	clientStreamHost, err := libp2p.New(libp2p.NoListenAddrs)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	client := libp2phttp.Host{StreamHost: clientStreamHost}
+
+	// Make an HTTP request using the Go standard library, but over libp2p
+	// streams. If the server were listening on an HTTP transport, this could
+	// also make the request over the HTTP transport.
+	httpClient, err := client.NamespacedClient("/echo/1.0.0", peer.AddrInfo{ID: server.PeerID(), Addrs: server.Addrs()})
+
+	// Only need to Post to "/" because this client is namespaced to the "/echo/1.0.0" protocol.
+	resp, err := httpClient.Post("/", "application/octet-stream", strings.NewReader("Hello HTTP"))
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(string(body))
+
+	// Output: Hello HTTP
+}
+
+func ExampleHost_Serve() {
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50221/http")},
+	}
+
+	go server.Serve()
+	defer server.Close()
+
+	fmt.Println(server.Addrs())
+
+	// Output: [/ip4/127.0.0.1/tcp/50221/http]
+}
+
+func ExampleHost_SetHTTPHandler() {
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50222/http")},
+	}
+
+	server.SetHTTPHandler("/hello/1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "text/plain")
+		w.Write([]byte("Hello World"))
+	}))
+
+	go server.Serve()
+	defer server.Close()
+
+	port, err := server.Addrs()[0].ValueForProtocol(ma.P_TCP)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	resp, err := http.Get("http://127.0.0.1:" + port + "/hello/1/")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+
+	// Output: Hello World
+}
+
+func ExampleHost_SetHTTPHandlerAtPath() {
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50224/http")},
+	}
+
+	server.SetHTTPHandlerAtPath("/hello/1", "/other-place/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "text/plain")
+		w.Write([]byte("Hello World"))
+	}))
+
+	go server.Serve()
+	defer server.Close()
+
+	port, err := server.Addrs()[0].ValueForProtocol(ma.P_TCP)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	resp, err := http.Get("http://127.0.0.1:" + port + "/other-place/")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+
+	// Output: Hello World
+}
+
+func ExampleHost_NamespacedClient() {
+	var client libp2phttp.Host
+
+	// Create the server
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50221/http")},
+	}
+
+	server.SetHTTPHandlerAtPath("/hello/1", "/other-place/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "text/plain")
+		w.Write([]byte("Hello World"))
+	}))
+
+	go server.Serve()
+	defer server.Close()
+
+	// Create an http.Client that is namespaced to this protocol.
+	httpClient, err := client.NamespacedClient("/hello/1", peer.AddrInfo{ID: server.PeerID(), Addrs: server.Addrs()})
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	resp, err := httpClient.Get("/")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+
+	// Output: Hello World
+}
+
+func ExampleHost_NamespaceRoundTripper() {
+	var client libp2phttp.Host
+
+	// Create the server
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50223/http")},
+	}
+
+	server.SetHTTPHandler("/hello/1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "text/plain")
+		w.Write([]byte("Hello World"))
+	}))
+
+	go server.Serve()
+	defer server.Close()
+
+	// Create an http.Roundtripper for the server
+	rt, err := client.NewConstrainedRoundTripper(peer.AddrInfo{ID: server.PeerID(), Addrs: server.Addrs()})
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Namespace this roundtripper to a specific protocol
+	rt, err = client.NamespaceRoundTripper(rt, "/hello/1", server.PeerID())
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	resp, err := (&http.Client{Transport: rt}).Get("/")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+
+	// Output: Hello World
+}
+
+func ExampleHost_NewConstrainedRoundTripper() {
+	var client libp2phttp.Host
+
+	// Create the server
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true, // For our example, we'll allow insecure HTTP
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/50225/http")},
+	}
+
+	server.SetHTTPHandler("/hello/1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "text/plain")
+		w.Write([]byte("Hello World"))
+	}))
+
+	go server.Serve()
+	defer server.Close()
+
+	// Create an http.Roundtripper for the server
+	rt, err := client.NewConstrainedRoundTripper(peer.AddrInfo{ID: server.PeerID(), Addrs: server.Addrs()})
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	resp, err := (&http.Client{Transport: rt}).Get("/hello/1")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+
+	// Output: Hello World
+}
+
+func ExampleWellKnownHandler() {
+	var h libp2phttp.WellKnownHandler
+	h.AddProtocolMeta("/hello/1", libp2phttp.ProtocolMeta{
+		Path: "/hello-path/",
+	})
+
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	defer listener.Close()
+	// Serve `.well-known/libp2p`. Note, this is handled automatically if you use the libp2phttp.Host.
+	go http.Serve(listener, &h)
+
+	// Get the `.well-known/libp2p` resource
+	resp, err := http.Get("http://" + listener.Addr().String() + "/.well-known/libp2p")
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	fmt.Println(string(respBody))
+	// Output: {"/hello/1":{"path":"/hello-path/"}}
+
+}
diff --git a/p2p/http/libp2phttp_test.go b/p2p/http/libp2phttp_test.go
new file mode 100644
index 00000000..1114b5a9
--- /dev/null
+++ b/p2p/http/libp2phttp_test.go
@@ -0,0 +1,392 @@
+package libp2phttp_test
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/json"
+	"io"
+	"math/big"
+	"net"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/libp2p/go-libp2p"
+	host "github.com/libp2p/go-libp2p/core/host"
+	"github.com/libp2p/go-libp2p/core/peer"
+	libp2phttp "github.com/libp2p/go-libp2p/p2p/http"
+	httpping "github.com/libp2p/go-libp2p/p2p/http/ping"
+	ma "github.com/multiformats/go-multiaddr"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHTTPOverStreams(t *testing.T) {
+	serverHost, err := libp2p.New(
+		libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/quic-v1"),
+	)
+	require.NoError(t, err)
+
+	httpHost := libp2phttp.Host{StreamHost: serverHost}
+
+	httpHost.SetHTTPHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("hello"))
+	}))
+
+	// Start server
+	go httpHost.Serve()
+	defer httpHost.Close()
+
+	// Start client
+	clientHost, err := libp2p.New(libp2p.NoListenAddrs)
+	require.NoError(t, err)
+	clientHost.Connect(context.Background(), peer.AddrInfo{
+		ID:    serverHost.ID(),
+		Addrs: serverHost.Addrs(),
+	})
+
+	clientRT, err := (&libp2phttp.Host{StreamHost: clientHost}).NewConstrainedRoundTripper(peer.AddrInfo{ID: serverHost.ID()})
+	require.NoError(t, err)
+
+	client := &http.Client{Transport: clientRT}
+
+	resp, err := client.Get("/hello")
+	require.NoError(t, err)
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+
+	require.Equal(t, "hello", string(body))
+}
+
+func TestRoundTrippers(t *testing.T) {
+	serverHost, err := libp2p.New(
+		libp2p.ListenAddrStrings("/ip4/127.0.0.1/udp/0/quic-v1"),
+	)
+	require.NoError(t, err)
+
+	httpHost := libp2phttp.Host{
+		InsecureAllowHTTP: true,
+		StreamHost:        serverHost,
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/http")},
+	}
+
+	httpHost.SetHTTPHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte("hello"))
+	}))
+
+	// Start stream based server
+	go httpHost.Serve()
+	defer httpHost.Close()
+
+	serverMultiaddrs := httpHost.Addrs()
+	serverHTTPAddr := serverMultiaddrs[1]
+
+	testCases := []struct {
+		name                     string
+		setupRoundTripper        func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper
+		expectStreamRoundTripper bool
+	}{
+		{
+			name: "HTTP preferred",
+			setupRoundTripper: func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper {
+				rt, err := clientHTTPHost.NewConstrainedRoundTripper(peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: serverMultiaddrs,
+				}, libp2phttp.PreferHTTPTransport)
+				require.NoError(t, err)
+				return rt
+			},
+		},
+		{
+			name: "HTTP first",
+			setupRoundTripper: func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper {
+				rt, err := clientHTTPHost.NewConstrainedRoundTripper(peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: []ma.Multiaddr{serverHTTPAddr, serverHost.Addrs()[0]},
+				})
+				require.NoError(t, err)
+				return rt
+			},
+		},
+		{
+			name: "No HTTP transport",
+			setupRoundTripper: func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper {
+				rt, err := clientHTTPHost.NewConstrainedRoundTripper(peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: []ma.Multiaddr{serverHost.Addrs()[0]},
+				})
+				require.NoError(t, err)
+				return rt
+			},
+			expectStreamRoundTripper: true,
+		},
+		{
+			name: "Stream transport first",
+			setupRoundTripper: func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper {
+				rt, err := clientHTTPHost.NewConstrainedRoundTripper(peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: []ma.Multiaddr{serverHost.Addrs()[0], serverHTTPAddr},
+				})
+				require.NoError(t, err)
+				return rt
+			},
+			expectStreamRoundTripper: true,
+		},
+		{
+			name: "Existing stream transport connection",
+			setupRoundTripper: func(t *testing.T, clientStreamHost host.Host, clientHTTPHost *libp2phttp.Host) http.RoundTripper {
+				clientStreamHost.Connect(context.Background(), peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: serverHost.Addrs(),
+				})
+				rt, err := clientHTTPHost.NewConstrainedRoundTripper(peer.AddrInfo{
+					ID:    serverHost.ID(),
+					Addrs: []ma.Multiaddr{serverHTTPAddr, serverHost.Addrs()[0]},
+				})
+				require.NoError(t, err)
+				return rt
+			},
+			expectStreamRoundTripper: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Start client
+			clientStreamHost, err := libp2p.New(libp2p.NoListenAddrs)
+			require.NoError(t, err)
+			defer clientStreamHost.Close()
+
+			clientHttpHost := &libp2phttp.Host{StreamHost: clientStreamHost}
+
+			rt := tc.setupRoundTripper(t, clientStreamHost, clientHttpHost)
+			if tc.expectStreamRoundTripper {
+				// Hack to get the private type of this roundtripper
+				typ := reflect.TypeOf(rt).String()
+				require.Contains(t, typ, "streamRoundTripper", "Expected stream based round tripper")
+			}
+
+			for _, tc := range []bool{true, false} {
+				name := ""
+				if tc {
+					name = "with namespaced roundtripper"
+				}
+				t.Run(name, func(t *testing.T) {
+					var resp *http.Response
+					var err error
+					if tc {
+						var h libp2phttp.Host
+						require.NoError(t, err)
+						nrt, err := h.NamespaceRoundTripper(rt, "/hello", serverHost.ID())
+						require.NoError(t, err)
+						client := &http.Client{Transport: nrt}
+						resp, err = client.Get("/")
+						require.NoError(t, err)
+					} else {
+						client := &http.Client{Transport: rt}
+						resp, err = client.Get("/hello/")
+						require.NoError(t, err)
+					}
+					defer resp.Body.Close()
+
+					body, err := io.ReadAll(resp.Body)
+					require.NoError(t, err)
+					require.Equal(t, "hello", string(body))
+				})
+			}
+
+			// Read the .well-known/libp2p resource
+			wk, err := rt.(libp2phttp.PeerMetadataGetter).GetPeerMetadata()
+			require.NoError(t, err)
+
+			expectedMap := make(libp2phttp.PeerMeta)
+			expectedMap["/hello"] = libp2phttp.ProtocolMeta{Path: "/hello/"}
+			require.Equal(t, expectedMap, wk)
+		})
+	}
+}
+
+func TestPlainOldHTTPServer(t *testing.T) {
+	mux := http.NewServeMux()
+	wk := libp2phttp.WellKnownHandler{}
+	mux.Handle("/.well-known/libp2p", &wk)
+
+	mux.Handle("/ping/", httpping.Ping{})
+	wk.AddProtocolMeta(httpping.PingProtocolID, libp2phttp.ProtocolMeta{Path: "/ping/"})
+
+	server := &http.Server{Addr: "127.0.0.1:0", Handler: mux}
+
+	l, err := net.Listen("tcp", server.Addr)
+	require.NoError(t, err)
+
+	go server.Serve(l)
+	defer server.Close()
+
+	// That's all for the server, now the client:
+
+	serverAddrParts := strings.Split(l.Addr().String(), ":")
+
+	testCases := []struct {
+		name         string
+		do           func(*testing.T, *http.Request) (*http.Response, error)
+		getWellKnown func(*testing.T) (libp2phttp.PeerMeta, error)
+	}{
+		{
+			name: "using libp2phttp",
+			do: func(t *testing.T, request *http.Request) (*http.Response, error) {
+				var clientHttpHost libp2phttp.Host
+				rt, err := clientHttpHost.NewConstrainedRoundTripper(peer.AddrInfo{Addrs: []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/" + serverAddrParts[1] + "/http")}})
+				require.NoError(t, err)
+
+				client := &http.Client{Transport: rt}
+				return client.Do(request)
+			},
+			getWellKnown: func(t *testing.T) (libp2phttp.PeerMeta, error) {
+				var clientHttpHost libp2phttp.Host
+				rt, err := clientHttpHost.NewConstrainedRoundTripper(peer.AddrInfo{Addrs: []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/" + serverAddrParts[1] + "/http")}})
+				require.NoError(t, err)
+				return rt.(libp2phttp.PeerMetadataGetter).GetPeerMetadata()
+			},
+		},
+		{
+			name: "using stock http client",
+			do: func(t *testing.T, request *http.Request) (*http.Response, error) {
+				request.URL.Scheme = "http"
+				request.URL.Host = l.Addr().String()
+				request.Host = l.Addr().String()
+
+				client := http.Client{}
+				return client.Do(request)
+			},
+			getWellKnown: func(t *testing.T) (libp2phttp.PeerMeta, error) {
+				client := http.Client{}
+				resp, err := client.Get("http://" + l.Addr().String() + "/.well-known/libp2p")
+				require.NoError(t, err)
+
+				b, err := io.ReadAll(resp.Body)
+				require.NoError(t, err)
+
+				var out libp2phttp.PeerMeta
+				err = json.Unmarshal(b, &out)
+				return out, err
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			body := [32]byte{}
+			_, err = rand.Reader.Read(body[:])
+			require.NoError(t, err)
+			req, err := http.NewRequest(http.MethodPost, "/ping/", bytes.NewReader(body[:]))
+			require.NoError(t, err)
+			resp, err := tc.do(t, req)
+			require.NoError(t, err)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
+			rBody := [32]byte{}
+			_, err = io.ReadFull(resp.Body, rBody[:])
+			require.NoError(t, err)
+			require.Equal(t, body, rBody)
+
+			// Make sure we can get the well known resource
+			protoMap, err := tc.getWellKnown(t)
+			require.NoError(t, err)
+
+			expectedMap := make(libp2phttp.PeerMeta)
+			expectedMap[httpping.PingProtocolID] = libp2phttp.ProtocolMeta{Path: "/ping/"}
+			require.Equal(t, expectedMap, protoMap)
+		})
+	}
+}
+
+func TestHostZeroValue(t *testing.T) {
+	server := libp2phttp.Host{
+		InsecureAllowHTTP: true,
+		ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/http")},
+	}
+	server.SetHTTPHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello")) }))
+	go func() {
+		server.Serve()
+	}()
+	defer server.Close()
+
+	c := libp2phttp.Host{}
+	client, err := c.NamespacedClient("/hello", peer.AddrInfo{Addrs: server.Addrs()})
+	require.NoError(t, err)
+	resp, err := client.Get("/")
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+
+	require.Equal(t, "hello", string(body), "expected response from server")
+}
+
+func TestHTTPS(t *testing.T) {
+	server := libp2phttp.Host{
+		TLSConfig:   selfSignedTLSConfig(t),
+		ListenAddrs: []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/https")},
+	}
+	server.SetHTTPHandler(httpping.PingProtocolID, httpping.Ping{})
+	go func() {
+		server.Serve()
+	}()
+	defer server.Close()
+
+	clientTransport := http.DefaultTransport.(*http.Transport).Clone()
+	clientTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
+	client := libp2phttp.Host{
+		DefaultClientRoundTripper: clientTransport,
+	}
+	httpClient, err := client.NamespacedClient(httpping.PingProtocolID, peer.AddrInfo{Addrs: server.Addrs()})
+	require.NoError(t, err)
+	err = httpping.SendPing(httpClient)
+	require.NoError(t, err)
+}
+
+func selfSignedTLSConfig(t *testing.T) *tls.Config {
+	t.Helper()
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	notBefore := time.Now()
+	notAfter := notBefore.Add(365 * 24 * time.Hour)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	require.NoError(t, err)
+
+	certTemplate := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"Test"},
+		},
+		NotBefore:             notBefore,
+		NotAfter:              notAfter,
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+	}
+
+	derBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &priv.PublicKey, priv)
+	require.NoError(t, err)
+
+	cert := tls.Certificate{
+		Certificate: [][]byte{derBytes},
+		PrivateKey:  priv,
+	}
+
+	tlsConfig := &tls.Config{
+		Certificates: []tls.Certificate{cert},
+	}
+	return tlsConfig
+}