Browse Source

node: add spy service

Change-Id: Ieb04e6d26c7778d8a8afbbeaee79d764d9f2cd31
Leo 4 years ago
parent
commit
bc48b1b51d

+ 10 - 0
DEVELOP.md

@@ -133,6 +133,16 @@ IntelliJ's [remote development backend](https://www.jetbrains.com/remote-develop
 
 ## Tips and tricks
 
+### Call gRPC services
+
+    tools/bin/grpcurl -protoset <(tools/bin/buf build -o -) -plaintext localhost:7072 spy.v1.SpyRPCService/SubscribeSignedVAA
+
+With parameters (using proto json encoding):
+
+    tools/bin/grpcurl -protoset <(tools/bin/buf build -o -) \
+        -d '{"filters": [{"emitter_filter": {"emitter_address": "574108aed69daf7e625a361864b1f74d13702f2ca56de9660e566d1d8691848d", "chain_id": "CHAIN_ID_SOLANA"}}]}' \
+        -plaintext localhost:7072 spy.v1.SpyRPCService/SubscribeSignedVAA
+
 ### Post messages
 
 To Solana:

+ 17 - 8
Tiltfile

@@ -137,6 +137,14 @@ k8s_resource("guardian", resource_deps = ["proto-gen", "solana-devnet"], port_fo
     port_forward(2345, name = "Debugger [:2345]", host = webHost),
 ])
 
+# spy
+k8s_yaml_with_ns("devnet/spy.yaml")
+
+k8s_resource("spy", resource_deps = ["proto-gen", "guardian"], port_forwards = [
+    port_forward(6061, container_port = 6060, name = "Debug/Status Server [:6061]", host = webHost),
+    port_forward(7072, name = "Spy gRPC [:7072]", host = webHost),
+])
+
 # solana client cli (used for devnet setup)
 
 docker_build(
@@ -228,7 +236,6 @@ k8s_resource("eth-devnet2", port_forwards = [
 ])
 
 if bridge_ui:
-
     docker_build(
         ref = "bridge-ui",
         context = ".",
@@ -272,26 +279,27 @@ def build_cloud_function(container_name, go_func_name, path, builder):
     if ci:
         # inherit the DOCKER_HOST socket provided by custom_build.
         pack_build_cmd = pack_build_cmd + " --docker-host inherit"
+
         # do not attempt to access Docker cache in CI
         # pack_build_cmd = pack_build_cmd + " --clear-cache"
         # don't try to pull previous container versions in CI
         pack_build_cmd = pack_build_cmd + " --pull-policy never"
+
         # push to kubernetes registry
         disable_push = False
         skips_local_docker = False
 
-    docker_tag_cmd  = "tilt docker -- tag " + caching_ref + " $EXPECTED_REF"
+    docker_tag_cmd = "tilt docker -- tag " + caching_ref + " $EXPECTED_REF"
     custom_build(
         container_name,
         pack_build_cmd + " && " + docker_tag_cmd,
         [path],
-        tag=tag,
-        skips_local_docker=skips_local_docker,
-        disable_push=disable_push,
+        tag = tag,
+        skips_local_docker = skips_local_docker,
+        disable_push = disable_push,
     )
 
 if explorer:
-
     local_resource(
         name = "devnet-cloud-function",
         cmd = "tilt docker -- build -f ./event_database/cloud_functions/Dockerfile.run . -t devnet-cloud-function --label builtby=tilt",
@@ -308,7 +316,8 @@ if explorer:
 
     k8s_yaml_with_ns("devnet/bigtable.yaml")
 
-    k8s_resource("bigtable-emulator",
+    k8s_resource(
+        "bigtable-emulator",
         port_forwards = [port_forward(8086, name = "BigTable clients [:8086]", host = webHost)],
         labels = ["explorer"],
     )
@@ -323,7 +332,7 @@ if explorer:
         "bigtable-functions",
         resource_deps = ["proto-gen", "bigtable-emulator"],
         port_forwards = [port_forward(8090, name = "BigTable Functions [:8090]", host = webHost)],
-        labels = ["explorer"]
+        labels = ["explorer"],
     )
 
     # explorer web app

+ 5 - 0
buf.yaml

@@ -12,6 +12,11 @@ lint:
     - DEFAULT
     # https://github.com/twitchtv/twirp/issues/70#issuecomment-470367807
     - UNARY_RPC
+  ignore_only:
+    RPC_NO_SERVER_STREAMING:
+      # Allow streamed RPC for the spy server, which is designed to run as a sidecar
+      # and won't handle large amounts of connections.
+      - spy/v1/spy.proto
 breaking:
   use:
     - WIRE_JSON

+ 60 - 0
devnet/spy.yaml

@@ -0,0 +1,60 @@
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: spy
+  labels:
+    app: spy
+spec:
+  ports:
+    - port: 7072
+      name: spyrpc
+      protocol: TCP
+    - port: 6060
+      name: status
+      protocol: TCP
+  clusterIP: None
+  selector:
+    app: spy
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: spy
+spec:
+  selector:
+    matchLabels:
+      app: spy
+  serviceName: spy
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: spy
+    spec:
+      terminationGracePeriodSeconds: 0
+      containers:
+        - name: spy
+          image: guardiand-image
+          command:
+            - /guardiand
+            - spy
+            - --nodeKey
+            - /tmp/node.key
+            - --spyRPC
+            - "[::]:7072"
+            # Hardcoded devnet bootstrap (generated from deterministic key in guardiand)
+            - --bootstrap
+            - /dns4/guardian-0.guardian/udp/8999/quic/p2p/12D3KooWL3XJ9EMCyZvmmGXL2LMiVBtrVa2BuESsJiXkSj7333Jw
+#            - --logLevel=debug
+          ports:
+            - containerPort: 7072
+              name: spyrpc
+              protocol: TCP
+            - containerPort: 6060
+              name: status
+              protocol: TCP
+          readinessProbe:
+            httpGet:
+              port: 6060
+              path: /metrics

+ 1 - 26
node/cmd/guardiand/adminserver.go

@@ -9,12 +9,7 @@ import (
 	publicrpcv1 "github.com/certusone/wormhole/node/pkg/proto/publicrpc/v1"
 	"github.com/certusone/wormhole/node/pkg/publicrpc"
 	ethcommon "github.com/ethereum/go-ethereum/common"
-	grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
-	grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap"
-	grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
-	grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
 	"go.uber.org/zap"
-	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 	"math"
@@ -269,28 +264,8 @@ func adminServiceRunnable(logger *zap.Logger, socketPath string, injectC chan<-
 
 	publicrpcService := publicrpc.NewPublicrpcServer(logger, db, gst)
 
-	grpcServer := newGRPCServer(logger)
+	grpcServer := common.NewInstrumentedGRPCServer(logger)
 	nodev1.RegisterNodePrivilegedServiceServer(grpcServer, nodeService)
 	publicrpcv1.RegisterPublicRPCServiceServer(grpcServer, publicrpcService)
 	return supervisor.GRPCServer(grpcServer, l, false), nil
 }
-
-func newGRPCServer(logger *zap.Logger) *grpc.Server {
-	server := grpc.NewServer(
-		grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
-			grpc_ctxtags.StreamServerInterceptor(),
-			grpc_prometheus.StreamServerInterceptor,
-			grpc_zap.StreamServerInterceptor(logger),
-		)),
-		grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
-			grpc_ctxtags.UnaryServerInterceptor(),
-			grpc_prometheus.UnaryServerInterceptor,
-			grpc_zap.UnaryServerInterceptor(logger),
-		)),
-	)
-
-	grpc_prometheus.EnableHandlingTimeHistogram()
-	grpc_prometheus.Register(server)
-
-	return server
-}

+ 3 - 2
node/cmd/guardiand/guardiankey.go

@@ -5,6 +5,7 @@ import (
 	"crypto/rand"
 	"errors"
 	"fmt"
+	"github.com/certusone/wormhole/node/pkg/common"
 	"io/ioutil"
 	"log"
 	"os"
@@ -36,8 +37,8 @@ var KeygenCmd = &cobra.Command{
 }
 
 func runKeygen(cmd *cobra.Command, args []string) {
-	lockMemory()
-	setRestrictiveUmask()
+	common.LockMemory()
+	common.SetRestrictiveUmask()
 
 	log.Print("Creating new key at ", args[0])
 

+ 11 - 32
node/cmd/guardiand/node.go

@@ -3,30 +3,20 @@ package guardiand
 import (
 	"context"
 	"fmt"
+	"github.com/certusone/wormhole/node/pkg/db"
 	"github.com/certusone/wormhole/node/pkg/notify/discord"
+	"github.com/gagliardetto/solana-go/rpc"
 	"log"
 	"net/http"
 	_ "net/http/pprof"
 	"os"
 	"path"
 	"strings"
-	"syscall"
-
-	"github.com/certusone/wormhole/node/pkg/db"
-	"github.com/gagliardetto/solana-go/rpc"
 
 	solana_types "github.com/gagliardetto/solana-go"
 	"github.com/gorilla/mux"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 
-	eth_common "github.com/ethereum/go-ethereum/common"
-	ethcrypto "github.com/ethereum/go-ethereum/crypto"
-	"github.com/libp2p/go-libp2p-core/crypto"
-	"github.com/libp2p/go-libp2p-core/peer"
-	"github.com/spf13/cobra"
-	"go.uber.org/zap"
-	"golang.org/x/sys/unix"
-
 	"github.com/certusone/wormhole/node/pkg/common"
 	"github.com/certusone/wormhole/node/pkg/devnet"
 	"github.com/certusone/wormhole/node/pkg/ethereum"
@@ -38,6 +28,12 @@ import (
 	solana "github.com/certusone/wormhole/node/pkg/solana"
 	"github.com/certusone/wormhole/node/pkg/supervisor"
 	"github.com/certusone/wormhole/node/pkg/vaa"
+	eth_common "github.com/ethereum/go-ethereum/common"
+	ethcrypto "github.com/ethereum/go-ethereum/crypto"
+	"github.com/libp2p/go-libp2p-core/crypto"
+	"github.com/libp2p/go-libp2p-core/peer"
+	"github.com/spf13/cobra"
+	"go.uber.org/zap"
 
 	"github.com/certusone/wormhole/node/pkg/terra"
 
@@ -199,23 +195,6 @@ func rootLoggerName() string {
 	}
 }
 
-// lockMemory locks current and future pages in memory to protect secret keys from being swapped out to disk.
-// It's possible (and strongly recommended) to deploy Wormhole such that keys are only ever
-// stored in memory and never touch the disk. This is a privileged operation and requires CAP_IPC_LOCK.
-func lockMemory() {
-	err := unix.Mlockall(syscall.MCL_CURRENT | syscall.MCL_FUTURE)
-	if err != nil {
-		fmt.Printf("Failed to lock memory: %v (CAP_IPC_LOCK missing?)\n", err)
-		os.Exit(1)
-	}
-}
-
-// setRestrictiveUmask masks the group and world bits. This ensures that key material
-// and sockets we create aren't accidentally group- or world-readable.
-func setRestrictiveUmask() {
-	syscall.Umask(0077) // cannot fail
-}
-
 // NodeCmd represents the node command
 var NodeCmd = &cobra.Command{
 	Use:   "node",
@@ -228,8 +207,8 @@ func runNode(cmd *cobra.Command, args []string) {
 		fmt.Print(devwarning)
 	}
 
-	lockMemory()
-	setRestrictiveUmask()
+	common.LockMemory()
+	common.SetRestrictiveUmask()
 
 	// Refuse to run as root in production mode.
 	if !*unsafeDevMode && os.Geteuid() == 0 {
@@ -506,7 +485,7 @@ func runNode(cmd *cobra.Command, args []string) {
 		}
 		priv = devnet.DeterministicP2PPrivKeyByIndex(int64(idx))
 	} else {
-		priv, err = getOrCreateNodeKey(logger, *nodeKeyPath)
+		priv, err = common.GetOrCreateNodeKey(logger, *nodeKeyPath)
 		if err != nil {
 			logger.Fatal("Failed to load node key", zap.Error(err))
 		}

+ 1 - 1
node/cmd/guardiand/publicrpc.go

@@ -21,7 +21,7 @@ func publicrpcServiceRunnable(logger *zap.Logger, listenAddr string, db *db.Data
 	logger.Info("publicrpc server listening", zap.String("addr", l.Addr().String()))
 
 	rpcServer := publicrpc.NewPublicrpcServer(logger, db, gst)
-	grpcServer := newGRPCServer(logger)
+	grpcServer := common.NewInstrumentedGRPCServer(logger)
 	publicrpcv1.RegisterPublicRPCServiceServer(grpcServer, rpcServer)
 
 	return supervisor.GRPCServer(grpcServer, l, false), grpcServer, nil

+ 2 - 0
node/cmd/root.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"fmt"
 	"github.com/certusone/wormhole/node/cmd/debug"
+	"github.com/certusone/wormhole/node/cmd/spy"
 	"github.com/certusone/wormhole/node/pkg/version"
 	"os"
 
@@ -45,6 +46,7 @@ func init() {
 
 	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.guardiand.yaml)")
 	rootCmd.AddCommand(guardiand.NodeCmd)
+	rootCmd.AddCommand(spy.SpyCmd)
 	rootCmd.AddCommand(guardiand.KeygenCmd)
 	rootCmd.AddCommand(guardiand.AdminCmd)
 	rootCmd.AddCommand(guardiand.TemplateCmd)

+ 322 - 0
node/cmd/spy/spy.go

@@ -0,0 +1,322 @@
+package spy
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"github.com/certusone/wormhole/node/pkg/common"
+	"github.com/certusone/wormhole/node/pkg/p2p"
+	gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
+	"github.com/certusone/wormhole/node/pkg/proto/spy/v1"
+	"github.com/certusone/wormhole/node/pkg/supervisor"
+	"github.com/certusone/wormhole/node/pkg/vaa"
+	"github.com/google/uuid"
+	"github.com/gorilla/mux"
+	ipfslog "github.com/ipfs/go-log/v2"
+	"github.com/libp2p/go-libp2p-core/crypto"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
+	"github.com/spf13/cobra"
+	"go.uber.org/zap"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"net"
+	"net/http"
+	"os"
+	"sync"
+)
+
+var (
+	rootCtx       context.Context
+	rootCtxCancel context.CancelFunc
+)
+
+var (
+	p2pNetworkID *string
+	p2pPort      *uint
+	p2pBootstrap *string
+
+	statusAddr *string
+
+	nodeKeyPath *string
+
+	logLevel *string
+
+	spyRPC *string
+)
+
+func init() {
+	p2pNetworkID = SpyCmd.Flags().String("network", "/wormhole/dev", "P2P network identifier")
+	p2pPort = SpyCmd.Flags().Uint("port", 8999, "P2P UDP listener port")
+	p2pBootstrap = SpyCmd.Flags().String("bootstrap", "", "P2P bootstrap peers (comma-separated)")
+
+	statusAddr = SpyCmd.Flags().String("statusAddr", "[::]:6060", "Listen address for status server (disabled if blank)")
+
+	nodeKeyPath = SpyCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)")
+
+	logLevel = SpyCmd.Flags().String("logLevel", "info", "Logging level (debug, info, warn, error, dpanic, panic, fatal)")
+
+	spyRPC = SpyCmd.Flags().String("spyRPC", "", "Listen address for gRPC interface")
+}
+
+// SpyCmd represents the node command
+var SpyCmd = &cobra.Command{
+	Use:   "spy",
+	Short: "Run gossip spy client",
+	Run:   runSpy,
+}
+
+type spyServer struct {
+	spyv1.UnimplementedSpyRPCServiceServer
+	logger *zap.Logger
+	subs   map[string]*subscription
+	subsMu sync.Mutex
+}
+
+type message struct {
+	vaaBytes []byte
+}
+
+type filter struct {
+	chainId     vaa.ChainID
+	emitterAddr vaa.Address
+}
+
+type subscription struct {
+	filters []filter
+	ch      chan message
+}
+
+func subscriptionId() string {
+	return uuid.New().String()
+}
+
+func decodeEmitterAddr(hexAddr string) (vaa.Address, error) {
+	address, err := hex.DecodeString(hexAddr)
+	if err != nil {
+		return vaa.Address{}, status.Error(codes.InvalidArgument, fmt.Sprintf("failed to decode address: %v", err))
+	}
+	if len(address) != 32 {
+		return vaa.Address{}, status.Error(codes.InvalidArgument, "address must be 32 bytes")
+	}
+
+	addr := vaa.Address{}
+	copy(addr[:], address)
+
+	return addr, nil
+}
+
+func (s *spyServer) Publish(vaaBytes []byte) error {
+	s.subsMu.Lock()
+	defer s.subsMu.Unlock()
+
+	var v *vaa.VAA
+
+	for _, sub := range s.subs {
+		if len(sub.filters) == 0 {
+			sub.ch <- message{vaaBytes: vaaBytes}
+		} else {
+			if v == nil {
+				var err error
+				v, err = vaa.Unmarshal(vaaBytes)
+				if err != nil {
+					return err
+				}
+			}
+
+			for _, fi := range sub.filters {
+				if fi.chainId == v.EmitterChain && fi.emitterAddr == v.EmitterAddress {
+					sub.ch <- message{vaaBytes: vaaBytes}
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func (s *spyServer) SubscribeSignedVAA(req *spyv1.SubscribeSignedVAARequest, resp spyv1.SpyRPCService_SubscribeSignedVAAServer) error {
+	var fi []filter
+	if req.Filters != nil {
+		for _, f := range req.Filters {
+			switch t := f.Filter.(type) {
+			case *spyv1.FilterEntry_EmitterFilter:
+				addr, err := decodeEmitterAddr(t.EmitterFilter.EmitterAddress)
+				if err != nil {
+					return status.Error(codes.InvalidArgument, fmt.Sprintf("failed to decode emitter address: %v", err))
+				}
+				fi = append(fi, filter{
+					chainId:     vaa.ChainID(t.EmitterFilter.ChainId),
+					emitterAddr: addr,
+				})
+			default:
+				return status.Error(codes.InvalidArgument, "unsupported filter type")
+			}
+		}
+	}
+
+	s.subsMu.Lock()
+	id := subscriptionId()
+	sub := &subscription{
+		ch:      make(chan message, 1),
+		filters: fi,
+	}
+	s.subs[id] = sub
+	s.subsMu.Unlock()
+
+	defer func() {
+		s.subsMu.Lock()
+		defer s.subsMu.Unlock()
+		delete(s.subs, id)
+	}()
+
+	for {
+		select {
+		case <-resp.Context().Done():
+			return resp.Context().Err()
+		case msg := <-sub.ch:
+			if err := resp.Send(&spyv1.SubscribeSignedVAAResponse{
+				VaaBytes: msg.vaaBytes,
+			}); err != nil {
+				return err
+			}
+		}
+	}
+}
+
+func newSpyServer(logger *zap.Logger) *spyServer {
+	return &spyServer{
+		logger: logger.Named("spyserver"),
+		subs:   make(map[string]*subscription),
+	}
+}
+
+func spyServerRunnable(s *spyServer, logger *zap.Logger, listenAddr string) (supervisor.Runnable, *grpc.Server, error) {
+	l, err := net.Listen("tcp", listenAddr)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to listen: %w", err)
+	}
+
+	logger.Info("publicrpc server listening", zap.String("addr", l.Addr().String()))
+
+	grpcServer := common.NewInstrumentedGRPCServer(logger)
+	spyv1.RegisterSpyRPCServiceServer(grpcServer, s)
+
+	return supervisor.GRPCServer(grpcServer, l, false), grpcServer, nil
+}
+
+func runSpy(cmd *cobra.Command, args []string) {
+	common.SetRestrictiveUmask()
+
+	lvl, err := ipfslog.LevelFromString(*logLevel)
+	if err != nil {
+		fmt.Println("Invalid log level")
+		os.Exit(1)
+	}
+
+	logger := ipfslog.Logger("wormhole-spy").Desugar()
+
+	ipfslog.SetAllLoggers(lvl)
+
+	// Status server
+	if *statusAddr != "" {
+		router := mux.NewRouter()
+
+		router.Handle("/metrics", promhttp.Handler())
+
+		go func() {
+			logger.Info("status server listening on [::]:6060")
+			logger.Error("status server crashed", zap.Error(http.ListenAndServe(*statusAddr, router)))
+		}()
+	}
+
+	// Verify flags
+
+	if *nodeKeyPath == "" {
+		logger.Fatal("Please specify --nodeKey")
+	}
+	if *p2pBootstrap == "" {
+		logger.Fatal("Please specify --bootstrap")
+	}
+
+	// Node's main lifecycle context.
+	rootCtx, rootCtxCancel = context.WithCancel(context.Background())
+	defer rootCtxCancel()
+
+	// Outbound gossip message queue
+	sendC := make(chan []byte)
+
+	// Inbound observations
+	obsvC := make(chan *gossipv1.SignedObservation, 50)
+
+	// Inbound signed VAAs
+	signedInC := make(chan *gossipv1.SignedVAAWithQuorum, 50)
+
+	// Guardian set state managed by processor
+	gst := common.NewGuardianSetState()
+
+	// RPC server
+	s := newSpyServer(logger)
+	rpcSvc, _, err := spyServerRunnable(s, logger, *spyRPC)
+	if err != nil {
+		logger.Fatal("failed to start RPC server", zap.Error(err))
+	}
+
+	// Ignore observations
+	go func() {
+		for {
+			select {
+			case <-rootCtx.Done():
+				return
+			case <-obsvC:
+			}
+		}
+	}()
+
+	// Log signed VAAs
+	go func() {
+		for {
+			select {
+			case <-rootCtx.Done():
+				return
+			case v := <-signedInC:
+				logger.Info("Received signed VAA",
+					zap.Any("vaa", v.Vaa))
+				if err := s.Publish(v.Vaa); err != nil {
+					logger.Error("failed to publish signed VAA", zap.Error(err))
+				}
+			}
+		}
+	}()
+
+	// Load p2p private key
+	var priv crypto.PrivKey
+	priv, err = common.GetOrCreateNodeKey(logger, *nodeKeyPath)
+	if err != nil {
+		logger.Fatal("Failed to load node key", zap.Error(err))
+	}
+
+	// Run supervisor.
+	supervisor.New(rootCtx, logger, func(ctx context.Context) error {
+		if err := supervisor.Run(ctx, "p2p", p2p.Run(
+			obsvC, sendC, signedInC, priv, nil, gst, *p2pPort, *p2pNetworkID, *p2pBootstrap, "", false, rootCtxCancel)); err != nil {
+			return err
+		}
+
+		if err := supervisor.Run(ctx, "spyrpc", rpcSvc); err != nil {
+			return err
+		}
+
+		logger.Info("Started internal services")
+
+		<-ctx.Done()
+		return nil
+	},
+		// It's safer to crash and restart the process in case we encounter a panic,
+		// rather than attempting to reschedule the runnable.
+		supervisor.WithPropagatePanic)
+
+	<-rootCtx.Done()
+	logger.Info("root context cancelled, exiting...")
+	// TODO: wait for things to shut down gracefully
+}

+ 30 - 0
node/pkg/common/grpc.go

@@ -0,0 +1,30 @@
+package common
+
+import (
+	"github.com/grpc-ecosystem/go-grpc-middleware"
+	"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap"
+	"github.com/grpc-ecosystem/go-grpc-middleware/tags"
+	"github.com/grpc-ecosystem/go-grpc-prometheus"
+	"go.uber.org/zap"
+	"google.golang.org/grpc"
+)
+
+func NewInstrumentedGRPCServer(logger *zap.Logger) *grpc.Server {
+	server := grpc.NewServer(
+		grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
+			grpc_ctxtags.StreamServerInterceptor(),
+			grpc_prometheus.StreamServerInterceptor,
+			grpc_zap.StreamServerInterceptor(logger),
+		)),
+		grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
+			grpc_ctxtags.UnaryServerInterceptor(),
+			grpc_prometheus.UnaryServerInterceptor,
+			grpc_zap.UnaryServerInterceptor(logger),
+		)),
+	)
+
+	grpc_prometheus.EnableHandlingTimeHistogram()
+	grpc_prometheus.Register(server)
+
+	return server
+}

+ 8 - 9
node/cmd/guardiand/nodekey.go → node/pkg/common/nodekey.go

@@ -1,27 +1,26 @@
-package guardiand
+package common
 
 import (
 	"fmt"
-	"io/ioutil"
-	"os"
-
-	p2pcrypto "github.com/libp2p/go-libp2p-core/crypto"
+	"github.com/libp2p/go-libp2p-core/crypto"
 	"github.com/libp2p/go-libp2p-core/peer"
 	"go.uber.org/zap"
+	"io/ioutil"
+	"os"
 )
 
-func getOrCreateNodeKey(logger *zap.Logger, path string) (p2pcrypto.PrivKey, error) {
+func GetOrCreateNodeKey(logger *zap.Logger, path string) (crypto.PrivKey, error) {
 	b, err := ioutil.ReadFile(path)
 	if err != nil {
 		if os.IsNotExist(err) {
 			logger.Info("No node key found, generating a new one...", zap.String("path", path))
 
-			priv, _, err := p2pcrypto.GenerateKeyPair(p2pcrypto.Ed25519, -1)
+			priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1)
 			if err != nil {
 				panic(err)
 			}
 
-			s, err := p2pcrypto.MarshalPrivateKey(priv)
+			s, err := crypto.MarshalPrivateKey(priv)
 			if err != nil {
 				panic(err)
 			}
@@ -37,7 +36,7 @@ func getOrCreateNodeKey(logger *zap.Logger, path string) (p2pcrypto.PrivKey, err
 		}
 	}
 
-	priv, err := p2pcrypto.UnmarshalPrivateKey(b)
+	priv, err := crypto.UnmarshalPrivateKey(b)
 	if err != nil {
 		return nil, fmt.Errorf("failed to unmarshal node key: %w", err)
 	}

+ 25 - 0
node/pkg/common/sysutils.go

@@ -0,0 +1,25 @@
+package common
+
+import (
+	"fmt"
+	"golang.org/x/sys/unix"
+	"os"
+	"syscall"
+)
+
+// LockMemory locks current and future pages in memory to protect secret keys from being swapped out to disk.
+// It's possible (and strongly recommended) to deploy Wormhole such that keys are only ever
+// stored in memory and never touch the disk. This is a privileged operation and requires CAP_IPC_LOCK.
+func LockMemory() {
+	err := unix.Mlockall(syscall.MCL_CURRENT | syscall.MCL_FUTURE)
+	if err != nil {
+		fmt.Printf("Failed to lock memory: %v (CAP_IPC_LOCK missing?)\n", err)
+		os.Exit(1)
+	}
+}
+
+// SetRestrictiveUmask masks the group and world bits. This ensures that key material
+// and sockets we create aren't accidentally group- or world-readable.
+func SetRestrictiveUmask() {
+	syscall.Umask(0077) // cannot fail
+}

+ 5 - 0
node/pkg/p2p/p2p.go

@@ -193,6 +193,11 @@ func Run(obsvC chan *gossipv1.SignedObservation, sendC chan []byte, signedInC ch
 		}()
 
 		go func() {
+			// Disable heartbeat when no node name is provided (spy mode)
+			if nodeName == "" {
+				return
+			}
+
 			ctr := int64(0)
 			tick := time.NewTicker(15 * time.Second)
 			defer tick.Stop()

+ 0 - 2
proto/gossip/v1/gossip.proto

@@ -6,8 +6,6 @@ option go_package = "github.com/certusone/wormhole/node/pkg/proto/gossip/v1;goss
 
 message GossipMessage {
   oneof message {
-    // Deprecated: use SignedHeartbeat.
-    Heartbeat heartbeat = 1;
     SignedObservation signed_observation = 2;
     SignedHeartbeat signed_heartbeat = 3;
     SignedVAAWithQuorum signed_vaa_with_quorum = 4;

+ 44 - 0
proto/spy/v1/spy.proto

@@ -0,0 +1,44 @@
+syntax = "proto3";
+
+package spy.v1;
+
+option go_package = "github.com/certusone/wormhole/node/pkg/proto/spy/v1;spyv1";
+
+import "google/api/annotations.proto";
+import "publicrpc/v1/publicrpc.proto";
+
+// SpyRPCService exposes a gossip introspection service, allowing sniffing of gossip messages.
+service SpyRPCService {
+  // SubscribeSignedVAA returns a stream of signed VAA messages received on the network.
+  rpc SubscribeSignedVAA (SubscribeSignedVAARequest) returns (stream SubscribeSignedVAAResponse) {
+    option (google.api.http) = {
+      post: "/v1:subscribe_signed_vaa"
+      body: "*"
+    };
+  }
+}
+
+// A MessageFilter represents an exact match for an emitter.
+message EmitterFilter {
+  // Source chain
+  publicrpc.v1.ChainID chain_id = 1;
+  // Hex-encoded (without leading 0x) emitter address.
+  string emitter_address = 2;
+}
+
+message FilterEntry {
+  oneof filter {
+    EmitterFilter emitter_filter = 1;
+  }
+}
+
+message SubscribeSignedVAARequest {
+  // List of filters to apply to the stream (OR).
+  // If empty, all messages are streamed.
+  repeated FilterEntry filters = 1;
+}
+
+message SubscribeSignedVAAResponse {
+  // Raw VAA bytes
+  bytes vaa_bytes = 1;
+}

+ 1 - 0
tools/build.sh

@@ -7,3 +7,4 @@ go build -mod=readonly -o bin/protoc-gen-openapiv2 github.com/grpc-ecosystem/grp
 go build -mod=readonly -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
 go build -mod=readonly -o bin/buf github.com/bufbuild/buf/cmd/buf
 go build -mod=readonly -o bin/cobra github.com/spf13/cobra/cobra
+go build -mod=readonly -o bin/grpcurl github.com/fullstorydev/grpcurl/cmd/grpcurl

+ 99 - 0
tools/go.mod

@@ -5,8 +5,107 @@ go 1.17
 require (
 	github.com/bufbuild/buf v0.48.2
 	github.com/buildpacks/pack v0.20.0
+	github.com/fullstorydev/grpcurl v1.8.5
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0
 	github.com/spf13/cobra v1.2.1
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
 	google.golang.org/protobuf v1.27.1
 )
+
+require (
+	cloud.google.com/go v0.81.0 // indirect
+	github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect
+	github.com/BurntSushi/toml v0.3.1 // indirect
+	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/Microsoft/go-winio v0.4.15-0.20200908182639-5b44b70ab3ab // indirect
+	github.com/Microsoft/hcsshim v0.8.10 // indirect
+	github.com/apex/log v1.9.0 // indirect
+	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
+	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
+	github.com/buildpacks/lifecycle v0.11.3 // indirect
+	github.com/census-instrumentation/opencensus-proto v0.2.1 // indirect
+	github.com/cespare/xxhash v1.1.0 // indirect
+	github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 // indirect
+	github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed // indirect
+	github.com/containerd/containerd v1.4.1 // indirect
+	github.com/containerd/stargz-snapshotter/estargz v0.4.1 // indirect
+	github.com/docker/cli v0.0.0-20200312141509-ef2f64abbd37 // indirect
+	github.com/docker/distribution v2.7.1+incompatible // indirect
+	github.com/docker/docker v20.10.7+incompatible // indirect
+	github.com/docker/docker-credential-helpers v0.6.3 // indirect
+	github.com/docker/go-connections v0.4.0 // indirect
+	github.com/docker/go-units v0.4.0 // indirect
+	github.com/emirpasic/gods v1.12.0 // indirect
+	github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 // indirect
+	github.com/envoyproxy/protoc-gen-validate v0.1.0 // indirect
+	github.com/fsnotify/fsnotify v1.4.9 // indirect
+	github.com/ghodss/yaml v1.0.0 // indirect
+	github.com/gofrs/flock v0.8.1 // indirect
+	github.com/gofrs/uuid v4.0.0+incompatible // indirect
+	github.com/gogo/protobuf v1.3.2 // indirect
+	github.com/golang/glog v0.0.0-20210429001901-424d2337a529 // indirect
+	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
+	github.com/golang/mock v1.6.0 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/golang/snappy v0.0.3 // indirect
+	github.com/google/go-containerregistry v0.5.1 // indirect
+	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/heroku/color v0.0.6 // indirect
+	github.com/inconshreveable/mousetrap v1.0.0 // indirect
+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/jhump/protoreflect v1.10.1 // indirect
+	github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
+	github.com/klauspost/compress v1.13.1 // indirect
+	github.com/klauspost/pgzip v1.2.5 // indirect
+	github.com/magiconair/properties v1.8.5 // indirect
+	github.com/mattn/go-colorable v0.1.8 // indirect
+	github.com/mattn/go-isatty v0.0.12 // indirect
+	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e // indirect
+	github.com/mitchellh/mapstructure v1.4.1 // indirect
+	github.com/moby/sys/mount v0.2.0 // indirect
+	github.com/moby/sys/mountinfo v0.4.0 // indirect
+	github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf // indirect
+	github.com/morikuni/aec v1.0.0 // indirect
+	github.com/opencontainers/go-digest v1.0.0 // indirect
+	github.com/opencontainers/image-spec v1.0.1 // indirect
+	github.com/opencontainers/runc v0.1.1 // indirect
+	github.com/opencontainers/selinux v1.6.0 // indirect
+	github.com/pelletier/go-toml v1.9.3 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/pkg/profile v1.6.0 // indirect
+	github.com/sabhiram/go-gitignore v0.0.0-20201211074657-223ce5d391b0 // indirect
+	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/sirupsen/logrus v1.7.0 // indirect
+	github.com/spf13/afero v1.6.0 // indirect
+	github.com/spf13/cast v1.3.1 // indirect
+	github.com/spf13/jwalterweatherman v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/spf13/viper v1.8.1 // indirect
+	github.com/src-d/gcfg v1.4.0 // indirect
+	github.com/subosito/gotenv v1.2.0 // indirect
+	github.com/twitchtv/twirp v8.1.0+incompatible // indirect
+	github.com/willf/bitset v1.1.11 // indirect
+	github.com/xanzy/ssh-agent v0.3.0 // indirect
+	go.opencensus.io v0.23.0 // indirect
+	go.uber.org/atomic v1.7.0 // indirect
+	go.uber.org/multierr v1.7.0 // indirect
+	go.uber.org/zap v1.18.1 // indirect
+	golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
+	golang.org/x/mod v0.4.2 // indirect
+	golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 // indirect
+	golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1 // indirect
+	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
+	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
+	golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
+	golang.org/x/text v0.3.6 // indirect
+	google.golang.org/appengine v1.6.7 // indirect
+	google.golang.org/genproto v0.0.0-20210729151513-df9385d47c1b // indirect
+	google.golang.org/grpc v1.40.0-dev.0.20210708170655-30dfb4b933a5 // indirect
+	gopkg.in/ini.v1 v1.62.0 // indirect
+	gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect
+	gopkg.in/src-d/go-git.v4 v4.13.1 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+)

+ 17 - 1
tools/go.sum

@@ -18,6 +18,7 @@ cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKP
 cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
 cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
+cloud.google.com/go v0.81.0 h1:at8Tk2zUz63cLPR0JPWm5vp77pEZmzxEQBEfRKn1VV8=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
@@ -52,6 +53,7 @@ github.com/Microsoft/go-winio v0.4.15-0.20200908182639-5b44b70ab3ab/go.mod h1:tT
 github.com/Microsoft/hcsshim v0.8.10 h1:k5wTrpnVU2/xv8ZuzGkbXVd3js5zJ8RnumPo5RxiIxU=
 github.com/Microsoft/hcsshim v0.8.10/go.mod h1:g5uw8EV2mAlzqe94tfNBNdr89fnbD/n3HV0OhsddkmM=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
+github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -93,7 +95,9 @@ github.com/buildpacks/lifecycle v0.11.3 h1:FyvtzNxNjnBAdujzUiSpiCap3x+NzrqokGj69
 github.com/buildpacks/lifecycle v0.11.3/go.mod h1:4anPUHYqREC3oh3qqKZwt7wqWR866E7BvtIxRE8xGLE=
 github.com/buildpacks/pack v0.20.0 h1:MkMkfnMcuk1eIBU9qqd3JCAMdB1BqcjMzb/YQf7vB38=
 github.com/buildpacks/pack v0.20.0/go.mod h1:VmSAGBQ1jO8Ht+SO5GWwsh501ZerxrgJN0fVt1wZM3U=
+github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -102,7 +106,9 @@ github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmE
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed h1:OZmjad4L3H8ncOIR8rnb5MREYqG8ixi5+WbeUsquF0c=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM=
 github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
@@ -165,13 +171,17 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
 github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0 h1:dulLQAYQFYtG5MTplgNGHWuV2D+OBD+Z8lmDBmbLg+s=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/fullstorydev/grpcurl v1.8.5 h1:xYZBGwhLFuHx6VZLdANGx7Ffb/dlY8JZlJz76/TxclM=
+github.com/fullstorydev/grpcurl v1.8.5/go.mod h1:hmAJ/1FHD4xEdiTQS4Byb5NHVVGZr9iGy8CnX0t6ftA=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -285,6 +295,7 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
@@ -333,8 +344,9 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/jhump/protoreflect v1.9.0 h1:npqHz788dryJiR/l6K/RUQAyh2SwV91+d1dnh4RjO9w=
 github.com/jhump/protoreflect v1.9.0/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg=
+github.com/jhump/protoreflect v1.10.1 h1:iH+UZfsbRE6vpyZH7asAjTPWJf7RJbpZ9j/N3lDlKs0=
+github.com/jhump/protoreflect v1.10.1/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
@@ -509,6 +521,7 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
 github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
@@ -707,6 +720,7 @@ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1 h1:x622Z2o4hgCr/4CiKWc51jHVKaWdtVpBNmEI8wI9Qns=
 golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -906,6 +920,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
 google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -973,6 +988,7 @@ google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA5
 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
 google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
 google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.40.0-dev.0.20210708170655-30dfb4b933a5 h1:jeEzNnOogdiVxvaPNbt/QFOggkBTaUPBkQ2/NIngeyk=

+ 1 - 0
tools/tools.go

@@ -9,6 +9,7 @@ package main
 import (
 	_ "github.com/bufbuild/buf/cmd/buf"
 	_ "github.com/buildpacks/pack/cmd/pack"
+	_ "github.com/fullstorydev/grpcurl/cmd/grpcurl"
 	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
 	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
 	_ "github.com/spf13/cobra/cobra"