rdv

package module
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Mar 17, 2024 License: Apache-2.0 Imports: 18 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:

CONNECTED: p2p 192.168.1.16:39841, 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_lastpass_vault.zip  # A publicly available file
./rdv accept ... > vault.zip  # Seriously, don't send anything sensitive

Server setup

Simply add the rdv server to your exising http stack:

func main() {
    server := rdv.NewServer(nil) // Config goes here, if you need any
    http.Handle("/rdv", server)
    go server.Serve()
    http.ListenAndServe(":8080", nil)
}

You can use TLS, auth tokens, cookies and any middleware you like, since this is just a regular HTTP endpoint.

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

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:

NOTICE: missing observed address

If you see that notice, 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

Clients are stateless, so they're pretty easy to use:

// On all clients
client := new(rdv.Client)

// Dialing
token := uuid.NewString()
conn, err := client.Dial("https://example.com/rdv", token)

// Accepting
conn, err := client.Accept("https://example.com/rdv", token)
Signaling

While connecting is easy, you need to signal to the other peer (1) the address of the rdv server (can be hardcoded if there's only one) and (2) the connection token. Typically, the dialer generates a random token and signals your API of the connection attempt, which relays that message to the destination peer, over e.g. a persistent websocket connection.

Authentication

You need to decide how auth and identity should work in your application. Perhaps peers have persistent public keys, or maybe you want the server to vouch for peer identities through e.g. an auth token.

In either case, you should make sure connections are at least authenticated, and preferably also end-to-end encrypted. You can use TLS from the standard library with client certificates, for instance.

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)
    │                      │                      |
    │ http/1.1 DIAL        │                      │
    ├────────────────────> │      http/1.1 ACCEPT │  Request
    │                      │ <────────────────────┤
    │                      │                      │
    │           101 Switching Protocols           │
    │ <────────────────────┼────────────────────> │  Response
    │                      │                      │
    │                  TCP dial                   │
    │ <═════════════════════════════════════════> │  Connect
    │                      │                      │
    │                      │  rdv/1 HELLO         │
    │ <─────────────────── < ─────────────────────┤
    │ <═══════════════════════════════════════════╡
    │                      │                      │
    │ rdv/1 CONFIRM        |                      │
    ├───────────────────── ? ───────────────────> │  Confirm
    │                      │                      │
    │ <~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~> │  (Authentication)
    │                      │                      │
    ┴                      ┴                      ┴

Signaling: Peers agree on an arbitrary one-time token and an rdv server to use, over an application-specific side channel. The token may be generated by the dialing peer.

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 or ACCEPT request:

  • Connection: upgrade
  • Upgrade: rdv/1, for upgrading the http conn to TCP for relaying.
  • Rdv-Token: The chosen token.
  • Rdv-Self-Addrs: A list of self-reported ip:port addresses. By default, all local unicast addrs are used, except private ipv6 addresses.
  • 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 server-observed ipv4:port of the request, for diagnostic purposes. This serves the same purpose as STUN.
  • Rdv-Peer-Addrs: The other peer's candidate addresses, consisting of both the self-reported and the server-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 opens up firewalls and NATs for incoming traffic. The accepting peer sends an rdv-specific rdv/1 HELLO <TOKEN> header on all opened connections (including the relay), to detect misdials. Note that some connections may result in TCP simultenous open.

Confirm: The dialing peer chooses a connection by sending rdv/1 CONFIRM <TOKEN>. By default, the first available p2p connection is chosen, or the relay is used after 2 seconds. All other conns, and the socket, are closed.

Authentication: Peers should authenticate each other over an application-defined protocol, such as TLS or Noise. Authentication is not handled by rdv.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrHijackFailed   = errors.New("failed hijacking http conn")
	ErrBadHandshake   = errors.New("bad http handshake")
	ErrProtocol       = errors.New("rdv protocol error")
	ErrUpgrade        = errors.New("rdv http upgrade error")
	ErrNotChosen      = errors.New("no rdv conn chosen")
	ErrServerClosed   = errors.New("rdv server closed")
	ErrPrivilegedPort = errors.New("bad addr: expected port >=1024")
	ErrInvalidAddr    = errors.New("bad addr: invalid addr")
	ErrDontUse        = errors.New("bad addr: not helpful for connectivity")
)

Functions

func DefaultObservedAddr

func DefaultObservedAddr(r *http.Request) (netip.AddrPort, error)

func DefaultSelfAddrs

func DefaultSelfAddrs(ctx context.Context, socket *Socket) []netip.AddrPort

TODO: Ipv4-mapped v6-addrs

func DefaultServeFunc added in v0.0.5

func DefaultServeFunc(ctx context.Context, dc, ac *Conn)

Handler which simply relays data without timeouts or taps.

Types

type AddrSpace added in v0.0.3

type AddrSpace uint32

An IP address space is derived from an IP address. These are used for connectivity in rdv, and thus don't include multicast etc. order to differentiate between meaningful addrs.

const (

	// Denotes invalid spaces.
	SpaceInvalid AddrSpace = 0

	// Public IPv4 addrs, extremely common and useful for remote connectivity when available.
	SpacePublic4 AddrSpace = 1 << iota

	// Public IPv6 addrs, very common and very useful for both local and remote connectivity.
	SpacePublic6

	// Private IPv4 addrs are very common and useful for local connectivity.
	SpacePrivate4

	// ULA ipv6 addrs are not common (although link-local are).
	SpacePrivate6

	// Link-local ipv4 addrs are not common in most setups.
	SpaceLink4

	// Link-local ipv6 addrs are not recommended with rdv due to zones.
	SpaceLink6

	// Loopback addresses are mostly useful for testing.
	SpaceLoopback
)
const (
	// No spaces won't match any spaces
	NoSpaces AddrSpace = 1 << 31

	// Public IPs only
	PublicSpaces AddrSpace = SpacePublic4 | SpacePublic6

	// Sensible defaults for most users, includes private and public spaces
	DefaultSpaces AddrSpace = SpacePublic4 | SpacePublic6 | SpacePrivate4 | SpacePrivate6

	// All IP spaces
	AllSpaces AddrSpace = ^NoSpaces
)

func FromNetAddr added in v0.0.3

func FromNetAddr(na net.Addr) (addr netip.AddrPort, space AddrSpace)

Get AddrPort and AddrSpace from a TCP net.Addr

func GetAddrSpace added in v0.0.3

func GetAddrSpace(ip netip.Addr) AddrSpace

func (AddrSpace) Includes added in v0.0.3

func (s AddrSpace) Includes(space AddrSpace) bool

func (AddrSpace) String added in v0.0.3

func (s AddrSpace) String() string

type Chooser

type Chooser func(cancel func(), candidates chan *Conn) (chosen *Conn, unchosen []*Conn)

Chooser is called once a direct connection is started. All conns on lobby are ready to go The chan is closed when either: - The parents timeout is reached - The parents context is canceled - The picker calls the cancel function (optional) The picker must drain the lobby channel. The picker must return all conns. Chosen may be nil

func RelayPenalty

func RelayPenalty(penalty time.Duration) Chooser

A chooser which gives the relay some penalty How long the dialer waits for a p2p connection, before falling back on using the relay. If zero, the relay is used as soon as available, but p2p can still be faster. A larger value increases the chances of p2p, at the cost of delaying the connection. If exceeding ConnTimeout, the relay will not be used.

type Client

type Client struct {
	// contains filtered or unexported fields
}

func NewClient added in v0.0.3

func NewClient(cfg *ClientConfig) *Client

func (*Client) Accept

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

func (*Client) Dial

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

type ClientConfig added in v0.0.3

type ClientConfig struct {
	// TLS config to use with the rdv server.
	TlsConfig *tls.Config

	// Strategy for choosing the conn to use. If nil, defaults to RelayPenalty(time.Second)
	DialChooser Chooser

	// Can be used to allow only a certain set of spaces, such as public IPs only. Defaults to
	// DefaultSpaces which optimal for both local and global peering.
	AddrSpaces AddrSpace

	// Defaults to using all available interface addresses. The list is automatically filtered by
	// AddrSpaces. This is called on each Dial or Accept, so it should be quick (ideally < 100ms).
	// Can be overridden if port mapping protocols are needed.
	SelfAddrFunc func(ctx context.Context, socket *Socket) []netip.AddrPort

	// Logger, by default slog.Default()
	Logger *slog.Logger
}

type Conn

type Conn struct {
	net.Conn
	// contains filtered or unexported fields
}

func (*Conn) IsRelay

func (c *Conn) IsRelay() bool

func (*Conn) Meta

func (c *Conn) Meta() *Meta

func (*Conn) Read

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

func (*Conn) Request added in v0.0.5

func (c *Conn) Request() *http.Request

Returns the http request for this conn. Read-only, so don't use its context or body.

type Meta

type Meta struct {
	ServerAddr           string
	IsDialer             bool
	Token                string
	ObservedAddr         *netip.AddrPort
	SelfAddrs, PeerAddrs []netip.AddrPort
}

type Relayer

type Relayer struct {
	DialTap, AcceptTap io.Writer

	// At least this much inactivity is allowed on both peers before terminating the connection.
	// Recommended at least 30s to account for network conditions and
	// application level heartbeats. Zero means no timeout.
	// As relays may serve a lot of traffic, activity is checked at an interval.
	IdleTimeout time.Duration
}

A relayer handles a pair of rdv conns. The zero-value can be used.

func (*Relayer) Reject

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

func (*Relayer) Run

func (r *Relayer) Run(ctx context.Context, dc, ac *Conn) (dn int64, an int64, err error)

Runs the relay service. Return actual data transferred and the first error that occurred. In case one end closed the connection in a normal manner, the error is io.EOF.

type Server

type Server struct {
	// contains filtered or unexported fields
}

func NewServer

func NewServer(cfg *ServerConfig) *Server

func (*Server) AddClient

func (l *Server) AddClient(w http.ResponseWriter, req *http.Request) error

func (*Server) Serve

func (l *Server) Serve(ctx context.Context) error

Runs the goroutines associated with the Server.

func (*Server) ServeHTTP

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

type ServerConfig

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

	// Function to serve a relay connection between dialer and server.
	// The provided context is canceled when the server is closed.
	// The function is responsible for closing conns.
	// Used to customize monitoring, rate limiting, idle timeouts relating to relay
	// connections. Defaults to `DefaultServeFunc`.
	ServeFunc func(ctx context.Context, dc, ac *Conn)

	// Determines the remote addr:port from the client request, and adds it to the set of
	// candidate addrs sent to the other peer. If nil, `req.RemoteAddr` is used.
	// If your server is behind a load balancer, reverse proxy or similar, you may need to extract
	// the address using forwarding headers. To disable this feature, return an error.
	// See the server setup guide for details.
	ObservedAddrFunc func(req *http.Request) (netip.AddrPort, error)

	// Logging function.
	Logger *slog.Logger
}

type Socket

type Socket struct {

	// A dual-stack (ipv4/6) TCP listener.
	//
	// TODO: Should this be refactored into two single-stack listeners, in order to support
	// non dual-stack systems? And if so, can the ports be different? See also NAT64.
	net.Listener

	/// Dialers for ipv4 and ipv6.
	D4, D6 *net.Dialer

	/// Port number for the socket, both stacks.
	Port uint16

	// TLS config for https.
	//
	// TODO: Higher level protocols should be one layer above sockets?
	TlsConfig *tls.Config
}

An SO_REUSEPORT TCP socket suitable for NAT traversal/hole punching, over both ipv4 and ipv6. Usually, higher level abstractions should be used.

func NewSocket

func NewSocket(ctx context.Context, port uint16, tlsConf *tls.Config) (*Socket, error)

func (*Socket) DialContext

func (s *Socket) DialContext(ctx context.Context, network, address string) (net.Conn, error)

func (*Socket) DialIPContext

func (s *Socket) DialIPContext(ctx context.Context, addr netip.AddrPort) (net.Conn, error)

func (*Socket) DialURLContext

func (s *Socket) DialURLContext(ctx context.Context, network string, url *urlpkg.URL) (net.Conn, error)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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