// Package transport provides the low-level QUIC+TLS connection to a quicprochat server // and bootstraps the Cap'n Proto RPC client over a single bidirectional QUIC stream. package transport import ( "context" "crypto/tls" "crypto/x509" "fmt" "os" "time" capnp "capnproto.org/go/capnp/v3" "capnproto.org/go/capnp/v3/rpc" "github.com/quic-go/quic-go" "quicprochat.dev/sdk/go/proto/node" ) // ConnectOptions configures the connection to a quicprochat server. type ConnectOptions struct { // Addr is the host:port of the server (e.g. "127.0.0.1:5001"). Addr string // InsecureSkipVerify disables TLS certificate verification (dev mode only). InsecureSkipVerify bool // CACertPath is the path to a PEM-encoded CA certificate for production use. // When set, the server's certificate is verified against this CA. CACertPath string // ConnectTimeout is the maximum time to wait for the QUIC handshake. // Defaults to 10 seconds if zero. ConnectTimeout time.Duration } // Connection holds the QUIC connection, Cap'n Proto RPC state, and the // bootstrapped NodeService client. type Connection struct { quicConn *quic.Conn stream *quic.Stream rpcConn *rpc.Conn client node.NodeService } // Connect establishes a QUIC+TLS 1.3 connection to the server, opens a single // bidirectional stream, and bootstraps the Cap'n Proto RPC NodeService client. func Connect(ctx context.Context, opts ConnectOptions) (*Connection, error) { tlsCfg, err := buildTLSConfig(opts) if err != nil { return nil, fmt.Errorf("transport: build TLS config: %w", err) } quicCfg := &quic.Config{ MaxIdleTimeout: 300 * time.Second, KeepAlivePeriod: 30 * time.Second, } timeout := opts.ConnectTimeout if timeout == 0 { timeout = 10 * time.Second } dialCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() qConn, err := quic.DialAddr(dialCtx, opts.Addr, tlsCfg, quicCfg) if err != nil { return nil, fmt.Errorf("transport: QUIC dial %s: %w", opts.Addr, err) } stream, err := qConn.OpenStream() if err != nil { qConn.CloseWithError(1, "failed to open stream") return nil, fmt.Errorf("transport: open bidi stream: %w", err) } capnpTransport := rpc.NewStreamTransport(stream) rpcConn := rpc.NewConn(capnpTransport, nil) client := node.NodeService(rpcConn.Bootstrap(ctx)) return &Connection{ quicConn: qConn, stream: stream, rpcConn: rpcConn, client: client, }, nil } // Client returns the bootstrapped NodeService RPC client. func (c *Connection) Client() node.NodeService { return c.client } // Close gracefully shuts down the RPC connection and underlying QUIC transport. func (c *Connection) Close() error { capnp.Client(c.client).Release() c.rpcConn.Close() return c.quicConn.CloseWithError(0, "client closed") } func buildTLSConfig(opts ConnectOptions) (*tls.Config, error) { cfg := &tls.Config{ NextProtos: []string{"capnp"}, MinVersion: tls.VersionTLS13, } if opts.InsecureSkipVerify { cfg.InsecureSkipVerify = true return cfg, nil } if opts.CACertPath != "" { caPEM, err := os.ReadFile(opts.CACertPath) if err != nil { return nil, fmt.Errorf("read CA cert %s: %w", opts.CACertPath, err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { return nil, fmt.Errorf("CA cert %s: no valid PEM certificates found", opts.CACertPath) } cfg.RootCAs = pool } return cfg, nil }