-
Notifications
You must be signed in to change notification settings - Fork 729
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Node/CCQ/Server: Add permissions file watcher (#3586)
- Loading branch information
1 parent
2a3d4c8
commit fd05cb0
Showing
7 changed files
with
288 additions
and
167 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.