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:
Ilia Denisov
2026-05-07 11:49:28 +02:00
parent 39b7b2ef29
commit 118f7c17a2
30 changed files with 1009 additions and 772 deletions
@@ -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
}