phase 4: connectrpc on the gateway authenticated edge
Replace the native-gRPC server bootstrap with a single `connectrpc.com/connect` HTTP/h2c listener. Connect-Go natively serves Connect, gRPC, and gRPC-Web on the same port, so browsers can now reach the authenticated surface without giving up the gRPC framing native and desktop clients may use later. The decorator stack (envelope → session → payload-hash → signature → freshness/replay → rate-limit → routing/push) is reused unchanged behind a small Connect → gRPC adapter and a `grpc.ServerStream` shim around `*connect.ServerStream`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,30 +5,34 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
gatewayauthn "galaxy/gateway/authn"
|
||||
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||
"galaxy/gateway/proto/galaxy/gateway/v1/gatewayv1connect"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// SignedGatewayClient drives the authenticated gRPC surface of the
|
||||
// SignedGatewayClient drives the authenticated edge surface of the
|
||||
// gateway from tests. It signs ExecuteCommand envelopes with the
|
||||
// session's Ed25519 private key, verifies response signatures with
|
||||
// the gateway's response-signer public key, and exposes a
|
||||
// SubscribeEvents helper.
|
||||
// SubscribeEvents helper. The client speaks Connect over HTTP/2
|
||||
// cleartext (h2c) — the gateway listener supports that natively
|
||||
// alongside gRPC and gRPC-Web on the same port.
|
||||
type SignedGatewayClient struct {
|
||||
conn *grpc.ClientConn
|
||||
edge gatewayv1.EdgeGatewayClient
|
||||
httpClient *http.Client
|
||||
edge gatewayv1connect.EdgeGatewayClient
|
||||
deviceSID string
|
||||
privateKey ed25519.PrivateKey
|
||||
respPub ed25519.PublicKey
|
||||
@@ -55,25 +59,42 @@ func EncodePublicKey(pub ed25519.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(pub)
|
||||
}
|
||||
|
||||
// DialGateway opens a gRPC connection to gateway's authenticated
|
||||
// surface and prepares a signing client bound to deviceSID.
|
||||
func DialGateway(ctx context.Context, addr string, deviceSID string, privateKey ed25519.PrivateKey, respPub ed25519.PublicKey) (*SignedGatewayClient, error) {
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial gateway: %w", err)
|
||||
// DialGateway opens a Connect (HTTP/2 cleartext) client against the
|
||||
// gateway's authenticated edge listener at addr ("host:port") and
|
||||
// prepares a signing client bound to deviceSID.
|
||||
func DialGateway(_ context.Context, addr string, deviceSID string, privateKey ed25519.PrivateKey, respPub ed25519.PublicKey) (*SignedGatewayClient, error) {
|
||||
if addr == "" {
|
||||
return nil, fmt.Errorf("dial gateway: empty addr")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: &http2.Transport{
|
||||
AllowHTTP: true,
|
||||
DialTLSContext: func(ctx context.Context, network, target string, _ *tls.Config) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, target)
|
||||
},
|
||||
},
|
||||
}
|
||||
edge := gatewayv1connect.NewEdgeGatewayClient(httpClient, "http://"+addr)
|
||||
|
||||
return &SignedGatewayClient{
|
||||
conn: conn,
|
||||
edge: gatewayv1.NewEdgeGatewayClient(conn),
|
||||
httpClient: httpClient,
|
||||
edge: edge,
|
||||
deviceSID: deviceSID,
|
||||
privateKey: privateKey,
|
||||
respPub: respPub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection.
|
||||
// Close releases idle HTTP/2 connections held by the underlying transport.
|
||||
// The Connect client itself is stateless, so this is best-effort.
|
||||
func (c *SignedGatewayClient) Close() error {
|
||||
return c.conn.Close()
|
||||
if c.httpClient != nil {
|
||||
if transport, ok := c.httpClient.Transport.(*http2.Transport); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteOptions tunes one ExecuteCommand call. The zero value
|
||||
@@ -81,11 +102,11 @@ func (c *SignedGatewayClient) Close() error {
|
||||
// need a fixed request_id (anti-replay) or a stale timestamp
|
||||
// (freshness window) override the relevant fields.
|
||||
type ExecuteOptions struct {
|
||||
RequestID string
|
||||
TimestampMS int64
|
||||
OverrideSignature []byte
|
||||
OverridePayloadHash []byte
|
||||
OverrideSessionID string
|
||||
RequestID string
|
||||
TimestampMS int64
|
||||
OverrideSignature []byte
|
||||
OverridePayloadHash []byte
|
||||
OverrideSessionID string
|
||||
OverrideProtocolVersion string
|
||||
}
|
||||
|
||||
@@ -155,10 +176,11 @@ func (c *SignedGatewayClient) Execute(ctx context.Context, messageType string, p
|
||||
}
|
||||
atomic.AddUint64(&c.requestSeq, 1)
|
||||
|
||||
resp, err := c.edge.ExecuteCommand(ctx, req)
|
||||
respWrap, err := c.edge.ExecuteCommand(ctx, connect.NewRequest(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := respWrap.Msg
|
||||
|
||||
respHash := sha256.Sum256(resp.GetPayloadBytes())
|
||||
if string(respHash[:]) != string(resp.GetPayloadHash()) {
|
||||
@@ -202,7 +224,7 @@ func (c *SignedGatewayClient) SubscribeEvents(ctx context.Context, messageType s
|
||||
PayloadHash: emptyHash[:],
|
||||
}))
|
||||
|
||||
stream, err := c.edge.SubscribeEvents(ctx, &gatewayv1.SubscribeEventsRequest{
|
||||
stream, err := c.edge.SubscribeEvents(ctx, connect.NewRequest(&gatewayv1.SubscribeEventsRequest{
|
||||
ProtocolVersion: protocolVersion,
|
||||
DeviceSessionId: c.deviceSID,
|
||||
MessageType: messageType,
|
||||
@@ -210,7 +232,7 @@ func (c *SignedGatewayClient) SubscribeEvents(ctx context.Context, messageType s
|
||||
RequestId: requestID,
|
||||
PayloadHash: emptyHash[:],
|
||||
Signature: signature,
|
||||
})
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open subscribe events: %w", err)
|
||||
}
|
||||
@@ -219,41 +241,39 @@ func (c *SignedGatewayClient) SubscribeEvents(ctx context.Context, messageType s
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(events)
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
events <- ev
|
||||
defer func() { _ = stream.Close() }()
|
||||
for stream.Receive() {
|
||||
events <- stream.Msg()
|
||||
}
|
||||
errs <- stream.Err()
|
||||
}()
|
||||
return events, errs, nil
|
||||
}
|
||||
|
||||
// IsUnauthenticated reports whether err is a gRPC Unauthenticated
|
||||
// status, useful for negative-path edge tests.
|
||||
// IsUnauthenticated reports whether err carries Connect's
|
||||
// CodeUnauthenticated, useful for negative-path edge tests.
|
||||
func IsUnauthenticated(err error) bool {
|
||||
return status.Code(err) == codes.Unauthenticated
|
||||
return connect.CodeOf(err) == connect.CodeUnauthenticated
|
||||
}
|
||||
|
||||
// IsInvalidArgument reports whether err is a gRPC InvalidArgument
|
||||
// status (used for malformed envelopes and unsupported
|
||||
// IsInvalidArgument reports whether err carries Connect's
|
||||
// CodeInvalidArgument (used for malformed envelopes and unsupported
|
||||
// protocol_version).
|
||||
func IsInvalidArgument(err error) bool {
|
||||
return status.Code(err) == codes.InvalidArgument
|
||||
return connect.CodeOf(err) == connect.CodeInvalidArgument
|
||||
}
|
||||
|
||||
// IsResourceExhausted reports whether err is a gRPC
|
||||
// ResourceExhausted status (used for replay rejection).
|
||||
// IsResourceExhausted reports whether err carries Connect's
|
||||
// CodeResourceExhausted (used for replay rejection or rate-limit
|
||||
// rejections).
|
||||
func IsResourceExhausted(err error) bool {
|
||||
return status.Code(err) == codes.ResourceExhausted
|
||||
return connect.CodeOf(err) == connect.CodeResourceExhausted
|
||||
}
|
||||
|
||||
// IsFailedPrecondition reports whether err is a gRPC
|
||||
// FailedPrecondition status. The gateway uses this code for replay
|
||||
// IsFailedPrecondition reports whether err carries Connect's
|
||||
// CodeFailedPrecondition. The gateway uses this code for replay
|
||||
// rejections (the canonical envelope was authentic but the
|
||||
// `request_id` was already consumed).
|
||||
func IsFailedPrecondition(err error) bool {
|
||||
return status.Code(err) == codes.FailedPrecondition
|
||||
return connect.CodeOf(err) == connect.CodeFailedPrecondition
|
||||
}
|
||||
Reference in New Issue
Block a user