From e9c205e82f9614bf73543acd8c1abcf5213e5406 Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Mon, 25 Sep 2023 14:10:47 +0400 Subject: [PATCH] adm: Add commands to work with private node attributes Add command to get and set list of public keys for the storage nodes allowed to use private attribute. Refs #2280. Signed-off-by: Leonard Lyubich --- .../internal/modules/morph/n3client.go | 2 +- cmd/neofs-adm/internal/modules/morph/nns.go | 121 ++++++++++ .../modules/morph/private_node_attributes.go | 208 ++++++++++++++++++ cmd/neofs-adm/internal/modules/morph/root.go | 63 ++++++ docs/private-node-attributes.md | 28 +++ 5 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 cmd/neofs-adm/internal/modules/morph/nns.go create mode 100644 cmd/neofs-adm/internal/modules/morph/private_node_attributes.go diff --git a/cmd/neofs-adm/internal/modules/morph/n3client.go b/cmd/neofs-adm/internal/modules/morph/n3client.go index 138943b6e4c..720744a0ceb 100644 --- a/cmd/neofs-adm/internal/modules/morph/n3client.go +++ b/cmd/neofs-adm/internal/modules/morph/n3client.go @@ -58,7 +58,7 @@ type clientContext struct { SentTxs []hashVUBPair } -func getN3Client(v *viper.Viper) (Client, error) { +func getN3Client(v *viper.Viper) (*rpcclient.Client, error) { // number of opened connections // by neo-go client per one host const ( diff --git a/cmd/neofs-adm/internal/modules/morph/nns.go b/cmd/neofs-adm/internal/modules/morph/nns.go new file mode 100644 index 00000000000..a8fe2991140 --- /dev/null +++ b/cmd/neofs-adm/internal/modules/morph/nns.go @@ -0,0 +1,121 @@ +package morph + +import ( + "errors" + "fmt" + "strings" + + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + nnsrpc "github.com/nspcc-dev/neofs-contract/rpc/nns" +) + +// Various NeoFS NNS errors. +var ( + errMissingDomain = errors.New("missing domain") + errMissingDomainRecords = errors.New("missing domain records") +) + +type errInvalidNNSDomainRecord struct { + domain string + cause error +} + +func invalidNNSDomainRecordError(domain string, cause error) errInvalidNNSDomainRecord { + return errInvalidNNSDomainRecord{ + domain: domain, + cause: cause, + } +} + +func (x errInvalidNNSDomainRecord) Error() string { + return fmt.Sprintf("invalid record of the NNS domain %q: %s", x.domain, x.cause) +} + +func (x errInvalidNNSDomainRecord) Unwrap() error { + return x.cause +} + +var errBreakIterator = errors.New("break iterator") + +// iterates over text records of the specified NeoFS NNS domain and passes them +// into f. Breaks on any f's error and returns it (if f returns +// errBreakIterator, iterateNNSDomainTextRecords returns no error). Returns +// errMissingDomain if domain is missing in the NNS. Returns +// errMissingDomainRecords if domain exists but has no records. +func iterateNNSDomainTextRecords(inv nnsrpc.Invoker, nnsContractAddr util.Uint160, domain string, f func(string) error) error { + nnsContract := nnsrpc.NewReader(inv, nnsContractAddr) + + sessionID, iter, err := nnsContract.GetAllRecords(domain) + if err != nil { + // Track https://github.com/nspcc-dev/neofs-node/issues/2583. + if strings.Contains(err.Error(), "token not found") { + return errMissingDomain + } + + return fmt.Errorf("init iterator over all records of the NNS domain %q: %w", domain, err) + } + + defer func() { + _ = inv.TerminateSession(sessionID) + }() + + hasRecords := false + + for { + items, err := inv.TraverseIterator(sessionID, &iter, 10) + if err != nil { + return fmt.Errorf("NNS domain %q records' iterator break: %w", domain, err) + } + + if len(items) == 0 { + if hasRecords { + return nil + } + + return errMissingDomainRecords + } + + hasRecords = true + + for i := range items { + fields, ok := items[i].Value().([]stackitem.Item) + if !ok { + return invalidNNSDomainRecordError(domain, + fmt.Errorf("unexpected type %s instead of %s", stackitem.StructT, items[i].Type())) + } + + if len(fields) < 3 { + return invalidNNSDomainRecordError(domain, + fmt.Errorf("unsupported number of struct fields: expected at least 3, got %d", len(fields))) + } + + _, err = fields[0].TryBytes() + if err != nil { + return invalidNNSDomainRecordError(domain, fmt.Errorf("1st field is not a byte array: got %v", fields[0].Type())) + } + + typ, err := fields[1].TryInteger() + if err != nil { + return invalidNNSDomainRecordError(domain, fmt.Errorf("2nd field is not an integer: got %v", fields[1].Type())) + } + + if typ.Cmp(nnsrpc.TXT) != 0 { + return invalidNNSDomainRecordError(domain, fmt.Errorf("non-TXT record of type %v", typ)) + } + + data, err := fields[2].TryBytes() + if err != nil { + return invalidNNSDomainRecordError(domain, fmt.Errorf("3rd field is not a byte array: got %v", fields[2].Type())) + } + + if err = f(string(data)); err != nil { + if errors.Is(err, errBreakIterator) { + return nil + } + + return invalidNNSDomainRecordError(domain, err) + } + } + } +} diff --git a/cmd/neofs-adm/internal/modules/morph/private_node_attributes.go b/cmd/neofs-adm/internal/modules/morph/private_node_attributes.go new file mode 100644 index 00000000000..70bb102f121 --- /dev/null +++ b/cmd/neofs-adm/internal/modules/morph/private_node_attributes.go @@ -0,0 +1,208 @@ +package morph + +import ( + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/invoker" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + nnsrpc "github.com/nspcc-dev/neofs-contract/rpc/nns" + "github.com/nspcc-dev/neofs-node/cmd/neofs-adm/internal/modules/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func attributeNNSDomain(s string) string { + cs := md5.Sum([]byte(s)) + return hex.EncodeToString(cs[:]) +} + +const privateAttributesRootDomain = "private-node-attributes.neofs" + +func privateAttributeDomain(key, value string) string { + return attributeNNSDomain(value) + "." + attributeNNSDomain(key) + "." + privateAttributesRootDomain +} + +func privateAttributeAccessList(cmd *cobra.Command, _ []string) error { + vpr := viper.GetViper() + + attrKey := vpr.GetString(attributeKeyFlag) + if attrKey == "" { + return errors.New("empty attribute key is not allowed") + } + + attrVal := vpr.GetString(attributeValueFlag) + if attrVal == "" { + return errors.New("empty attribute value is not allowed") + } + + n3Client, err := getN3Client(vpr) + if err != nil { + return fmt.Errorf("open connection: %w", err) + } + + nnsState, err := n3Client.GetContractStateByID(1) + if err != nil { + return fmt.Errorf("get NeoFS NNS contract state: %w", err) + } + + domain := privateAttributeDomain(attrKey, attrVal) + + err = iterateNNSDomainTextRecords(invoker.New(n3Client, nil), nnsState.Hash, domain, func(rec string) error { + _, err := hex.DecodeString(rec) + if err != nil { + return fmt.Errorf("not a HEX string %q", rec) + } + + cmd.Println(rec) + + return nil + }) + if err != nil { + if errors.Is(err, errMissingDomain) { + cmd.Printf("Attribute is not private (missing NNS domain %q).\n", domain) + return nil + } + + if errors.Is(err, errMissingDomainRecords) { + cmd.Printf("Attribute is not private (NNS domain %q has no records).\n", domain) + return nil + } + } + + return err +} + +func privateAttributeSetAccessList(cmd *cobra.Command, _ []string) error { + vpr := viper.GetViper() + + attrKey := vpr.GetString(attributeKeyFlag) + if attrKey == "" { + return errors.New("empty attribute key is not allowed") + } + + attrVal := vpr.GetString(attributeValueFlag) + if attrVal == "" { + return errors.New("empty attribute value is not allowed") + } + + strKeys := vpr.GetStringSlice(publicKeysFlag) + if len(strKeys) == 0 { + return errors.New("empty public key list is not allowed") + } + + for i := range strKeys { + _, err := hex.DecodeString(strKeys[i]) + if err != nil { + return fmt.Errorf("key #%d is not a valid HEX string", i) + } + + for j := i + 1; j < len(strKeys); j++ { + if strKeys[i] == strKeys[j] { + return fmt.Errorf("duplicated public key %s", strKeys[i]) + } + } + } + + walletDir := config.ResolveHomePath(vpr.GetString(alphabetWalletsFlag)) + + wallets, err := openAlphabetWallets(vpr, walletDir) + if err != nil { + return err + } + + committeeAcc, err := getWalletAccount(wallets[0], committeeAccountName) + if err != nil { + return fmt.Errorf("get committee account: %w", err) + } + + n3Client, err := getN3Client(vpr) + if err != nil { + return fmt.Errorf("open connection: %w", err) + } + + nnsState, err := n3Client.GetContractStateByID(1) + if err != nil { + return fmt.Errorf("get NeoFS NNS contract state: %w", err) + } + + actr, err := actor.NewSimple(n3Client, committeeAcc) + if err != nil { + return fmt.Errorf("init committee actor: %w", err) + } + + attrKeyDomain := attributeNNSDomain(attrKey) + attrValDomain := attributeNNSDomain(attrVal) + + fullDomain := attrValDomain + "." + attrKeyDomain + "." + privateAttributesRootDomain + + scriptBuilder := smartcontract.NewBuilder() + + hasOtherKey := false + mAlreadySetIndices := make(map[int]struct{}, len(strKeys)) + + err = iterateNNSDomainTextRecords(actr, nnsState.Hash, fullDomain, func(rec string) error { + for i := range strKeys { + if strKeys[i] == rec { + mAlreadySetIndices[i] = struct{}{} + return nil + } + } + + hasOtherKey = true + + return errBreakIterator + }) + if err != nil { + switch { + default: + return err + case errors.Is(err, errMissingDomain): + scriptBuilder.InvokeMethod(nnsState.Hash, "register", + privateAttributesRootDomain, committeeAcc.ScriptHash(), "ops@nspcc.ru", int64(3600), int64(600), int64(defaultExpirationTime), int64(3600)) + case errors.Is(err, errMissingDomainRecords): + } + } + + if !hasOtherKey && len(mAlreadySetIndices) == len(strKeys) { + cmd.Println("Key list is already the same, skip.") + return nil + } + + if hasOtherKey { + // there is no way to delete particular key, so clean all first + scriptBuilder.InvokeMethod(nnsState.Hash, "deleteRecords", + fullDomain, nnsrpc.TXT.Int64()) + } + + for i := range strKeys { + if !hasOtherKey { + if _, ok := mAlreadySetIndices[i]; ok { + continue + } + } + + scriptBuilder.InvokeMethod(nnsState.Hash, "addRecord", + fullDomain, nnsrpc.TXT.Int64(), strKeys[i]) + } + + txScript, err := scriptBuilder.Script() + if err != nil { + return fmt.Errorf("build transaction script: %w", err) + } + + txID, vub, err := actr.SendRun(txScript) + if err != nil { + if err != nil { + return fmt.Errorf("send transction with built script: %w", err) + } + } + + return awaitTx(cmd, n3Client, []hashVUBPair{{ + hash: txID, + vub: vub, + }}) +} diff --git a/cmd/neofs-adm/internal/modules/morph/root.go b/cmd/neofs-adm/internal/modules/morph/root.go index 9d092f118c1..c643132e2cb 100644 --- a/cmd/neofs-adm/internal/modules/morph/root.go +++ b/cmd/neofs-adm/internal/modules/morph/root.go @@ -42,6 +42,9 @@ const ( localDumpFlag = "local-dump" protoConfigPath = "protocol" walletAddressFlag = "wallet-address" + attributeKeyFlag = "attr-key" + attributeValueFlag = "attr-val" + publicKeysFlag = "public-keys" ) var ( @@ -257,6 +260,39 @@ Values for unknown keys are added exactly the way they're provided, no conversio }, RunE: listNetmapCandidatesNodes, } + + privateNodeAttributesCmd = &cobra.Command{ + Use: "private-node-attributes", + Short: "Group of commands to work with private attributes of the storage nodes", + Args: cobra.NoArgs, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlag(endpointFlag, cmd.Flags().Lookup(endpointFlag)) + _ = viper.BindPFlag(attributeKeyFlag, cmd.Flags().Lookup(attributeKeyFlag)) + _ = viper.BindPFlag(attributeValueFlag, cmd.Flags().Lookup(attributeValueFlag)) + }, + } + + privateAttributeAccessListCmd = &cobra.Command{ + Use: "access-list", + Short: "Get access list for particular attribute of the storage nodes", + Long: "List HEX-encoded public keys of storage nodes that have access to use the attribute. " + + "If attribute is not private, corresponding message is shown meaning that any storage node may use this attribute.", + Args: cobra.NoArgs, + RunE: privateAttributeAccessList, + } + + privateAttributeSetAccessListCmd = &cobra.Command{ + Use: "set-access-list", + Short: "Set access list for particular attributes of the storage nodes", + Long: "Set list of HEX-encoded public keys of storage nodes that have access to use the attribute. " + + "The list must not be empty and unique.", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlag(publicKeysFlag, cmd.Flags().Lookup(publicKeysFlag)) + _ = viper.BindPFlag(alphabetWalletsFlag, cmd.Flags().Lookup(alphabetWalletsFlag)) + }, + RunE: privateAttributeSetAccessList, + } ) func init() { @@ -365,4 +401,31 @@ func init() { RootCmd.AddCommand(netmapCandidatesCmd) netmapCandidatesCmd.Flags().StringP(endpointFlag, "r", "", "N3 RPC node endpoint") + + privateAttributesAccessListCmdFlags := privateAttributeAccessListCmd.Flags() + privateAttributesAccessListCmdFlags.StringP(endpointFlag, "r", "", "NeoFS Sidechain RPC endpoint") + _ = privateAttributeAccessListCmd.MarkFlagRequired(endpointFlag) + privateAttributesAccessListCmdFlags.String(attributeKeyFlag, "", "Key to the private node attribute") + _ = privateAttributeAccessListCmd.MarkFlagRequired(attributeKeyFlag) + privateAttributesAccessListCmdFlags.String(attributeValueFlag, "", "Value of the private node attribute") + _ = privateAttributeAccessListCmd.MarkFlagRequired(attributeValueFlag) + + privateAttributesSetAccessListCmdFlags := privateAttributeSetAccessListCmd.Flags() + privateAttributesSetAccessListCmdFlags.String(alphabetWalletsFlag, "", "Path to directory containing Alphabet wallets in files 'az.json', 'buky.json', etc.") + _ = privateAttributeSetAccessListCmd.MarkFlagRequired(alphabetWalletsFlag) + privateAttributesSetAccessListCmdFlags.StringP(endpointFlag, "r", "", "NeoFS Sidechain RPC endpoint") + _ = privateAttributeSetAccessListCmd.MarkFlagRequired(endpointFlag) + privateAttributesSetAccessListCmdFlags.String(attributeKeyFlag, "", "Key to the private node attribute") + _ = privateAttributeSetAccessListCmd.MarkFlagRequired(attributeKeyFlag) + privateAttributesSetAccessListCmdFlags.String(attributeValueFlag, "", "Value of the private node attribute") + _ = privateAttributeSetAccessListCmd.MarkFlagRequired(attributeValueFlag) + privateAttributesSetAccessListCmdFlags.StringSlice(publicKeysFlag, nil, "HEX-encoded public keys of the storage nodes") + _ = privateAttributeSetAccessListCmd.MarkFlagRequired(publicKeysFlag) + + privateNodeAttributesCmd.AddCommand( + privateAttributeAccessListCmd, + privateAttributeSetAccessListCmd, + ) + + RootCmd.AddCommand(privateNodeAttributesCmd) } diff --git a/docs/private-node-attributes.md b/docs/private-node-attributes.md index a3acb4224a4..9749a4a8485 100644 --- a/docs/private-node-attributes.md +++ b/docs/private-node-attributes.md @@ -24,3 +24,31 @@ For each public key, a record is created - a structure with at least 3 fields: 1. `ByteString` with name of the corresponding domain 2. `Integer` that should be `16` (TXT records) 3. `ByteString` with HEX-encoded public key + +## CLI + +NeoFS ADM tool may be used to work with private attributes from command line. + +### Get access list +``` +$ neofs-adm morph private-node-attributes access-list -r https://rpc1.morph.t5.fs.neo.org:51331 \ + --attr-key some-key --attr-val some-value +02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2 +02103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e +03d90c07df63e690ce77912e10ab51acc944b66860237b608c4f8f8309e71ee699 +02a7bc55fe8684e0119768d104ba30795bdcc86619e864add26156723ed185cd62 +``` +where `-r` is the NeoFS Sidechain network endpoint. + +### Set access list +``` +$ neofs-adm morph private-node-attributes set-access-list -r https://rpc1.morph.t5.fs.neo.org:51331 \ + -attr-key some-key --attr-val some-value --alphabet-wallets ./ \ + --public-keys 037b4b68167f6b62352839a65c466d2f94485c5c28960440dc9e89ce1b870f4d7e + --public-keys 027f710643796348e7a95f49665d63c1237974e61a273eb2ae47b05654eb7ac4c1 +$ Password for az wallet > +$ Waiting for transactions to persist... +$ +``` +where `--alphabet-wallets` should lead to directory with NeoFS Alphabet wallet +files `az.json`, `buky.json`, etc.