rdv

package module
v0.0.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 15, 2024 License: Apache-2.0 Imports: 20 Imported by: 0

README

Rdv: Relay-assisted p2p connectivity

Go Reference

Rdv (from rendezvous) is a relay-assisted p2p connectivity library that quickly and reliably establishes a TCP connection between two peers in any network topology, with a relay fallback in the rare case where p2p isn't feasible. The library provides:

  • A client for dialing and accepting connections
  • A horizontally scalable http-based server, which acts as a rendezvous point and relay for clients
  • A CLI-tool for testing client and server

Rdv is designed to achieve p2p connectivity in real-world environments, without error-prone monitoring of the network or using stateful and complex port-mapping protocols (like UPnP). Clients use a small amount of resources while establishing connections, but after that there are no idle cost, aside from the TCP connection itself. See how it works below.

Rdv is built to support file transfers in Payload. Note that rdv is experimental and may change at any moment. Always use immature software responsibly. Feel free to use the issue tracker for questions and feedback.

Why?

If you're writing a centralized app, you can get lower latency, higher bandwidth and reduced operational costs, compared to sending p2p data through your servers.

If you're writing a decentralized or hybrid app, you can increase availability and QoS by having an optional set of rdv servers, since relays are necessary in some topologies where p2p isn't feasible. That said, rdv uses TCP, which isn't suitable for massive mesh-like networks with hundreds of thousands of interconnected nodes.

You can also think of rdv as a <1000 LoC, minimal config alternative to WebRTC, but for non-realtime use-cases and BYO authentication.

Quick start

Install the rdv CLI on 2+ clients and the server: go build -o rdv ./cmd from the cloned repo.

# On your server
./rdv serve

# On client A
./rdv dial http://example.com:8080 MY_TOKEN  # Token is an arbitrary string, e.g. a UUID

# On client B
./rdv accept http://example.com:8080 MY_TOKEN  # Both clients need to provide the same token

On the clients, you should see something like:

INFO client: peer connected is_relay=false addr=192.168.1.16:39841 dur=45ms

We got a local network TCP connection established in 45ms, great!

The rdv command connects stdin of A to stdout of B and vice versa, so you can now chat with your peer. You can pipe files and stuff in and out of these commands (but you probably shouldn't, since it's unencrypted):

./rdv dial MY_TOKEN < some_file.zip
./rdv accept MY_TOKEN > some_file.zip

Server setup

Simply add the rdv server to your exising http stack:

func main() {
    server := &rdv.Server{}
    server.Start()
    defer server.Close()
    http.ListenAndServe(":8080", server)
}

You can use TLS, auth tokens, cookies and any middleware you like, since this is just a regular HTTP endpoint. If you put the rdv server on a sub-path, make sure to strip the prefix:

http.Handle("/rdv/", http.StripPrefix("/rdv/", server))

If you need multiple rdv servers, they are entirely independent and scale horizontally. Just make sure that both peers connect to the same server.

Beware of reverse proxies

To increase your chances of p2p connectivity, the rdv server needs to know the source ipv4:port of clients, also known as the observed address. In some environments, this is harder than it should be.

To check whether the rdv server gets the right address, go through the quick start guide above (with the rdv server deployed to your real server environment), and check the CLI output:

# NOTE: This is normal when running locally
WARN client: expected observed to be public ipv4 (check server config)

If you see this warning, you need to figure out who is meddling with your traffic, typically a reverse proxy or a managed cloud provider. Ask them to kindly forward both the source ip and port to your http server, by adding http headers such as X-Forwarded-For and X-Forwarded-Port to inbound http requests. Finally, you need to tell the rdv server to use these headers, by overriding the ObservedAddrFunc in the ServerConfig struct.

Client setup

Unlike with most p2p, clients don't need to monitor network conditions continuously, so they're pretty much stateless and thus easy to use:

client := &rdv.Client{}
token := "abc"

// On the dialing device
conn, _, err := client.Dial("https://example.com/rdv", token)

// On the accepting device
conn, _, err := client.Accept("https://example.com/rdv", token)
Signaling

Both peers need to agree on a server addr and an arbitrary token in order to connect to each other. Typically, the dialer generates a token for each conn and signals the other peer through an application-specific side-channel. You could, for instance, share the endpoint details manually or use a websocket API to notify peers, depending on your application.

Authentication

Even if you are running rdv server behind TLS, this only secures the client-server data. Once a p2p connection is established, it is for security purposes equivalent to standard TCP. You can (and should) authenticate and encrypt rdv conns using e.g. TLS with client certificates or Noise, depending on your application's identity model.

How does it work?

Under the hood, rdv repackages a number of highly effective p2p techniques, notably STUN, TURN and TCP simultaneous open, into a flow based on a single http request, which doubles as a relay if needed:

  Alice                  Server                  Bob
    ┬                      ┬                      ┬
    │                      │                      |
    │            (server_addr, token)             |
    │ <~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~> │  (Signaling)
    │                      │                      |
    │ DIAL /foo           HTTP                    │
    ├────────────────────> │          ACCEPT /foo │  Request
    │                      │ <────────────────────┤
    │                      │                      │
    │           101 Switching Protocols           │
    │ <────────────────────┼────────────────────> │  Response
    │                      │                      │
    │ ACCEPT foo          TCP           DIAL foo  │
    │ <═════════════════════════════════════════> |  Connect
    │                      │                      │
    │ CONTINUE             |                      │
    ├───────────────────── ? ───────────────────> │  Pick
    │                      │                      │
    │ <~~~~~~~~~~~~~~~~~~~ ? ~~~~~~~~~~~~~~~~~~~> │  (Application Data)
    │                      │                      │
    ┴                      ┴                      ┴

Signaling: Before connecting, both peers must agree on the endpoint. This is application-specific.

Request: Each peer opens an SO_REUSEPORT socket, which is used through out the attempt. They dial the rdv server over ipv4 with a http/1.1 DIAL /<token> (or ACCEPT) request:

  • Connection: upgrade
  • Upgrade: rdv/1, for upgrading the http conn to TCP for relaying.
  • Rdv-Self-Addrs: A comma-separated list of self-reported ip:port addresses. By default, all public and private ipv4 and ipv6 default-route addrs are used.
  • Optional application-defined headers (e.g. auth tokens)

Response: Once both peers are present, the server responds with a 101 Switching Protocols:

  • Connection: upgrade
  • Upgrade: rdv/1
  • Rdv-Observed-Addr: The connecting device's server-observed ipv4:port, for diagnostic purposes. This serves the same purpose as STUN.
  • Rdv-Peer-Addrs: A comma-separated list of the peer's candidate addresses, consisting of both the self-reported and observed addresses.
  • Optional application-defined headers.

The connection remains open to be used as a relay. This serves the same purpose as TURN.

Connect: Clients simultenously listen and dial each other on all candidate peer addrs, which helps open up firewalls and NATs for incoming traffic. Both peers write an rdv-specific rdv/1 <METHOD> <TOKEN>\n header on all opened TCP conns (except the relay), to detect misdials. Note that some connections may result in TCP simultenous open.

Pick: The dialing peer picks a connection and writes CONTINUE\n to it. By default, the first available p2p connection is chosen, or the relay is used after one second. All other conns, and the socket, are closed. As a special case, the command OTHER <ip:port>\n is sent to the rdv server, if a p2p conn was chosen, for server metrics.

Application Data: The resulting TCP connection is now ready for use by the application. Remember to secure these connections (see authentication).

Limitations

Rdv is arguably very reliable, compared to other p2p technology. However, it's largely untested in these environments:

  • Client firewall prevents listening on a TCP high-number port
  • Client is using a VPN
  • Client is using an http proxy
  • Client is ipv6-only, or is using ipv4-mapped addresses
  • Client platform is not a major OS supported by https://github.com/libp2p/go-reuseport

Rdv may either work normally, use the relay unnecessarily, or in the worst case, not work at all. Bug reports should include verbose logs and ideally, as much information about the local network as possible.

Future Work

Non-default routes: Rdv does not currently uses the default network route, preventing use of e.g. LTE when WiFi is the default. Alternate routes could help with connectivity and/or allow spreading load across network paths. It is currently not clear how such features would be best implemented and exposed, or how they interact with proxies and VPNs (see above).

Fast start: Rdv is designed to first detect p2p with a timeout, and then fall back to the relay if unsuccessful. This imposes a tradeoff between using a better p2p route (longer timeout) and yielding a usable connection quickly (shorter timeout). Tuning the timeout is also hard, since network conditions and latencies vary a lot. Instead, we could return a usable relay conn immediately, and transparently switch over to an available p2p conn later. That would make this tradeoff (and difficult tuning problem) disappear. This would require changes to the wire protocol, and probably the client library API.

Documentation

Index

Examples

Constants

View Source
const (

	// HTTP methods to establish rdv conns
	DIAL, ACCEPT = "DIAL", "ACCEPT"
)

Variables

View Source
var (
	// ErrBadHandshake is returned from client and server when the http upgrade to rdv failed.
	ErrBadHandshake = errors.New("bad http handshake")

	// ErrProtocol is returned upon an error in the rdv header exhange.
	ErrProtocol = errors.New("rdv protocol error")
)

Functions

func AddrPortFrom added in v0.0.6

func AddrPortFrom(addr net.Addr) netip.AddrPort

Get AddrPort from a TCP- or UDP net.Addr. Returns the zero-value if not supported. Unmaps the ip, unlike net.TCPAddr.AddrPort, see https://github.com/golang/go/issues/53607

Types

type AddrSpace added in v0.0.3

type AddrSpace uint32

A unicast "address space" of an ip addr, for purposes of rdv connectivity. As a bitmask, this type can also be used as a set of addr spaces.

const (

	// Denotes an invalid address space (i.e. not enumerated here)
	SpaceInvalid AddrSpace = 0

	// Public addrs are very common and useful for remote connectivity.
	// Public IPv6 addrs can also provide local connectivity.
	SpacePublic4 AddrSpace = 1 << iota
	SpacePublic6

	// Private IPv4 addrs are very common and useful for local connectivity. IPv6 local (ULA) addrs
	// are less common.
	SpacePrivate4
	SpacePrivate6

	// Link-local IPv4 addrs are not common and IPv6 addrs are not recommended due to zones.
	SpaceLink4
	SpaceLink6

	// Loopback addresses are mostly useful for testing.
	SpaceLoopback4
	SpaceLoopback6
)
const (
	// NoSpaces is the set of no spaces, which can be used to force a relay conn, disabling p2p.
	NoSpaces AddrSpace = 1 << 31

	// PublicSpaces is the set of public ipv4 and ipv6 addrs.
	PublicSpaces AddrSpace = SpacePublic4 | SpacePublic6

	// DefaultSpaces is the set of spaces suitable for p2p WAN & LAN connectivity.
	DefaultSpaces AddrSpace = SpacePublic4 | SpacePublic6 | SpacePrivate4 | SpacePrivate6

	// AllSpaces is the set of all enumerated unicast spaces.
	AllSpaces AddrSpace = ^NoSpaces
)

func AddrSpaceFrom added in v0.0.6

func AddrSpaceFrom(ip netip.Addr) AddrSpace

Returns the address space of the ip address.

func (AddrSpace) Includes added in v0.0.3

func (s AddrSpace) Includes(space AddrSpace) bool

Returns true if the provided space is included in this set of addr spaces

func (AddrSpace) IncludesAddr added in v0.0.6

func (s AddrSpace) IncludesAddr(addr netip.Addr) bool

Returns true if the provided addr is included in this set of addr spaces

func (AddrSpace) MatchesAddr added in v0.0.6

func (s AddrSpace) MatchesAddr(addr netip.Addr) bool

Returns true if the provided addr's space is equal to this exact addr space

func (AddrSpace) String added in v0.0.3

func (s AddrSpace) String() string

type Client

type Client struct {
	// Can be used to allow only a certain set of spaces, such as public IPs only. Defaults to
	// DefaultSpaces which optimal for both LAN and WAN connectivity.
	AddrSpaces AddrSpace

	// Picker used by the dialing side. If nil, defaults to WaitForP2P(time.Second)
	Picker Picker

	// Timeout for the full dial/accept process, if provided. Note this may include DNS, TLS,
	// signaling delay and probing for p2p. We recommend >3s in production.
	Timeout time.Duration

	// Custom TLS config to use with the rdv server.
	TlsConfig *tls.Config

	// Optional logger to use.
	Logger *slog.Logger
}

Client can dial and accept rdv conns. The zero-value is valid.

Example
client := &Client{}
conn, _, err := client.Dial(context.Background(), "http://example.com/", "abc", nil)
if err != nil {
	return
}
defer conn.Close()
// Conn is either a direct p2p, or relayed, TCP-based conn with the other peer
Output:

func (*Client) Accept

func (c *Client) Accept(ctx context.Context, addr, token string, header http.Header) (*Conn, *http.Response, error)

Accept a peer conn, shorthand for Do(ctx, ACCEPT, ...)

func (*Client) Dial

func (c *Client) Dial(ctx context.Context, addr, token string, header http.Header) (*Conn, *http.Response, error)

Dial a peer, shorthand for Do(ctx, DIAL, ...)

func (*Client) Do added in v0.0.6

func (c *Client) Do(ctx context.Context, method, addr, token string, header http.Header) (*Conn, *http.Response, error)

Connect with another peer through an rdv server endpoint.

  • method: must be DIAL or ACCEPT
  • addr: http(s) addr of the rdv server endpoint
  • token: an arbitrary string for matching the two peers, typically chosen by the dialer
  • header: an optional set of http headers included in the request, e.g. for authorization

Returns an ErrBadHandshake error if the server doesn't upgrade the rdv conn properly. A read-only http response is returned if available, whether or not an error occurred.

type Conn

type Conn struct {

	// Metadata about the rdv conn.
	*Meta

	// Reports whether the conn is relayed by an rdv server. Client conns only.
	IsRelay bool

	// Read-only http request. Server conns only.
	Request *http.Request
	// contains filtered or unexported fields
}

Conn is an rdv conn, either p2p or relay, which implements net.Conn.

func (*Conn) Read

func (c *Conn) Read(p []byte) (int, error)

type ErrOther added in v0.0.6

type ErrOther struct {
	// The peer remote addr reported by the dialing client
	Addr netip.AddrPort
}

ErrOther indicates that a p2p conn was established directly between peers. This is the intended outcome, but considered an error server side, which expects to relay data.

func (ErrOther) Error added in v0.0.6

func (e ErrOther) Error() string

type Handler added in v0.0.6

type Handler interface {
	Serve(ctx context.Context, dc, ac *Conn)
}

Handler serves pairs of (dial- and accept) rdv conns. The context is canceled when the server is closed.

Custom implementations should use a Relayer to conform with the rdv protocol.

type Meta

type Meta struct {
	// Request data
	Method, Token string
	SelfAddrs     []netip.AddrPort

	// Response data
	ObservedAddr *netip.AddrPort
	PeerAddrs    []netip.AddrPort
}

Metadata associated with the rdv http handshake between client and server.

type Picker added in v0.0.6

type Picker interface {
	Pick(candidates chan *Conn, cancel func()) (conns []*Conn)
}

Picker decides which conns to use, as they come available. Pick is invoked when peers begin their connection attempts to each other. Implementations must drain the candidates channel and return all conns in order of preference, where conns[0] will be chosen and returned to the user. The channel is closed when a timeout or cancelation occurs upstream, or through the cancel callback.

func PickFirst added in v0.0.6

func PickFirst() Picker

Returns a picker which completes as soon as any conn is available.

func WaitConstant added in v0.0.6

func WaitConstant(timeout time.Duration) Picker

Returns a picker that always waits for a specific amount of time. This is useful for debugging and collecting stats.

func WaitForP2P added in v0.0.6

func WaitForP2P(timeout time.Duration) Picker

Returns a picker that completes when a p2p conn is found, or falls back to the relay if the timeout expires. Experimentally, it takes ~2-3 RTT to establish a p2p conn, whereas the relay conn is already present. Thus, "penalizing" the relay conn by 300-3000 ms is recommended as a balance between finding the best connection, while keeping establishment time reasonable.

Remember to set any application-level dial/accept timeouts much higher than this "picking" timeout, since rdv involves several more steps, like dns lookups and tcp/tls establishment.

type Relayer

type Relayer struct {
	// Specifies a duration of inactivity after which the relay is closed.
	// If zero, there is no timeout.
	IdleTimeout time.Duration

	// The size of copy buffers. By default the [io.Copy] default size is used.
	BufferSize int
}

Relayer handles a pair of rdv conns by relaying data between them. The zero-value can be used.

func (*Relayer) Continue added in v0.0.6

func (r *Relayer) Continue(ctx context.Context, dc, ac *Conn) error

Sends the http upgrade response to both conns and reads the dialer's CONTINUE command. Returns ErrOther if a p2p conn was established.

func (*Relayer) Reject

func (r *Relayer) Reject(dc, ac *Conn, statusCode int, reason string) error

Write an http error and close both conns.

func (*Relayer) Relay added in v0.0.6

func (r *Relayer) Relay(ctx context.Context, w1, w2 io.WriteCloser, r1, r2 io.Reader) (n1 int64, n2 int64, err error)

Copies data from r1 to w1 and from r2 to w2. Both writers are closed upon an IO error, when ctx is canceled, or due to inactivity (see [Relayer.IdleTimeout]). Returns amount of data copied for each pair, and the first error that occurred, often io.EOF. Note that Relayer.Continue must be called beforehand.

In order to monitor or rate-limit conns, use io.TeeReader for r1 and r2.

func (*Relayer) Serve added in v0.0.6

func (r *Relayer) Serve(ctx context.Context, dc, ac *Conn)

Serve implements Handler by connecting and relaying data between peers as necessary. Call Relayer.Continue and Relayer.Relay manually for custom behavior, monitoring, rate-limiting, etc.

type Server

type Server struct {
	// Handler serves relay connections between the two peers. Can be customized to monitor,
	// rate limit or set idle timeouts. If nil, a zero-value [Relayer] is used.
	Handler Handler

	// Amount of time that on peer can wait in the lobby for its partner. Zero means no timeout.
	LobbyTimeout time.Duration

	// Function that extracts the observed addr from requests. If nil, r.RemoteAddr is parsed.
	//
	// If your server is behind a load balancer, reverse proxy or similar, you may need to configure
	// forwarding headers and provide a custom function. See the server setup guide for details.
	ObservedAddrFunc func(r *http.Request) (netip.AddrPort, error)

	// Optional logger to use.
	Logger *slog.Logger
	// contains filtered or unexported fields
}

An rdv server, which implements net/http.Handler.

Example
// Run a plain rdv server without other endpoints
srv := &Server{}
srv.Start()
defer srv.Close()
http.ListenAndServe(":8080", srv)
Output:

func (*Server) Close added in v0.0.6

func (s *Server) Close() error

Calls Server.Shutdown and waits for handlers and internal goroutines to finish. Safe to call multiple times.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

Calls Server.Upgrade and logs the error, if any.

func (*Server) Shutdown added in v0.0.6

func (s *Server) Shutdown()

Evict all clients from lobby and cancels the context passed to handlers. After this, clients are rejected with a 503 error. Suitable for use with http.Server.RegisterOnShutdown. Use Server.Close to wait for all handlers to complete.

func (*Server) Start added in v0.0.6

func (s *Server) Start()

Start rdv server goroutines which manages upgrades and handler invocations.

func (*Server) Upgrade added in v0.0.6

func (s *Server) Upgrade(w http.ResponseWriter, r *http.Request) error

Upgrades the request and adds the client to the lobby for matching. Returns an ErrBadHandshake error if the upgrade failed, or net/http.ErrServerClosed if closed. An http error is written to the client if an error occurs.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL