diff --git a/node/cmd/ccq/http.go b/node/cmd/ccq/http.go index 8193278d3..2823f7057 100644 --- a/node/cmd/ccq/http.go +++ b/node/cmd/ccq/http.go @@ -35,7 +35,7 @@ type httpServer struct { topic *pubsub.Topic logger *zap.Logger env common.Environment - permissions Permissions + permissions *Permissions signerKey *ecdsa.PrivateKey pendingResponses *PendingResponses } @@ -79,7 +79,7 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) { apiKey := strings.ToLower(apiKeys[0]) // Make sure the user is authorized before we go any farther. - permEntry, exists := s.permissions[apiKey] + permEntry, exists := s.permissions.GetUserEntry(apiKey) if !exists { s.logger.Error("invalid api key", zap.String("apiKey", apiKey)) http.Error(w, "invalid api key", http.StatusForbidden) @@ -202,7 +202,7 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) { s.pendingResponses.Remove(pendingResponse) } -func NewHTTPServer(addr string, t *pubsub.Topic, permissions Permissions, signerKey *ecdsa.PrivateKey, p *PendingResponses, logger *zap.Logger, env common.Environment) *http.Server { +func NewHTTPServer(addr string, t *pubsub.Topic, permissions *Permissions, signerKey *ecdsa.PrivateKey, p *PendingResponses, logger *zap.Logger, env common.Environment) *http.Server { s := &httpServer{ topic: t, permissions: permissions, diff --git a/node/cmd/ccq/metrics.go b/node/cmd/ccq/metrics.go index caa451e58..bf784eb95 100644 --- a/node/cmd/ccq/metrics.go +++ b/node/cmd/ccq/metrics.go @@ -60,4 +60,16 @@ var ( Help: "Time from request to response published in ms", Buckets: []float64{10.0, 100.0, 250.0, 500.0, 1000.0, 5000.0, 10000.0, 30000.0}, }) + + permissionFileReloadsSuccess = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ccq_server_perm_file_reload_success", + Help: "Total number of times the permissions file was successfully reloaded", + }) + + permissionFileReloadsFailure = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ccq_server_perm_file_reload_failure", + Help: "Total number of times the permissions file failed to reload", + }) ) diff --git a/node/cmd/ccq/permissions.go b/node/cmd/ccq/permissions.go new file mode 100644 index 000000000..f414bc4c5 --- /dev/null +++ b/node/cmd/ccq/permissions.go @@ -0,0 +1,247 @@ +package ccq + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/certusone/wormhole/node/pkg/common" + "github.com/wormhole-foundation/wormhole/sdk/vaa" + "go.uber.org/zap" + + "gopkg.in/godo.v2/watcher/fswatch" +) + +type ( + Config struct { + Permissions []User `json:"Permissions"` + } + + User struct { + UserName string `json:"userName"` + ApiKey string `json:"apiKey"` + AllowUnsigned bool `json:"allowUnsigned"` + AllowedCalls []AllowedCall `json:"allowedCalls"` + } + + AllowedCall struct { + EthCall *EthCall `json:"ethCall"` + EthCallByTimestamp *EthCallByTimestamp `json:"ethCallByTimestamp"` + EthCallWithFinality *EthCallWithFinality `json:"ethCallWithFinality"` + } + + EthCall struct { + Chain int `json:"chain"` + ContractAddress string `json:"contractAddress"` + Call string `json:"call"` + } + + EthCallByTimestamp struct { + Chain int `json:"chain"` + ContractAddress string `json:"contractAddress"` + Call string `json:"call"` + } + + EthCallWithFinality struct { + Chain int `json:"chain"` + ContractAddress string `json:"contractAddress"` + Call string `json:"call"` + } + + PermissionsMap map[string]*permissionEntry + + permissionEntry struct { + userName string + apiKey string + allowUnsigned bool + allowedCalls allowedCallsForUser // Key is something like "ethCall:2:000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6:06fdde03" + } + + allowedCallsForUser map[string]struct{} + + Permissions struct { + lock sync.Mutex + permMap PermissionsMap + fileName string + watcher *fswatch.Watcher + } +) + +// NewPermissions creates a Permissions object which contains the per-user permissions. +func NewPermissions(fileName string) (*Permissions, error) { + permMap, err := parseConfigFile(fileName) + if err != nil { + return nil, err + } + + return &Permissions{ + permMap: permMap, + fileName: fileName, + }, nil +} + +// StartWatcher creates an fswatcher to watch for updates to the permissions file and reload it when it changes. +func (perms *Permissions) StartWatcher(ctx context.Context, logger *zap.Logger, errC chan error) { + logger = logger.With(zap.String("component", "perms")) + perms.watcher = fswatch.NewWatcher(perms.fileName) + fsChan := perms.watcher.Start() + + common.RunWithScissors(ctx, errC, "perm_file_watcher", func(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + case notif := <-fsChan: + if notif.Path != perms.fileName { + return fmt.Errorf("permissions watcher received an update for an unexpected file: %s", notif.Path) + } + + logger.Info("the permissions file has been updated", zap.String("fileName", notif.Path), zap.Int("event", int(notif.Event))) + perms.Reload(logger) + } + } + }) +} + +// Reload reloads the permissions file. +func (perms *Permissions) Reload(logger *zap.Logger) { + permMap, err := parseConfigFile(perms.fileName) + if err != nil { + logger.Error("failed to reload the permissions file, sticking with the old one", zap.String("fileName", perms.fileName), zap.Error(err)) + permissionFileReloadsFailure.Inc() + return + } + + logger.Info("successfully reloaded the permissions file, switching to it", zap.String("fileName", perms.fileName)) + perms.lock.Lock() + perms.permMap = permMap + perms.lock.Unlock() + permissionFileReloadsSuccess.Inc() +} + +// StopWatcher stops the permissions file watcher. +func (perms *Permissions) StopWatcher() { + if perms.watcher != nil { + perms.watcher.Stop() + } +} + +// GetUserEntry returns the permissions entry for a given API key. It uses the lock to protect against updates. +func (perms *Permissions) GetUserEntry(apiKey string) (*permissionEntry, bool) { + perms.lock.Lock() + defer perms.lock.Unlock() + userEntry, exists := perms.permMap[apiKey] + return userEntry, exists +} + +const ETH_CALL_SIG_LENGTH = 4 + +// parseConfigFile parses the permissions config file into a map keyed by API key. +func parseConfigFile(fileName string) (PermissionsMap, error) { + jsonFile, err := os.Open(fileName) + if err != nil { + return nil, fmt.Errorf(`failed to open permissions file "%s": %w`, fileName, err) + } + defer jsonFile.Close() + + byteValue, err := io.ReadAll(jsonFile) + if err != nil { + return nil, fmt.Errorf(`failed to read permissions file "%s": %w`, fileName, err) + } + + retVal, err := parseConfig(byteValue) + if err != nil { + return retVal, fmt.Errorf(`failed to parse permissions file "%s": %w`, fileName, err) + } + + return retVal, err +} + +// parseConfig parses the permissions config from a buffer into a map keyed by API key. +func parseConfig(byteValue []byte) (PermissionsMap, error) { + var config Config + if err := json.Unmarshal(byteValue, &config); err != nil { + return nil, fmt.Errorf(`failed to unmarshal json: %w`, err) + } + + ret := make(PermissionsMap) + userNames := map[string]struct{}{} + for _, user := range config.Permissions { + // Since we log user names in all our error messages, make sure they are unique. + if _, exists := userNames[user.UserName]; exists { + return nil, fmt.Errorf(`UserName "%s" is a duplicate`, user.UserName) + } + userNames[user.UserName] = struct{}{} + + apiKey := strings.ToLower(user.ApiKey) + if _, exists := ret[apiKey]; exists { + return nil, fmt.Errorf(`API key "%s" is a duplicate`, apiKey) + } + + // Build the list of allowed calls for this API key. + allowedCalls := make(allowedCallsForUser) + for _, ac := range user.AllowedCalls { + var chain int + var callType, contractAddressStr, callStr string + // var contractAddressStr string + if ac.EthCall != nil { + callType = "ethCall" + chain = ac.EthCall.Chain + contractAddressStr = ac.EthCall.ContractAddress + callStr = ac.EthCall.Call + } else if ac.EthCallByTimestamp != nil { + callType = "ethCallByTimestamp" + chain = ac.EthCallByTimestamp.Chain + contractAddressStr = ac.EthCallByTimestamp.ContractAddress + callStr = ac.EthCallByTimestamp.Call + } else if ac.EthCallWithFinality != nil { + callType = "ethCallWithFinality" + chain = ac.EthCallWithFinality.Chain + contractAddressStr = ac.EthCallWithFinality.ContractAddress + callStr = ac.EthCallWithFinality.Call + } else { + return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, user.UserName) + } + + // Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6". + contractAddress, err := vaa.StringToAddress(contractAddressStr) + if err != nil { + return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName) + } + + // The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03". + call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x")) + if err != nil { + return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName) + } + if len(call) != ETH_CALL_SIG_LENGTH { + return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH) + } + + // The permission key is the chain, contract address and call formatted as a colon separated string. + callKey := fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call)) + + if _, exists := allowedCalls[callKey]; exists { + return nil, fmt.Errorf(`"%s" is a duplicate allowed call for user "%s"`, callKey, user.UserName) + } + + allowedCalls[callKey] = struct{}{} + } + + pe := &permissionEntry{ + userName: user.UserName, + apiKey: apiKey, + allowUnsigned: user.AllowUnsigned, + allowedCalls: allowedCalls, + } + + ret[apiKey] = pe + } + + return ret, nil +} diff --git a/node/cmd/ccq/query_server.go b/node/cmd/ccq/query_server.go index 6e8c2cf7d..579bdf41e 100644 --- a/node/cmd/ccq/query_server.go +++ b/node/cmd/ccq/query_server.go @@ -132,7 +132,7 @@ func runQueryServer(cmd *cobra.Command, args []string) { logger.Fatal("Please specify --ethContract") } - permissions, err := parseConfigFile(*permFile) + permissions, err := NewPermissions(*permFile) if err != nil { logger.Fatal("Failed to load permissions file", zap.String("permFile", *permFile), zap.Error(err)) } @@ -213,11 +213,24 @@ func runQueryServer(cmd *cobra.Command, args []string) { cancel() }() - <-ctx.Done() - logger.Info("Context cancelled, exiting...") + // Start watching for permissions file updates. + errC := make(chan error) + permissions.StartWatcher(ctx, logger, errC) - // Cleanly shutdown - // Without this the same host won't properly discover peers until some timeout + // Wait for either a shutdown or a fatal error from the permissions watcher. + select { + case <-ctx.Done(): + logger.Info("Context cancelled, exiting...") + break + case err := <-errC: + logger.Error("Encountered an error, exiting", zap.Error(err)) + break + } + + // Stop the permissions file watcher. + permissions.StopWatcher() + + // Shutdown p2p. Without this the same host won't properly discover peers until some timeout p2p.sub.Cancel() if err := p2p.topic_req.Close(); err != nil { logger.Error("Error closing the request topic", zap.Error(err)) diff --git a/node/cmd/ccq/utils.go b/node/cmd/ccq/utils.go index 1f10f14e6..2b83d1e22 100644 --- a/node/cmd/ccq/utils.go +++ b/node/cmd/ccq/utils.go @@ -4,12 +4,8 @@ import ( "context" "crypto/ecdsa" "encoding/hex" - "encoding/json" "fmt" - "io" "net/http" - "os" - "strings" "time" "github.com/certusone/wormhole/node/pkg/common" @@ -53,162 +49,9 @@ func FetchCurrentGuardianSet(rpcUrl, coreAddr string) (*common.GuardianSet, erro }, nil } -type Config struct { - Permissions []User `json:"Permissions"` -} - -type User struct { - UserName string `json:"userName"` - ApiKey string `json:"apiKey"` - AllowUnsigned bool `json:"allowUnsigned"` - AllowedCalls []AllowedCall `json:"allowedCalls"` -} - -type AllowedCall struct { - EthCall *EthCall `json:"ethCall"` - EthCallByTimestamp *EthCallByTimestamp `json:"ethCallByTimestamp"` - EthCallWithFinality *EthCallWithFinality `json:"ethCallWithFinality"` -} - -type EthCall struct { - Chain int `json:"chain"` - ContractAddress string `json:"contractAddress"` - Call string `json:"call"` -} - -type EthCallByTimestamp struct { - Chain int `json:"chain"` - ContractAddress string `json:"contractAddress"` - Call string `json:"call"` -} - -type EthCallWithFinality struct { - Chain int `json:"chain"` - ContractAddress string `json:"contractAddress"` - Call string `json:"call"` -} - -type Permissions map[string]*permissionEntry - -type permissionEntry struct { - userName string - apiKey string - allowUnsigned bool - allowedCalls allowedCallsForUser // Key is something like "ethCall:2:000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6:06fdde03" -} - -type allowedCallsForUser map[string]struct{} - -const ETH_CALL_SIG_LENGTH = 4 - -// parseConfigFile parses the permissions config file into a map keyed by API key. -func parseConfigFile(fileName string) (Permissions, error) { - jsonFile, err := os.Open(fileName) - if err != nil { - return nil, fmt.Errorf(`failed to open permissions file "%s": %w`, fileName, err) - } - defer jsonFile.Close() - - byteValue, err := io.ReadAll(jsonFile) - if err != nil { - return nil, fmt.Errorf(`failed to read permissions file "%s": %w`, fileName, err) - } - - retVal, err := parseConfig(byteValue) - if err != nil { - return retVal, fmt.Errorf(`failed to parse permissions file "%s": %w`, fileName, err) - } - - return retVal, err -} - -// parseConfig parses the permissions config from a buffer into a map keyed by API key. -func parseConfig(byteValue []byte) (Permissions, error) { - var config Config - if err := json.Unmarshal(byteValue, &config); err != nil { - return nil, fmt.Errorf(`failed to unmarshal json: %w`, err) - } - - ret := make(Permissions) - userNames := map[string]struct{}{} - for _, user := range config.Permissions { - // Since we log user names in all our error messages, make sure they are unique. - if _, exists := userNames[user.UserName]; exists { - return nil, fmt.Errorf(`UserName "%s" is a duplicate`, user.UserName) - } - userNames[user.UserName] = struct{}{} - - apiKey := strings.ToLower(user.ApiKey) - if _, exists := ret[apiKey]; exists { - return nil, fmt.Errorf(`API key "%s" is a duplicate`, apiKey) - } - - // Build the list of allowed calls for this API key. - allowedCalls := make(allowedCallsForUser) - for _, ac := range user.AllowedCalls { - var chain int - var callType, contractAddressStr, callStr string - // var contractAddressStr string - if ac.EthCall != nil { - callType = "ethCall" - chain = ac.EthCall.Chain - contractAddressStr = ac.EthCall.ContractAddress - callStr = ac.EthCall.Call - } else if ac.EthCallByTimestamp != nil { - callType = "ethCallByTimestamp" - chain = ac.EthCallByTimestamp.Chain - contractAddressStr = ac.EthCallByTimestamp.ContractAddress - callStr = ac.EthCallByTimestamp.Call - } else if ac.EthCallWithFinality != nil { - callType = "ethCallWithFinality" - chain = ac.EthCallWithFinality.Chain - contractAddressStr = ac.EthCallWithFinality.ContractAddress - callStr = ac.EthCallWithFinality.Call - } else { - return nil, fmt.Errorf(`unsupported call type for user "%s", must be "ethCall", "ethCallByTimestamp" or "ethCallWithFinality"`, user.UserName) - } - - // Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6". - contractAddress, err := vaa.StringToAddress(contractAddressStr) - if err != nil { - return nil, fmt.Errorf(`invalid contract address "%s" for user "%s"`, contractAddressStr, user.UserName) - } - - // The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03". - call, err := hex.DecodeString(strings.TrimPrefix(callStr, "0x")) - if err != nil { - return nil, fmt.Errorf(`invalid eth call "%s" for user "%s"`, callStr, user.UserName) - } - if len(call) != ETH_CALL_SIG_LENGTH { - return nil, fmt.Errorf(`eth call "%s" for user "%s" has an invalid length, must be %d bytes`, callStr, user.UserName, ETH_CALL_SIG_LENGTH) - } - - // The permission key is the chain, contract address and call formatted as a colon separated string. - callKey := fmt.Sprintf("%s:%d:%s:%s", callType, chain, contractAddress, hex.EncodeToString(call)) - - if _, exists := allowedCalls[callKey]; exists { - return nil, fmt.Errorf(`"%s" is a duplicate allowed call for user "%s"`, callKey, user.UserName) - } - - allowedCalls[callKey] = struct{}{} - } - - pe := &permissionEntry{ - userName: user.UserName, - apiKey: apiKey, - allowUnsigned: user.AllowUnsigned, - allowedCalls: allowedCalls, - } - - ret[apiKey] = pe - } - - return ret, nil -} - // validateRequest verifies that this API key is allowed to do all of the calls in this request. In the case of an error, it returns the HTTP status. -func validateRequest(logger *zap.Logger, env common.Environment, perms Permissions, signerKey *ecdsa.PrivateKey, apiKey string, qr *gossipv1.SignedQueryRequest) (int, error) { - permsForUser, exists := perms[apiKey] +func validateRequest(logger *zap.Logger, env common.Environment, perms *Permissions, signerKey *ecdsa.PrivateKey, apiKey string, qr *gossipv1.SignedQueryRequest) (int, error) { + permsForUser, exists := perms.GetUserEntry(apiKey) if !exists { logger.Debug("invalid api key", zap.String("apiKey", apiKey)) invalidQueryRequestReceived.WithLabelValues("invalid_api_key").Inc() diff --git a/node/go.mod b/node/go.mod index e1079a517..d494c02f8 100644 --- a/node/go.mod +++ b/node/go.mod @@ -229,6 +229,7 @@ require ( github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mgutz/str v1.2.0 // indirect github.com/miekg/dns v1.1.55 // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect @@ -344,6 +345,7 @@ require ( gonum.org/v1/gonum v0.12.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect + gopkg.in/godo.v2 v2.0.9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/node/go.sum b/node/go.sum index 4b219cb8c..f95869fa6 100644 --- a/node/go.sum +++ b/node/go.sum @@ -2178,6 +2178,8 @@ github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aks github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg= github.com/mgechev/revive v1.2.1/go.mod h1:+Ro3wqY4vakcYNtkBWdZC7dBg1xSB6sp054wWwmeFm0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw= +github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -4247,6 +4249,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/gcfg.v1 v1.2.0/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/godo.v2 v2.0.9 h1:jnbznTzXVk0JDKOxN3/LJLDPYJzIl0734y+Z0cEJb4A= +gopkg.in/godo.v2 v2.0.9/go.mod h1:wgvPPKLsWN0hPIJ4JyxvFGGbIW3fJMSrXhdvSuZ1z/8= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=