bridge: implement guardian set update submission node admin service

Tested on a live devnet via `scripts/test-injection.sh 0`.

ghstack-source-id: 92489c2455
Pull Request resolved: https://github.com/certusone/wormhole/pull/104
This commit is contained in:
Leo 2020-11-19 12:53:19 +01:00
parent 7545d2b803
commit 66430cb5be
15 changed files with 508 additions and 27 deletions

View File

@ -0,0 +1,81 @@
package guardiand
import (
"context"
"fmt"
"io/ioutil"
"log"
"time"
"github.com/spf13/cobra"
"github.com/status-im/keycard-go/hexutils"
"google.golang.org/grpc"
"google.golang.org/protobuf/encoding/prototext"
nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
)
var clientSocketPath *string
func init() {
pf := AdminClientInjectGuardianSetUpdateCmd.Flags()
clientSocketPath = pf.String("socket", "", "gRPC admin server socket to connect to")
err := cobra.MarkFlagRequired(pf, "socket")
if err != nil {
panic(err)
}
AdminCmd.AddCommand(AdminClientInjectGuardianSetUpdateCmd)
AdminCmd.AddCommand(AdminClientGuardianSetTemplateCmd)
AdminCmd.AddCommand(AdminClientGuardianSetVerifyCmd)
}
var AdminCmd = &cobra.Command{
Use: "admin",
Short: "Guardian node admin commands",
}
var AdminClientInjectGuardianSetUpdateCmd = &cobra.Command{
Use: "guardian-set-update-inject",
Short: "Inject and sign a guardian set update from a prototxt file (see docs!)",
Run: runInjectGuardianSetUpdate,
Args: cobra.ExactArgs(1),
}
func getAdminClient(ctx context.Context, addr string) (*grpc.ClientConn, error, nodev1.NodePrivilegedClient) {
conn, err := grpc.DialContext(ctx, fmt.Sprintf("unix:///%s", addr), grpc.WithInsecure())
if err != nil {
log.Fatalf("failed to connect to %s: %v", addr, err)
}
c := nodev1.NewNodePrivilegedClient(conn)
return conn, err, c
}
func runInjectGuardianSetUpdate(cmd *cobra.Command, args []string) {
path := args[0]
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err, c := getAdminClient(ctx, *clientSocketPath)
defer conn.Close()
b, err := ioutil.ReadFile(path)
if err != nil {
log.Fatalf("failed to read file: %v", err)
}
var msg nodev1.GuardianSetUpdate
err = prototext.Unmarshal(b, &msg)
if err != nil {
log.Fatalf("failed to deserialize: %v", err)
}
resp, err := c.SubmitGuardianSetVAA(ctx, &nodev1.SubmitGuardianSetVAARequest{GuardianSet: &msg})
if err != nil {
log.Fatalf("failed to submit guardian set update: %v", err)
}
log.Printf("VAA successfully injected with digest %s", hexutils.BytesToHex(resp.Digest))
}

View File

@ -0,0 +1,123 @@
package guardiand
import (
"context"
"errors"
"fmt"
"net"
"os"
"time"
ethcommon "github.com/ethereum/go-ethereum/common"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/certusone/wormhole/bridge/pkg/common"
nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
"github.com/certusone/wormhole/bridge/pkg/supervisor"
"github.com/certusone/wormhole/bridge/pkg/vaa"
)
type nodePrivilegedService struct {
nodev1.UnimplementedNodePrivilegedServer
injectC chan<- *vaa.VAA
logger *zap.Logger
}
// adminGuardianSetUpdateToVAA converts a nodev1.GuardianSetUpdate message to its canonical VAA representation.
// Returns an error if the data is invalid.
func adminGuardianSetUpdateToVAA(req *nodev1.GuardianSetUpdate) (*vaa.VAA, error) {
if len(req.Guardians) == 0 {
return nil, errors.New("empty guardian set specified")
}
if len(req.Guardians) > common.MaxGuardianCount {
return nil, fmt.Errorf("too many guardians - %d, maximum is %d", len(req.Guardians), common.MaxGuardianCount)
}
addrs := make([]ethcommon.Address, len(req.Guardians))
for i, g := range req.Guardians {
if !ethcommon.IsHexAddress(g.Pubkey) {
return nil, fmt.Errorf("invalid pubkey format at index %d (%s)", i, g.Name)
}
addrs[i] = ethcommon.HexToAddress(g.Pubkey)
}
v := &vaa.VAA{
Version: vaa.SupportedVAAVersion,
GuardianSetIndex: req.CurrentSetIndex,
Timestamp: time.Unix(int64(req.Timestamp), 0),
Payload: &vaa.BodyGuardianSetUpdate{
Keys: addrs,
NewIndex: req.CurrentSetIndex + 1,
},
}
return v, nil
}
func (s *nodePrivilegedService) SubmitGuardianSetVAA(ctx context.Context, req *nodev1.SubmitGuardianSetVAARequest) (*nodev1.SubmitGuardianSetVAAResponse, error) {
s.logger.Info("guardian set injected via admin socket", zap.String("request", req.String()))
v, err := adminGuardianSetUpdateToVAA(req.GuardianSet)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Generate digest of the unsigned VAA.
digest, err := v.SigningMsg()
if err != nil {
panic(err)
}
s.logger.Info("guardian set VAA constructed",
zap.Any("vaa", v),
zap.String("digest", digest.String()),
)
s.injectC <- v
return &nodev1.SubmitGuardianSetVAAResponse{Digest: digest.Bytes()}, nil
}
func adminServiceRunnable(logger *zap.Logger, socketPath string, injectC chan<- *vaa.VAA) (supervisor.Runnable, error) {
// Delete existing UNIX socket, if present.
fi, err := os.Stat(socketPath)
if err == nil {
fmode := fi.Mode()
if fmode&os.ModeType == os.ModeSocket {
err = os.Remove(socketPath)
if err != nil {
return nil, fmt.Errorf("failed to remove existing socket at %s: %w", socketPath, err)
}
} else {
return nil, fmt.Errorf("%s is not a UNIX socket", socketPath)
}
}
// Create a new UNIX socket and listen to it.
// The socket is created with the default umask. We set a restrictive umask in setRestrictiveUmask
// to ensure that any files we create are only readable by the user - this is much harder to mess up.
// The umask avoids a race condition between file creation and chmod.
laddr, err := net.ResolveUnixAddr("unix", socketPath)
l, err := net.ListenUnix("unix", laddr)
if err != nil {
return nil, fmt.Errorf("failed to listen on %s: %w", socketPath, err)
}
logger.Info("listening on", zap.String("path", socketPath))
nodeService := &nodePrivilegedService{
injectC: injectC,
logger: logger.Named("adminservice"),
}
grpcServer := grpc.NewServer()
nodev1.RegisterNodePrivilegedServer(grpcServer, nodeService)
return supervisor.GRPCServer(grpcServer, l, false), nil
}

View File

@ -0,0 +1,61 @@
package guardiand
import (
"fmt"
"io/ioutil"
"log"
"github.com/ethereum/go-ethereum/crypto"
"github.com/spf13/cobra"
"google.golang.org/protobuf/encoding/prototext"
"github.com/certusone/wormhole/bridge/pkg/devnet"
nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
)
var templateNumGuardians *int
var templateGuardianIndex *int
func init() {
templateNumGuardians = AdminClientGuardianSetTemplateCmd.Flags().Int("num", 1, "Number of devnet guardians in example file")
templateGuardianIndex = AdminClientGuardianSetTemplateCmd.Flags().Int("idx", 0, "Default current guardian set index")
}
var AdminClientGuardianSetTemplateCmd = &cobra.Command{
Use: "guardian-set-update-template",
Short: "Generate an empty guardian set template at specified path (offline)",
Run: runGuardianSetTemplate,
Args: cobra.ExactArgs(1),
}
func runGuardianSetTemplate(cmd *cobra.Command, args []string) {
path := args[0]
// Use deterministic devnet addresses as examples in the template, such that this doubles as a test fixture.
guardians := make([]*nodev1.GuardianSetUpdate_Guardian, *templateNumGuardians)
for i := 0; i < *templateNumGuardians; i++ {
k := devnet.DeterministicEcdsaKeyByIndex(crypto.S256(), uint64(i))
guardians[i] = &nodev1.GuardianSetUpdate_Guardian{
Pubkey: crypto.PubkeyToAddress(k.PublicKey).Hex(),
Name: fmt.Sprintf("Example validator %d", i),
}
}
m := &nodev1.GuardianSetUpdate{
CurrentSetIndex: uint32(*templateGuardianIndex),
// Timestamp is hardcoded to make it reproducible on different devnet nodes.
// In production, a real UNIX timestamp should be used (see node.proto).
Timestamp: 1605744545,
Guardians: guardians,
}
b, err := prototext.MarshalOptions{Multiline: true}.Marshal(m)
if err != nil {
panic(err)
}
err = ioutil.WriteFile(path, b, 0640)
if err != nil {
log.Fatal(err)
}
}

View File

@ -0,0 +1,46 @@
package guardiand
import (
"io/ioutil"
"log"
"github.com/davecgh/go-spew/spew"
"github.com/spf13/cobra"
"google.golang.org/protobuf/encoding/prototext"
nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1"
)
var AdminClientGuardianSetVerifyCmd = &cobra.Command{
Use: "guardian-set-update-verify",
Short: "Verify guardian set update in prototxt format (offline)",
Run: runGuardianSetVerify,
Args: cobra.ExactArgs(1),
}
func runGuardianSetVerify(cmd *cobra.Command, args []string) {
path := args[0]
b, err := ioutil.ReadFile(path)
if err != nil {
log.Fatalf("failed to read file: %v", err)
}
var msg nodev1.GuardianSetUpdate
err = prototext.Unmarshal(b, &msg)
if err != nil {
log.Fatalf("failed to deserialize: %v", err)
}
v, err := adminGuardianSetUpdateToVAA(&msg)
if err != nil {
log.Fatalf("invalid update: %v", err)
}
digest, err := v.SigningMsg()
if err != nil {
panic(err)
}
log.Printf("VAA with digest %s: %+v", digest.Hex(), spew.Sdump(v))
}

View File

@ -38,6 +38,8 @@ var (
nodeKeyPath *string
adminSocketPath *string
bridgeKeyPath *string
ethRPC *string
@ -67,6 +69,8 @@ func init() {
nodeKeyPath = BridgeCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)")
adminSocketPath = BridgeCmd.Flags().String("adminSocket", "", "Admin gRPC service UNIX domain socket path")
bridgeKeyPath = BridgeCmd.Flags().String("bridgeKey", "", "Path to guardian key (required)")
ethRPC = BridgeCmd.Flags().String("ethRPC", "", "Ethereum RPC URL")
@ -133,6 +137,12 @@ func lockMemory() {
}
}
// 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
}
// BridgeCmd represents the bridge command
var BridgeCmd = &cobra.Command{
Use: "bridge",
@ -146,6 +156,7 @@ func runBridge(cmd *cobra.Command, args []string) {
}
lockMemory()
setRestrictiveUmask()
// Set up logging. The go-log zap wrapper that libp2p uses is compatible with our
// usage of zap in supervisor, which is nice.
@ -196,6 +207,9 @@ func runBridge(cmd *cobra.Command, args []string) {
if *bridgeKeyPath == "" {
logger.Fatal("Please specify -bridgeKey")
}
if *adminSocketPath == "" {
logger.Fatal("Please specify -adminSocket")
}
if *agentRPC == "" {
logger.Fatal("Please specify -agentRPC")
}
@ -273,6 +287,9 @@ func runBridge(cmd *cobra.Command, args []string) {
// VAAs to submit to Solana
solanaVaaC := make(chan *vaa.VAA)
// Injected VAAs (manually generated rather than created via observation)
injectC := make(chan *vaa.VAA)
// Load p2p private key
var priv crypto.PrivKey
if *unsafeDevMode {
@ -288,6 +305,11 @@ func runBridge(cmd *cobra.Command, args []string) {
}
}
adminService, err := adminServiceRunnable(logger, *adminSocketPath, injectC)
if err != nil {
logger.Fatal("failed to create admin service socket", zap.Error(err))
}
// Run supervisor.
supervisor.New(rootCtx, logger, func(ctx context.Context) error {
if err := supervisor.Run(ctx, "p2p", p2p.Run(
@ -314,11 +336,15 @@ func runBridge(cmd *cobra.Command, args []string) {
return err
}
p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer)
p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, injectC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer)
if err := supervisor.Run(ctx, "processor", p.Run); err != nil {
return err
}
if err := supervisor.Run(ctx, "admin", adminService); err != nil {
return err
}
logger.Info("Started internal services")
select {

View File

@ -32,6 +32,7 @@ var KeygenCmd = &cobra.Command{
func runKeygen(cmd *cobra.Command, args []string) {
lockMemory()
setRestrictiveUmask()
log.Print("Creating new key at ", args[0])

View File

@ -2,9 +2,10 @@ package cmd
import (
"fmt"
"github.com/spf13/cobra"
"os"
"github.com/spf13/cobra"
homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
@ -34,6 +35,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.guardiand.yaml)")
rootCmd.AddCommand(guardiand.BridgeCmd)
rootCmd.AddCommand(guardiand.KeygenCmd)
rootCmd.AddCommand(guardiand.AdminCmd)
}
// initConfig reads in config file and ENV variables if set.

View File

@ -7,6 +7,7 @@ require (
github.com/aristanetworks/goarista v0.0.0-20201012165903-2cb20defcd66 // indirect
github.com/btcsuite/btcd v0.21.0-beta // indirect
github.com/cenkalti/backoff/v4 v4.1.0
github.com/davecgh/go-spew v1.1.1
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
github.com/deckarep/golang-set v1.7.1 // indirect
github.com/ethereum/go-ethereum v1.9.23
@ -45,7 +46,7 @@ require (
github.com/shirou/gopsutil v2.20.9+incompatible // indirect
github.com/spf13/cobra v1.0.0
github.com/spf13/viper v1.6.3
github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969 // indirect
github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969
github.com/stretchr/testify v1.6.1
github.com/tendermint/tendermint v0.33.8 // indirect
github.com/terra-project/terra.go v1.0.1-0.20201113170042-b3bffdc6fd06

View File

@ -4,6 +4,15 @@ import (
"github.com/ethereum/go-ethereum/common"
)
// TODO: this should be 20, https://github.com/certusone/wormhole/issues/86
//
// Matching constants:
// - MAX_LEN_GUARDIAN_KEYS in Solana contract
//
// The Eth and Terra contracts do not specify a maximum number and support more than 20,
// but presumably, chain-specific transaction size limits will apply at some point (untested).
const MaxGuardianCount = 19
type GuardianSet struct {
// Guardian's public keys truncated by the ETH standard hashing mechanism (20 bytes).
Keys []common.Address

View File

@ -0,0 +1,48 @@
package processor
import (
"context"
"encoding/hex"
"github.com/ethereum/go-ethereum/crypto"
"go.uber.org/zap"
"github.com/certusone/wormhole/bridge/pkg/supervisor"
"github.com/certusone/wormhole/bridge/pkg/vaa"
)
// handleInjection processes a pre-populated VAA injected locally.
func (p *Processor) handleInjection(ctx context.Context, v *vaa.VAA) {
// Check if we're in the guardian set.
us, ok := p.gs.KeyIndex(p.ourAddr)
if !ok {
p.logger.Error("we're not in the guardian set - refusing to sign",
zap.Uint32("index", p.gs.Index),
zap.Stringer("our_addr", p.ourAddr),
zap.Any("set", p.gs.KeysAsHexStrings()))
return
}
// Generate digest of the unsigned VAA.
digest, err := v.SigningMsg()
if err != nil {
panic(err)
}
// The internal originator is responsible for logging the full VAA, just log the digest here.
supervisor.Logger(ctx).Info("signing injected VAA",
zap.Stringer("digest", digest))
// Sign the digest using our node's guardian key.
s, err := crypto.Sign(digest.Bytes(), p.gk)
if err != nil {
panic(err)
}
p.logger.Info("observed and signed injected VAA",
zap.String("digest", hex.EncodeToString(digest.Bytes())),
zap.String("signature", hex.EncodeToString(s)),
zap.Int("our_index", us))
p.broadcastSignature(v, s)
}

View File

@ -138,43 +138,50 @@ func (p *Processor) handleObservation(ctx context.Context, m *gossipv1.LockupObs
panic(err)
}
if t, ok := v.Payload.(*vaa.BodyTransfer); ok {
// Submit every VAA to Solana for data availability.
p.logger.Info("submitting signed VAA to Solana",
zap.String("digest", hash),
zap.Any("vaa", signed),
zap.String("bytes", hex.EncodeToString(vaaBytes)))
p.vaaC <- signed
switch t := v.Payload.(type) {
case *vaa.BodyTransfer:
// Depending on the target chain, guardians submit VAAs directly to the chain.
switch t.TargetChain {
case vaa.ChainIDEthereum,
vaa.ChainIDSolana,
vaa.ChainIDTerra:
// Submit to Solana if target is Solana, but also cross-submit all other targets to Solana for data availability
p.logger.Info("submitting signed VAA to Solana",
zap.String("digest", hash),
zap.Any("vaa", signed),
zap.String("bytes", hex.EncodeToString(vaaBytes)))
// Check whether we run in devmode and submit the VAA ourselves, if so.
switch t.TargetChain {
case vaa.ChainIDEthereum:
p.devnetVAASubmission(ctx, signed, hash)
case vaa.ChainIDTerra:
p.terraVAASubmission(ctx, signed, hash)
}
p.vaaC <- signed
case vaa.ChainIDSolana:
// No-op.
case vaa.ChainIDEthereum:
// Ethereum is special because it's expensive, and guardians cannot
// be expected to pay the fees. We only submit to Ethereum in devnet mode.
p.devnetVAASubmission(ctx, signed, hash)
case vaa.ChainIDTerra:
p.terraVAASubmission(ctx, signed, hash)
default:
p.logger.Error("we don't know how to submit this VAA",
p.logger.Error("unknown target chain ID",
zap.String("digest", hash),
zap.Any("vaa", signed),
zap.String("bytes", hex.EncodeToString(vaaBytes)),
zap.Stringer("target_chain", t.TargetChain))
}
p.state.vaaSignatures[hash].submitted = true
} else {
case *vaa.BodyGuardianSetUpdate:
// A guardian set update is broadcast to every chain that we talk to.
p.devnetVAASubmission(ctx, signed, hash)
p.terraVAASubmission(ctx, signed, hash)
default:
panic(fmt.Sprintf("unknown VAA payload type: %+v", v))
}
p.state.vaaSignatures[hash].submitted = true
} else {
p.logger.Info("quorum not met or already submitted, doing nothing",
zap.String("digest", hash))
}
} else {
p.logger.Info("we have not yet seen this VAA - temporarily storing signature",
zap.String("digest", hash))
}
}

View File

@ -50,6 +50,9 @@ type Processor struct {
// vaaC is a channel of VAAs to submit to store on Solana (either as target, or for data availability)
vaaC chan *vaa.VAA
// injectC is a channel of VAAs injected locally.
injectC chan *vaa.VAA
// gk is the node's guardian private key
gk *ecdsa.PrivateKey
@ -84,6 +87,7 @@ func NewProcessor(
sendC chan []byte,
obsvC chan *gossipv1.LockupObservation,
vaaC chan *vaa.VAA,
injectC chan *vaa.VAA,
gk *ecdsa.PrivateKey,
devnetMode bool,
devnetNumGuardians uint,
@ -99,6 +103,7 @@ func NewProcessor(
sendC: sendC,
obsvC: obsvC,
vaaC: vaaC,
injectC: injectC,
gk: gk,
devnetMode: devnetMode,
devnetNumGuardians: devnetNumGuardians,
@ -134,6 +139,8 @@ func (p *Processor) Run(ctx context.Context) error {
}
case k := <-p.lockC:
p.handleLockup(ctx, k)
case v := <-p.injectC:
p.handleInjection(ctx, v)
case m := <-p.obsvC:
p.handleObservation(ctx, m)
case <-p.cleanup.C:

View File

@ -40,6 +40,8 @@ message Heartbeat {
// guardians submitting valid signatures for a given hash, they can be assembled into a VAA.
//
// Messages without valid signature are dropped unceremoniously.
//
// TODO: rename? we also use it for governance VAAs
message LockupObservation {
// Guardian pubkey as truncated eth address.
bytes addr = 1;

View File

@ -6,9 +6,58 @@ option go_package = "proto/node/v1;nodev1";
import "google/api/annotations.proto";
service Node {
// NodePrivileged exposes an administrative API. It runs on a UNIX socket and is authenticated
// using Linux filesystem permissions.
service NodePrivileged {
// SubmitGuardianSetVAA injects a guardian set change VAA into the guardian node.
// The node will inject the VAA into the aggregator and sign/broadcast the VAA signature.
//
// A consensus majority of nodes on the network will have to inject the VAA within the
// VAA timeout window for it to reach consensus.
//
rpc SubmitGuardianSetVAA (SubmitGuardianSetVAARequest) returns (SubmitGuardianSetVAAResponse);
}
// GuardianSet represents a new guardian set to be submitted to and signed by the node.
// During the genesis procedure, this data structure will be assembled using off-chain collaborative tooling
// like GitHub using a human-readable encoding, so readability is a concern.
message GuardianSetUpdate {
// Index of the current guardian set to be replaced.
uint32 current_set_index = 1;
// UNIX timestamp (s) of the VAA to be created. The timestamp is informational and will be part
// of the VAA submitted to the chain. It's part of the VAA digest and has to be identical across nodes.
//
// For lockups, the timestamp identifies the block that the lockup belongs to. For guardian set updates,
// we create the VAA manually. Best practice is to pick a timestamp which roughly matches the expected
// genesis ceremony data.
//
// The actual on-chain guardian set creation timestamp will be set when the VAA is accepted on each chain.
//
// This is a uint32 to match the on-chain timestamp representation. This becomes a problem in 2106 (sorry).
uint32 timestamp = 2;
// List of guardian set members.
message Guardian {
// Guardian key pubkey. Stored as hex string with 0x prefix for human readability -
// this is the canonical Ethereum representation.
string pubkey = 1;
// Optional descriptive name. Not stored on any chain, purely informational.
string name = 2;
};
repeated Guardian guardians = 3;
}
message SubmitGuardianSetVAARequest {
GuardianSetUpdate guardian_set = 1;
}
message SubmitGuardianSetVAAResponse {
// Canonical digest of the submitted VAA.
bytes digest = 1;
}
// GuardianKey specifies the on-disk format for a node's guardian key.
message GuardianKey {
// description is an optional, free-form description text set by the operator.
string description = 1;

18
scripts/test-injection.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# This script submits a guardian set update using the VAA injection admin command.
# First argument is node to submit to. Second argument is current set index.
set -e
node=$1
idx=$2
path=/tmp/new-guardianset.prototxt
sock=/tmp/admin.sock
# Create a no-op update that sets the same 1-node guardian set again.
kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-template --num=1 --idx=${idx} $path
# Verify and print resulting result. The digest incorporates the current time and is NOT deterministic.
kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-verify $path
# Submit to node
kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-inject --socket $sock $path