Skip to content

Commit f18adbe

Browse files
committed
node: Add ability to pass custom headers to Sui gRPC
1 parent 87111a6 commit f18adbe

7 files changed

Lines changed: 303 additions & 8 deletions

File tree

node/cmd/guardiand/node.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ var (
148148
movementHandle *string
149149

150150
suiRPC *string
151+
suiRPCHeaders *[]string
151152
suiMoveEventType *string
152153

153154
solanaRPC *string
@@ -418,6 +419,7 @@ func init() {
418419
movementHandle = NodeCmd.Flags().String("movementHandle", "", "movement handle")
419420

420421
suiRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "suiRPC", "Sui gRPC endpoint", "sui:443", []string{""})
422+
suiRPCHeaders = NodeCmd.Flags().StringSlice("suiRPCHeaders", []string{}, "Sui gRPC headers as key=value pairs")
421423
suiMoveEventType = NodeCmd.Flags().String("suiMoveEventType", "", "Sui move event type for publish_message")
422424

423425
solanaRPC = node.RegisterFlagWithValidationOrFail(NodeCmd, "solanaRPC", "Solana RPC URL (required)", "http://solana-devnet:8899", []string{"http", "https"})
@@ -1775,6 +1777,7 @@ func runNode(cmd *cobra.Command, args []string) {
17751777
NetworkID: "sui",
17761778
ChainID: vaa.ChainIDSui,
17771779
Rpc: *suiRPC,
1780+
RpcHeaders: *suiRPCHeaders,
17781781
SuiMoveEventType: *suiMoveEventType,
17791782
TxVerifierEnabled: slices.Contains(txVerifierChains, vaa.ChainIDSui),
17801783
}

node/cmd/txverifier/sui.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
// CLI args
2727
var (
2828
suiRPC *string
29+
suiRPCHeaders *[]string
2930
suiProcessWormholeScanEvents *bool
3031
suiEnvironment *string
3132
suiDigest *string
@@ -45,6 +46,7 @@ var TransferVerifierCmdSui = &cobra.Command{
4546
// CLI parameters
4647
func init() {
4748
suiRPC = TransferVerifierCmdSui.Flags().String("suiRPC", "", "Sui gRPC endpoint, host:port (e.g. fullnode.mainnet.sui.io:443, or sui:443 in devnet)")
49+
suiRPCHeaders = TransferVerifierCmdSui.Flags().StringSlice("suiRPCHeaders", []string{}, "Sui gRPC headers as key=value pairs")
4850
suiProcessWormholeScanEvents = TransferVerifierCmdSui.Flags().Bool("suiProcessWormholeScanEvents", false, "Indicate whether the Sui transfer verifier should process WormholeScan events")
4951
suiDigest = TransferVerifierCmdSui.Flags().String("suiDigest", "", "If provided, perform transaction verification on this single digest")
5052
suiEnvironment = TransferVerifierCmdSui.Flags().String("suiEnvironment", "mainnet", "The Sui environment to connect to. Supported values: mainnet, testnet and devnet")
@@ -142,6 +144,12 @@ func runTransferVerifierSui(cmd *cobra.Command, args []string) {
142144
if *suiEnvironment == "devnet" {
143145
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
144146
}
147+
headerOpts, err := suiclient.GrpcHeaderDialOptions(*suiRPCHeaders)
148+
if err != nil {
149+
logger.Fatal("Invalid Sui gRPC headers", zap.Error(err))
150+
}
151+
dialOpts = append(dialOpts, headerOpts...)
152+
145153
client, err := suiclient.NewSuiGrpcClient(*suiRPC, logger, dialOpts...)
146154
if err != nil {
147155
logger.Fatal("Failed to create Sui gRPC client", zap.Error(err))

node/pkg/suiclient/grpc_headers.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package suiclient
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"google.golang.org/grpc"
9+
"google.golang.org/grpc/metadata"
10+
)
11+
12+
// GrpcHeaderDialOptions converts key=value header specs from command line args
13+
// into gRPC client interceptors that attach the headers as outgoing metadata
14+
func GrpcHeaderDialOptions(headerSpecs []string) ([]grpc.DialOption, error) {
15+
headers, err := parseGrpcHeaderSpecs(headerSpecs)
16+
if err != nil {
17+
return nil, err
18+
}
19+
if len(headers) == 0 {
20+
return nil, nil
21+
}
22+
23+
headers = headers.Copy()
24+
return []grpc.DialOption{
25+
grpc.WithChainUnaryInterceptor(grpcHeaderUnaryInterceptor(headers)),
26+
grpc.WithChainStreamInterceptor(grpcHeaderStreamInterceptor(headers)),
27+
}, nil
28+
}
29+
30+
func parseGrpcHeaderSpecs(headerSpecs []string) (metadata.MD, error) {
31+
headers := metadata.MD{}
32+
33+
for _, spec := range headerSpecs {
34+
spec = strings.TrimSpace(spec)
35+
if spec == "" {
36+
continue
37+
}
38+
39+
key, value, ok := strings.Cut(spec, "=")
40+
if !ok {
41+
return nil, fmt.Errorf("invalid gRPC header: expected key=value")
42+
}
43+
44+
key = strings.ToLower(strings.TrimSpace(key))
45+
value = strings.TrimSpace(value)
46+
47+
if err := validateGrpcMetadataKey(key); err != nil {
48+
return nil, err
49+
}
50+
if value == "" {
51+
return nil, fmt.Errorf("invalid gRPC header %q: value must not be empty", key)
52+
}
53+
if _, exists := headers[key]; exists {
54+
return nil, fmt.Errorf("duplicate gRPC header key %q", key)
55+
}
56+
57+
headers.Append(key, value)
58+
}
59+
60+
return headers, nil
61+
}
62+
63+
func validateGrpcMetadataKey(key string) error {
64+
if key == "" {
65+
return fmt.Errorf("invalid gRPC header: key must not be empty")
66+
}
67+
68+
for _, r := range key {
69+
switch {
70+
case r >= 'a' && r <= 'z':
71+
case r >= '0' && r <= '9':
72+
case r == '-' || r == '_' || r == '.':
73+
default:
74+
return fmt.Errorf("invalid gRPC header key %q: keys may only contain letters, digits, '-', '_' or '.'", key)
75+
}
76+
}
77+
78+
return nil
79+
}
80+
81+
func grpcHeaderUnaryInterceptor(headers metadata.MD) grpc.UnaryClientInterceptor {
82+
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
83+
return invoker(appendGrpcHeadersToContext(ctx, headers), method, req, reply, cc, opts...)
84+
}
85+
}
86+
87+
func grpcHeaderStreamInterceptor(headers metadata.MD) grpc.StreamClientInterceptor {
88+
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
89+
return streamer(appendGrpcHeadersToContext(ctx, headers), desc, cc, method, opts...)
90+
}
91+
}
92+
93+
func appendGrpcHeadersToContext(ctx context.Context, headers metadata.MD) context.Context {
94+
if len(headers) == 0 {
95+
return ctx
96+
}
97+
98+
existing, _ := metadata.FromOutgoingContext(ctx)
99+
return metadata.NewOutgoingContext(ctx, metadata.Join(existing, headers))
100+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package suiclient
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"google.golang.org/grpc"
9+
"google.golang.org/grpc/metadata"
10+
)
11+
12+
func TestParseGrpcHeaderSpecs(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
specs []string
16+
want metadata.MD
17+
wantErr bool
18+
}{
19+
{
20+
name: "empty",
21+
specs: nil,
22+
want: metadata.MD{},
23+
},
24+
{
25+
name: "valid headers",
26+
specs: []string{"X-API-Key=secret", "chain-route=sui-mainnet"},
27+
want: metadata.MD{
28+
"x-api-key": []string{"secret"},
29+
"chain-route": []string{"sui-mainnet"},
30+
},
31+
},
32+
{
33+
name: "duplicate key",
34+
specs: []string{"X-API-Key=secret", "x-api-key=second"},
35+
wantErr: true,
36+
},
37+
{
38+
name: "trims whitespace",
39+
specs: []string{" x-api-key = secret "},
40+
want: metadata.MD{
41+
"x-api-key": []string{"secret"},
42+
},
43+
},
44+
{
45+
name: "missing separator",
46+
specs: []string{"x-api-key"},
47+
wantErr: true,
48+
},
49+
{
50+
name: "empty key",
51+
specs: []string{"=secret"},
52+
wantErr: true,
53+
},
54+
{
55+
name: "empty value",
56+
specs: []string{"x-api-key="},
57+
wantErr: true,
58+
},
59+
{
60+
name: "invalid key",
61+
specs: []string{"x api key=secret"},
62+
wantErr: true,
63+
},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
got, err := parseGrpcHeaderSpecs(tt.specs)
69+
if tt.wantErr {
70+
require.Error(t, err)
71+
return
72+
}
73+
74+
require.NoError(t, err)
75+
require.Equal(t, tt.want, got)
76+
})
77+
}
78+
}
79+
80+
func TestGrpcHeaderInterceptorsAppendMetadata(t *testing.T) {
81+
headers := metadata.MD{
82+
"x-api-key": []string{"secret"},
83+
"chain-route": []string{"sui-mainnet"},
84+
}
85+
86+
ctx := metadata.AppendToOutgoingContext(context.Background(), "existing-header", "existing-value")
87+
88+
var unaryMetadata metadata.MD
89+
unaryInvoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error {
90+
var ok bool
91+
unaryMetadata, ok = metadata.FromOutgoingContext(ctx)
92+
require.True(t, ok)
93+
return nil
94+
}
95+
96+
err := grpcHeaderUnaryInterceptor(headers)(ctx, "/sui.rpc.v2.LedgerService/GetCheckpoint", nil, nil, nil, unaryInvoker)
97+
require.NoError(t, err)
98+
require.Equal(t, []string{"existing-value"}, unaryMetadata.Get("existing-header"))
99+
require.Equal(t, []string{"secret"}, unaryMetadata.Get("x-api-key"))
100+
require.Equal(t, []string{"sui-mainnet"}, unaryMetadata.Get("chain-route"))
101+
102+
var streamMetadata metadata.MD
103+
streamer := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) {
104+
var ok bool
105+
streamMetadata, ok = metadata.FromOutgoingContext(ctx)
106+
require.True(t, ok)
107+
return nil, nil
108+
}
109+
110+
_, err = grpcHeaderStreamInterceptor(headers)(ctx, &grpc.StreamDesc{}, nil, "/sui.rpc.v2.SubscriptionService/SubscribeCheckpoints", streamer)
111+
require.NoError(t, err)
112+
require.Equal(t, []string{"existing-value"}, streamMetadata.Get("existing-header"))
113+
require.Equal(t, []string{"secret"}, streamMetadata.Get("x-api-key"))
114+
require.Equal(t, []string{"sui-mainnet"}, streamMetadata.Get("chain-route"))
115+
}
116+
117+
func TestGrpcHeaderDialOptions(t *testing.T) {
118+
opts, err := GrpcHeaderDialOptions([]string{"x-api-key=secret"})
119+
require.NoError(t, err)
120+
require.Len(t, opts, 2)
121+
122+
opts, err = GrpcHeaderDialOptions(nil)
123+
require.NoError(t, err)
124+
require.Empty(t, opts)
125+
}

node/pkg/watchers/sui/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type WatcherConfig struct {
1414
NetworkID watchers.NetworkID // human readable name
1515
ChainID vaa.ChainID
1616
Rpc string
17+
RpcHeaders []string
1718
SuiMoveEventType string
1819
TxVerifierEnabled bool
1920
}
@@ -39,6 +40,7 @@ func (wc *WatcherConfig) Create(
3940

4041
watcher, err := NewWatcher(
4142
wc.Rpc,
43+
wc.RpcHeaders,
4244
wc.SuiMoveEventType,
4345
devMode,
4446
msgC,

node/pkg/watchers/sui/watcher.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,24 @@ import (
3535
// suiGrpcDialOpts returns the gRPC dial options for connecting to the Sui endpoint. In unsafe
3636
// dev mode the local Sui node serves plaintext gRPC, so TLS is disabled; otherwise the default
3737
// (TLS) transport configured by suiclient.NewSuiGrpcClient is used.
38-
func suiGrpcDialOpts(unsafeDevMode bool) []grpc.DialOption {
38+
func suiGrpcDialOpts(unsafeDevMode bool, rpcHeaders []string) ([]grpc.DialOption, error) {
39+
opts, err := suiclient.GrpcHeaderDialOptions(rpcHeaders)
40+
if err != nil {
41+
return nil, err
42+
}
43+
3944
if unsafeDevMode {
40-
return []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
45+
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
4146
}
42-
return nil
47+
48+
return opts, nil
4349
}
4450

4551
type (
4652
// Watcher is responsible for looking over Sui blockchain and reporting new transactions to the wormhole contract
4753
Watcher struct {
4854
suiRPC string
55+
suiRPCHeaders []string
4956
suiMoveEventType string
5057

5158
unsafeDevMode bool
@@ -84,6 +91,7 @@ var (
8491
// NewWatcher creates a new Sui appid watcher
8592
func NewWatcher(
8693
suiRPC string,
94+
suiRPCHeaders []string,
8795
suiMoveEventType string,
8896
unsafeDevMode bool,
8997
messageEvents chan<- *common.MessagePublication,
@@ -93,6 +101,11 @@ func NewWatcher(
93101
) (*Watcher, error) {
94102
var suiTxVerifier *txverifier.SuiTransferVerifier
95103

104+
dialOpts, err := suiGrpcDialOpts(unsafeDevMode, suiRPCHeaders)
105+
if err != nil {
106+
return nil, fmt.Errorf("invalid Sui gRPC headers: %w", err)
107+
}
108+
96109
if txVerifierEnabled {
97110

98111
// Extracted from the suiMoveEventType passed to guardiand as CLI arg
@@ -128,7 +141,7 @@ func NewWatcher(
128141
}
129142

130143
// Create the Sui gRPC client used by the transfer verifier to query transactions and objects.
131-
suiClient, err := suiclient.NewSuiGrpcClient(suiRPC, nil, suiGrpcDialOpts(unsafeDevMode)...)
144+
suiClient, err := suiclient.NewSuiGrpcClient(suiRPC, nil, dialOpts...)
132145
if err != nil {
133146
return nil, fmt.Errorf("failed to create Sui gRPC client for transfer verifier: %w", err)
134147
}
@@ -145,6 +158,7 @@ func NewWatcher(
145158

146159
return &Watcher{
147160
suiRPC: suiRPC,
161+
suiRPCHeaders: suiRPCHeaders,
148162
suiMoveEventType: suiMoveEventType,
149163
unsafeDevMode: unsafeDevMode,
150164
msgChan: messageEvents,
@@ -299,7 +313,12 @@ func (e *Watcher) Run(ctx context.Context) error {
299313
// concurrent goroutines below cannot observe a closed or nil client during shutdown.
300314
client := e.suiClient
301315
if client == nil {
302-
grpcClient, err := suiclient.NewSuiGrpcClient(e.suiRPC, logger, suiGrpcDialOpts(e.unsafeDevMode)...)
316+
dialOpts, err := suiGrpcDialOpts(e.unsafeDevMode, e.suiRPCHeaders)
317+
if err != nil {
318+
return fmt.Errorf("invalid Sui gRPC headers: %w", err)
319+
}
320+
321+
grpcClient, err := suiclient.NewSuiGrpcClient(e.suiRPC, logger, dialOpts...)
303322
if err != nil {
304323
return fmt.Errorf("failed to create Sui gRPC client: %w", err)
305324
}

0 commit comments

Comments
 (0)