// Package relayclient implements the client side of an iroh relay connection. // // A client dials a relay server over a secure WebSocket (standard WebPKI TLS, // wire-compatible with iroh), negotiates the relay protocol version, completes // the authentication handshake, and then exchanges relay frames // ([relayproto.ClientToRelayMsg] / [relayproto.RelayToClientMsg]). // // It is a port of the client side of iroh-relay/src/client.
package relayclient import ( ) // relayPath is the HTTP path of the relay WebSocket endpoint. const relayPath = "/relay" // maxFrameSize is the maximum relay frame size (1 MiB), matching MAX_FRAME_SIZE. const maxFrameSize = 1024 * 1024 // Errors returned when connecting to a relay. var ( ErrBadVersionHeader = errors.New("relayclient: relay returned an unsupported protocol version") ErrHandshake = errors.New("relayclient: handshake failed") ) // Options configures a relay client dial. type Options struct { // SecretKey is the client's secret key, used for the authentication // handshake. Required. SecretKey key.SecretKey // TLSConfig overrides the TLS configuration used for the WSS connection. // If nil, the default WebPKI verification is used. TLSConfig *tls.Config // HTTPClient overrides the HTTP client used for the WebSocket dial. HTTPClient *http.Client // AuthToken, if set, is sent as a Bearer token in the Authorization header. AuthToken string } // Client is a connected relay client. It is not safe for concurrent use by // multiple senders or multiple receivers; use one goroutine for Send and one // for Recv. type Client struct { conn *websocket.Conn version relayproto.ProtocolVersion url netaddr.RelayURL } // Connect dials the relay at u and completes the protocol handshake. func ( context.Context, netaddr.RelayURL, Options) (*Client, error) { , := websocketURL() if != nil { return nil, } := http.Header{} if .AuthToken != "" { .Set("Authorization", "Bearer "+.AuthToken) } := .HTTPClient if == nil { = keyMaterialHTTPClient(.SecretKey, .TLSConfig) } , , := websocket.Dial(, , dialOptions(, )) if != nil { return nil, fmt.Errorf("relayclient: dial %s: %w", , ) } .SetReadLimit(maxFrameSize) , := relayproto.ParseProtocolVersion(.Header.Get("Sec-WebSocket-Protocol")) if ! { // coder/websocket also exposes the negotiated subprotocol on the conn. , = relayproto.ParseProtocolVersion(.Subprotocol()) } if ! { .Close(websocket.StatusProtocolError, "bad version") return nil, fmt.Errorf("%w: %q", ErrBadVersionHeader, .Header.Get("Sec-WebSocket-Protocol")) } := &Client{conn: , version: , url: } if := .handshake(, .SecretKey); != nil { .Close(websocket.StatusInternalError, "handshake failed") return nil, } return , nil } func keyMaterialHTTPClient( key.SecretKey, *tls.Config) *http.Client { return &http.Client{Transport: keyMaterialTransport{secretKey: , tlsConfig: }} } type keyMaterialTransport struct { secretKey key.SecretKey tlsConfig *tls.Config } func ( keyMaterialTransport) ( *http.Request) (*http.Response, error) { = .Clone(.Context()) := &http.Transport{TLSClientConfig: .tlsConfig} .DialTLSContext = func( context.Context, , string) (net.Conn, error) { := .tlsConfig if == nil { = &tls.Config{} } else { = .Clone() } if .ServerName == "" { , , := net.SplitHostPort() if != nil { = } .ServerName = } := tls.Dialer{Config: } , := .DialContext(, , ) if != nil { return nil, } , := .(*tls.Conn) if ! { return , nil } := .ConnectionState() if , := relayproto.NewKeyMaterialClientAuth(.secretKey, &); == nil { if , := .HeaderValue(); == nil { .Header.Set(relayproto.ClientAuthHeader, ) } } return , nil } return .RoundTrip() } // Version returns the negotiated relay protocol version. func ( *Client) () relayproto.ProtocolVersion { return .version } // Send sends a client-to-relay message. func ( *Client) ( context.Context, relayproto.ClientToRelayMsg) error { return .conn.Write(, websocket.MessageBinary, .AppendTo(nil)) } // Recv receives the next relay-to-client message. func ( *Client) ( context.Context) (relayproto.RelayToClientMsg, error) { , , := .conn.Read() if != nil { return relayproto.RelayToClientMsg{}, } return relayproto.ParseRelayToClientMsgNoCopy(, .version) } // Close closes the relay connection. func ( *Client) () error { return .conn.Close(websocket.StatusNormalClosure, "") } // handshake runs the challenge-based authentication handshake. The relay sends a // ServerChallenge, the client replies with a signed ClientAuth, and the relay // responds with ServerConfirmsAuth or ServerDeniesAuth. func ( *Client) ( context.Context, key.SecretKey) error { , := context.WithTimeout(, 30*time.Second) defer () , := .readFrame() if != nil { return fmt.Errorf("%w: reading challenge: %v", ErrHandshake, ) } switch f := .(type) { case *relayproto.ServerChallenge: := relayproto.NewClientAuth(, *) if := .writeFrame(, .AppendTo(nil)); != nil { return fmt.Errorf("%w: sending client auth: %v", ErrHandshake, ) } return .expectConfirmation() case *relayproto.ServerConfirmsAuth: return nil case *relayproto.ServerDeniesAuth: return fmt.Errorf("%w: %s", relayproto.ErrServerDeniedAuth, .Reason) default: return fmt.Errorf("%w: unexpected first frame %T", ErrHandshake, ) } } func ( *Client) ( context.Context) error { , := .readFrame() if != nil { return fmt.Errorf("%w: reading confirmation: %v", ErrHandshake, ) } switch f := .(type) { case *relayproto.ServerConfirmsAuth: return nil case *relayproto.ServerDeniesAuth: return fmt.Errorf("%w: %s", relayproto.ErrServerDeniedAuth, .Reason) default: return fmt.Errorf("%w: unexpected frame %T", ErrHandshake, ) } } func ( *Client) ( context.Context) (any, error) { , , := .conn.Read() if != nil { return nil, } return relayproto.ParseHandshakeFrame() } func ( *Client) ( context.Context, []byte) error { return .conn.Write(, websocket.MessageBinary, ) } // websocketURL converts a relay URL (http/https) into the ws/wss dial URL with // the relay path, matching the Rust client. func websocketURL( netaddr.RelayURL) (string, error) { := .URL() if == nil { return "", errors.New("relayclient: empty relay url") } := * .Path = relayPath switch strings.ToLower(.Scheme) { case "http", "ws": .Scheme = "ws" default: .Scheme = "wss" } return .String(), nil } var _ = url.URL{}