From 23f9c703e2acec369f60c4d1764f02fc707feb7c 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 verified nodes' domains Add commands to get and set list of the storage nodes allowed to use domain of the private node group. Refs #2280. Signed-off-by: Leonard Lyubich --- .../internal/modules/morph/n3client.go | 2 +- cmd/neofs-adm/internal/modules/morph/nns.go | 107 +++++++++ cmd/neofs-adm/internal/modules/morph/root.go | 60 +++++ .../modules/morph/verified_domains.go | 214 ++++++++++++++++++ cmd/neofs-cli/modules/storagegroup/put.go | 3 +- docs/verified-node-domains.md | 54 +++++ 6 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 cmd/neofs-adm/internal/modules/morph/nns.go create mode 100644 cmd/neofs-adm/internal/modules/morph/verified_domains.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..815c6fb65ac --- /dev/null +++ b/cmd/neofs-adm/internal/modules/morph/nns.go @@ -0,0 +1,107 @@ +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 ( + errDomainNotFound = errors.New("domain not found") + errMissingDomainRecords = errors.New("missing domain records") +) + +func invalidNNSDomainRecordError(cause error) error { + return fmt.Errorf("invalid domain record: %w", 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 +// errDomainNotFound 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 errDomainNotFound + } + + return fmt.Errorf("get all records of the NNS domain: %w", err) + } + + defer func() { + _ = inv.TerminateSession(sessionID) + }() + + hasRecords := false + + for { + items, err := inv.TraverseIterator(sessionID, &iter, 10) + if err != nil { + return fmt.Errorf("NNS domain records' iterator break: %w", 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( + fmt.Errorf("unexpected type %s instead of %s", stackitem.StructT, items[i].Type())) + } + + if len(fields) < 3 { + return invalidNNSDomainRecordError( + fmt.Errorf("unsupported number of struct fields: expected at least 3, got %d", len(fields))) + } + + _, err = fields[0].TryBytes() + if err != nil { + return invalidNNSDomainRecordError( + fmt.Errorf("1st field is not a byte array: got %v", fields[0].Type())) + } + + typ, err := fields[1].TryInteger() + if err != nil { + return invalidNNSDomainRecordError(fmt.Errorf("2nd field is not an integer: got %v", fields[1].Type())) + } + + if typ.Cmp(nnsrpc.TXT) != 0 { + continue + } + + data, err := fields[2].TryBytes() + if err != nil { + return invalidNNSDomainRecordError( + 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 err + } + } + } +} diff --git a/cmd/neofs-adm/internal/modules/morph/root.go b/cmd/neofs-adm/internal/modules/morph/root.go index 9d092f118c1..c625aca3e3e 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" + domainFlag = "domain" + neoAddressesFlag = "neo-addresses" + publicKeysFlag = "public-keys" ) var ( @@ -257,6 +260,38 @@ Values for unknown keys are added exactly the way they're provided, no conversio }, RunE: listNetmapCandidatesNodes, } + + verifiedNodesDomainCmd = &cobra.Command{ + Use: "verified-nodes-domain", + Short: "Group of commands to work with verified domains for the storage nodes", + Args: cobra.NoArgs, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlag(endpointFlag, cmd.Flags().Lookup(endpointFlag)) + _ = viper.BindPFlag(domainFlag, cmd.Flags().Lookup(domainFlag)) + }, + } + + verifiedNodesDomainAccessListCmd = &cobra.Command{ + Use: "access-list", + Short: "Get access list for the verified nodes' domain", + Long: "List Neo addresses of the storage nodes that have access to use the specified verified domain.", + Args: cobra.NoArgs, + RunE: verifiedNodesDomainAccessList, + } + + verifiedNodesDomainSetAccessListCmd = &cobra.Command{ + Use: "set-access-list", + Short: "Set access list for the verified nodes' domain", + Long: "Set list of the storage nodes that have access to use the specified verified domain. " + + "The list may be either Neo addresses or HEX-encoded public keys of the nodes.", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlag(alphabetWalletsFlag, cmd.Flags().Lookup(alphabetWalletsFlag)) + _ = viper.BindPFlag(publicKeysFlag, cmd.Flags().Lookup(publicKeysFlag)) + _ = viper.BindPFlag(neoAddressesFlag, cmd.Flags().Lookup(neoAddressesFlag)) + }, + RunE: verifiedNodesDomainSetAccessList, + } ) func init() { @@ -365,4 +400,29 @@ func init() { RootCmd.AddCommand(netmapCandidatesCmd) netmapCandidatesCmd.Flags().StringP(endpointFlag, "r", "", "N3 RPC node endpoint") + + cmd := verifiedNodesDomainAccessListCmd + fs := cmd.Flags() + fs.StringP(endpointFlag, "r", "", "NeoFS Sidechain RPC endpoint") + _ = cmd.MarkFlagRequired(endpointFlag) + fs.StringP(domainFlag, "d", "", "Verified domain of the storage nodes. Must be a valid NeoFS NNS domain (e.g. 'nodes.some-org.neofs')") + _ = cmd.MarkFlagRequired(domainFlag) + + verifiedNodesDomainCmd.AddCommand(cmd) + + cmd = verifiedNodesDomainSetAccessListCmd + fs = cmd.Flags() + fs.String(alphabetWalletsFlag, "", "Path to directory containing Alphabet wallets in files 'az.json', 'buky.json', etc.") + _ = cmd.MarkFlagRequired(alphabetWalletsFlag) + fs.StringP(endpointFlag, "r", "", "NeoFS Sidechain RPC endpoint") + _ = cmd.MarkFlagRequired(endpointFlag) + fs.StringP(domainFlag, "d", "", "Verified domain of the storage nodes. Must be a valid NeoFS NNS domain (e.g. 'nodes.some-org.neofs')") + _ = cmd.MarkFlagRequired(domainFlag) + fs.StringSlice(neoAddressesFlag, nil, "Neo addresses resolved from public keys of the storage nodes") + fs.StringSlice(publicKeysFlag, nil, "HEX-encoded public keys of the storage nodes") + cmd.MarkFlagsMutuallyExclusive(publicKeysFlag, neoAddressesFlag) + + verifiedNodesDomainCmd.AddCommand(cmd) + + RootCmd.AddCommand(verifiedNodesDomainCmd) } diff --git a/cmd/neofs-adm/internal/modules/morph/verified_domains.go b/cmd/neofs-adm/internal/modules/morph/verified_domains.go new file mode 100644 index 00000000000..ae5a4806f29 --- /dev/null +++ b/cmd/neofs-adm/internal/modules/morph/verified_domains.go @@ -0,0 +1,214 @@ +package morph + +import ( + "errors" + "fmt" + "strings" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/encoding/address" + "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 verifiedNodesDomainAccessList(cmd *cobra.Command, _ []string) error { + vpr := viper.GetViper() + + n3Client, err := getN3Client(vpr) + if err != nil { + return fmt.Errorf("open connection: %w", err) + } + + nnsContractAddr, err := nnsrpc.InferHash(n3Client) + if err != nil { + return fmt.Errorf("get NeoFS NNS contract address: %w", err) + } + + domain := vpr.GetString(domainFlag) + + err = iterateNNSDomainTextRecords(invoker.New(n3Client, nil), nnsContractAddr, domain, func(rec string) error { + cmd.Println(rec) + return nil + }) + if err != nil { + switch { + default: + return fmt.Errorf("handle domain %q records: %w", domain, err) + case errors.Is(err, errDomainNotFound): + cmd.Println("Domain not found.") + return nil + case errors.Is(err, errMissingDomainRecords): + cmd.Println("List is empty.") + return nil + } + } + + return nil +} + +func verifiedNodesDomainSetAccessList(cmd *cobra.Command, _ []string) error { + vpr := viper.GetViper() + + strNeoAddresses := vpr.GetStringSlice(neoAddressesFlag) + strPublicKeys := vpr.GetStringSlice(publicKeysFlag) + if len(strNeoAddresses)+len(strPublicKeys) == 0 { + // Track https://github.com/nspcc-dev/neofs-node/issues/2595. + return errors.New("neither Neo addresses nor public keys are set") + } + + if len(strNeoAddresses)*len(strPublicKeys) != 0 { + // just to make sure + panic("mutually exclusive flags bypassed Cobra") + } + + var err error + var additionalRecords []string + + if len(strNeoAddresses) > 0 { + for i := range strNeoAddresses { + for j := i + 1; j < len(strNeoAddresses); j++ { + if strNeoAddresses[i] == strNeoAddresses[j] { + return fmt.Errorf("duplicated Neo address %s", strNeoAddresses[i]) + } + } + + _, err = address.StringToUint160(strNeoAddresses[i]) + if err != nil { + return fmt.Errorf("address #%d is invalid: %w", i, err) + } + } + + additionalRecords = strNeoAddresses + } else { + additionalRecords = make([]string, len(strPublicKeys)) + + for i := range strPublicKeys { + for j := i + 1; j < len(strPublicKeys); j++ { + if strPublicKeys[i] == strPublicKeys[j] { + return fmt.Errorf("duplicated public key %s", strPublicKeys[i]) + } + } + + pubKey, err := keys.NewPublicKeyFromString(strPublicKeys[i]) + if err != nil { + return fmt.Errorf("public key #%d is not a HEX-encoded public key: %w", i, err) + } + + additionalRecords[i] = address.Uint160ToString(pubKey.GetScriptHash()) + } + } + + 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) + } + + nnsContractAddr, err := nnsrpc.InferHash(n3Client) + if err != nil { + return fmt.Errorf("get NeoFS NNS contract address: %w", err) + } + + actr, err := actor.NewSimple(n3Client, committeeAcc) + if err != nil { + return fmt.Errorf("init committee actor: %w", err) + } + + domain := vpr.GetString(domainFlag) + scriptBuilder := smartcontract.NewBuilder() + hasOtherRecord := false + mAlreadySetIndices := make(map[int]struct{}, len(additionalRecords)) + + err = iterateNNSDomainTextRecords(actr, nnsContractAddr, domain, func(rec string) error { + for i := range additionalRecords { + if additionalRecords[i] == rec { + mAlreadySetIndices[i] = struct{}{} + return nil + } + } + + hasOtherRecord = true + + return errBreakIterator + }) + if err != nil { + switch { + default: + return fmt.Errorf("handle domain %q records: %w", domain, err) + case errors.Is(err, errMissingDomainRecords): + // domain exists but has no records, that's ok: just going to add new ones + case errors.Is(err, errDomainNotFound): + domainToRegister := domain + if labels := strings.Split(domainToRegister, "."); len(labels) > 2 { + // we need explicitly register L2 domain like 'some-org.neofs' + // and then just add records to inferior domains + domainToRegister = labels[len(labels)-2] + "." + labels[len(labels)-1] + } + + scriptBuilder.InvokeMethod(nnsContractAddr, "register", + domainToRegister, committeeAcc.ScriptHash(), "ops@nspcc.ru", int64(3600), int64(600), int64(defaultExpirationTime), int64(3600)) + } + } + + if !hasOtherRecord && len(mAlreadySetIndices) == len(additionalRecords) { + cmd.Println("Current list is already the same, skip.") + return nil + } + + if hasOtherRecord { + // there is no way to delete particular record, so clean all first + scriptBuilder.InvokeMethod(nnsContractAddr, "deleteRecords", + domain, nnsrpc.TXT.Int64()) + } + + for i := range additionalRecords { + if !hasOtherRecord { + if _, ok := mAlreadySetIndices[i]; ok { + continue + } + } + + scriptBuilder.InvokeMethod(nnsContractAddr, "addRecord", + domain, nnsrpc.TXT.Int64(), additionalRecords[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) + } + } + + err = awaitTx(cmd, n3Client, []hashVUBPair{{ + hash: txID, + vub: vub, + }}) + if err != nil { + return fmt.Errorf("wait for transaction to be persisted: %w", err) + } + + cmd.Println("Access list has been successfully updated.") + + return nil +} diff --git a/cmd/neofs-cli/modules/storagegroup/put.go b/cmd/neofs-cli/modules/storagegroup/put.go index 161052a1ba7..836daef6461 100644 --- a/cmd/neofs-cli/modules/storagegroup/put.go +++ b/cmd/neofs-cli/modules/storagegroup/put.go @@ -49,8 +49,7 @@ func initSGPutCmd() { } func putSG(cmd *cobra.Command, _ []string) { - // with 1.8.0 cobra release we can use this instead of below - // sgPutCmd.MarkFlagsOneRequired("expire-at", "lifetime") + // Track https://github.com/nspcc-dev/neofs-node/issues/2595. exp, _ := cmd.Flags().GetUint64(commonflags.ExpireAt) lifetime, _ := cmd.Flags().GetUint64(commonflags.Lifetime) if exp == 0 && lifetime == 0 { // mutual exclusion is ensured by cobra diff --git a/docs/verified-node-domains.md b/docs/verified-node-domains.md index be705433327..01d69194225 100644 --- a/docs/verified-node-domains.md +++ b/docs/verified-node-domains.md @@ -30,6 +30,60 @@ For each public key, a record is created - a structure with at least 3 fields: 2. `Integer` that is `16` for TXT records (other record types are allowed but left unprocessed) 3. `ByteString` with Neo address of the storage node's public key +### Management + +NeoFS ADM tool may be used to work with verified nodes' domains from command line. +``` +$ neofs-adm morph verified-nodes-domain +``` + +#### Get access list + +List allowed storage nodes: +``` +$ neofs-adm morph verified-nodes-domain access-list -r https://rpc1.morph.t5.fs.neo.org:51331 \ +-d nodes.some-org.neofs +NZ1czz5gkEDamTg6Tiw6cxqp9Me1KLs8ae +NfMvD6WmBiCr4erfEnFFLs7jdj4Y5CM7nN +``` +where `-r` is the NeoFS Sidechain network endpoint. + +See command help for details +``` +$ neofs-adm morph verified-nodes-domain access-list -h +``` + +#### Set access list + +Set list of Neo addresses of the allowed storage nodes: +``` +$ neofs-adm morph verified-nodes-domain set-access-list -r https://rpc1.morph.t5.fs.neo.org:51331 \ +-d nodes.some-org.neofs --alphabet-wallets ./ \ +--neo-addresses NZ1czz5gkEDamTg6Tiw6cxqp9Me1KLs8ae \ +--neo-addresses NfMvD6WmBiCr4erfEnFFLs7jdj4Y5CM7nN +$ Password for az wallet > +$ Waiting for transactions to persist... +$ Access list has been successfully updated. +``` +where `--alphabet-wallets` should lead to directory with NeoFS Alphabet wallet +files `az.json`, `buky.json`, etc. + +Auxiliary flag `--public-keys` allows you to specify public keys instead of addresses: +``` +$ neofs-adm morph verified-nodes-domain set-access-list -r https://rpc1.morph.t5.fs.neo.org:51331 \ +-d nodes.some-org.neofs --alphabet-wallets ./ \ +--public-keys 02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2 \ +--public-keys 02103a7f7dd016558597f7960d27c516a4394fd968b9e65155eb4b013e4040406e +$ Password for az wallet > +$ Waiting for transactions to persist... +$ Access list has been successfully updated. +``` + +See command help for details: +``` +$ neofs-adm morph verified-nodes-domain set-access-list -h +``` + ## Private subnet entrance By default, storage nodes do not belong to private groups. Any node wishing to