From 6ab5c7c3cc5af1890f67a58261caee01566eb13b Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Thu, 28 Sep 2023 14:05:35 +0400 Subject: [PATCH] ir: Support verified NNS domains of the storage nodes From now, the Inner Ring checks any incoming node for permission to associate itself with optional private node group (kind of subnet). Access lists are stored in the NeoFS NNS. Closes #2280. Signed-off-by: Leonard Lyubich --- CHANGELOG.md | 3 + cmd/neofs-node/config/node/config_test.go | 3 +- config/example/node.env | 1 + config/example/node.json | 1 + config/example/node.yaml | 4 + docs/storage-node-configuration.md | 2 +- docs/verified-node-domains.md | 40 +++ go.mod | 2 +- go.sum | 4 +- pkg/innerring/innerring.go | 9 + pkg/innerring/nns.go | 190 +++++++++++ pkg/innerring/nns_test.go | 310 ++++++++++++++++++ .../privatedomains/validator.go | 90 +++++ .../privatedomains/validator_test.go | 150 +++++++++ pkg/morph/client/client.go | 70 +++- 15 files changed, 862 insertions(+), 17 deletions(-) create mode 100644 docs/verified-node-domains.md create mode 100644 pkg/innerring/nns.go create mode 100644 pkg/innerring/nns_test.go create mode 100644 pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator.go create mode 100644 pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 758c061e2e8..579ff75f0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Changelog for NeoFS Node ## [Unreleased] +### Added +- Support of verified domains for the storage nodes (#2280) + ### Fixed - `neofs-cli netmap netinfo` documentation (#2555) - `GETRANGEHASH` to a node without an object produced `GETRANGE` or `GET` requests (#2541) diff --git a/cmd/neofs-node/config/node/config_test.go b/cmd/neofs-node/config/node/config_test.go index d12d7037744..f3b1dc89fc9 100644 --- a/cmd/neofs-node/config/node/config_test.go +++ b/cmd/neofs-node/config/node/config_test.go @@ -111,9 +111,10 @@ func TestNodeSection(t *testing.T) { require.Equal(t, true, relay) - require.Len(t, attributes, 2) + require.Len(t, attributes, 3) require.Equal(t, "Price:11", attributes[0]) require.Equal(t, "UN-LOCODE:RU MSK", attributes[1]) + require.Equal(t, "VerifiedNodesDomain:nodes.some-org.neofs", attributes[2]) require.NotNil(t, wKey) require.Equal(t, diff --git a/config/example/node.env b/config/example/node.env index 5449b6567ad..04cd62b0869 100644 --- a/config/example/node.env +++ b/config/example/node.env @@ -16,6 +16,7 @@ NEOFS_NODE_WALLET_PASSWORD=password NEOFS_NODE_ADDRESSES="s01.neofs.devenv:8080 /dns4/s02.neofs.devenv/tcp/8081 grpc://127.0.0.1:8082 grpcs://localhost:8083" NEOFS_NODE_ATTRIBUTE_0=Price:11 NEOFS_NODE_ATTRIBUTE_1="UN-LOCODE:RU MSK" +NEOFS_NODE_ATTRIBUTE_2="VerifiedNodesDomain:nodes.some-org.neofs" NEOFS_NODE_RELAY=true NEOFS_NODE_PERSISTENT_SESSIONS_PATH=/sessions NEOFS_NODE_PERSISTENT_STATE_PATH=/state diff --git a/config/example/node.json b/config/example/node.json index fa11730f57a..d3df08df814 100644 --- a/config/example/node.json +++ b/config/example/node.json @@ -27,6 +27,7 @@ ], "attribute_0": "Price:11", "attribute_1": "UN-LOCODE:RU MSK", + "attribute_2": "VerifiedNodesDomain:nodes.some-org.neofs", "relay": true, "persistent_sessions": { "path": "/sessions" diff --git a/config/example/node.yaml b/config/example/node.yaml index fde52a969f3..c30e751f0f1 100644 --- a/config/example/node.yaml +++ b/config/example/node.yaml @@ -22,8 +22,12 @@ node: - /dns4/s02.neofs.devenv/tcp/8081 - grpc://127.0.0.1:8082 - grpcs://localhost:8083 + # List of colon-separated key-value attributes. attribute_0: "Price:11" attribute_1: UN-LOCODE:RU MSK + # Next attribute specifies optional NeoFS NNS domain in order to enter the storage node into a private node group + # (kind of subnet). The node must have public key from the corresponding access list. See docs for more detailed information. + attribute_2: VerifiedNodesDomain:nodes.some-org.neofs relay: true # start Storage node in relay mode without bootstrapping into the Network map persistent_sessions: path: /sessions # path to persistent session tokens file of Storage node (default: in-memory sessions) diff --git a/docs/storage-node-configuration.md b/docs/storage-node-configuration.md index 6eccf545699..63baa50adf2 100644 --- a/docs/storage-node-configuration.md +++ b/docs/storage-node-configuration.md @@ -311,7 +311,7 @@ node: | `key` | `string` | | Path to the binary-encoded private key. | | `wallet` | [Wallet config](#wallet-subsection) | | Wallet configuration. Has no effect if `key` is provided. | | `addresses` | `[]string` | | Addresses advertised in the netmap. | -| `attribute` | `[]string` | | Node attributes as a list of key-value pairs in `:` format. | +| `attribute` | `[]string` | | Node attributes as a list of key-value pairs in `:` format. See also docs about verified nodes' domains.| | `relay` | `bool` | | Enable relay mode. | | `persistent_sessions` | [Persistent sessions config](#persistent_sessions-subsection) | | Persistent session token store configuration. | | `persistent_state` | [Persistent state config](#persistent_state-subsection) | | Persistent state configuration. | diff --git a/docs/verified-node-domains.md b/docs/verified-node-domains.md new file mode 100644 index 00000000000..be705433327 --- /dev/null +++ b/docs/verified-node-domains.md @@ -0,0 +1,40 @@ +# Verified domains of the NeoFS storage nodes + +Storage nodes declare information flexibly via key-value string attributes when +applying to enter the NeoFS network map. In general, any attributes can be +declared, however, some of them may be subject to restrictions. In particular, +some parties may need to limit the relationship to them of any nodes of their +public network. For example, an organization may need to deploy its storage +nodes as a subnet of a public network to implement specific data storage +strategies. In this example, the organization’s nodes will be “normal” for 3rd +parties, while other nodes will not be able to enter the subnet without special +permission at the system level. + +NeoFS implements solution of the described task through access lists managed +within NeoFS NNS. + +## Access lists + +These lists are stored in the NeoFS NNS. Each party may register any available +NNS domain and set records of `TXT` type with Neo addresses of the storage +nodes. After the domain is registered, it becomes an alias to the subnet composed +only from specified storage nodes. Any storage node trying to associate itself +with this subnet while trying to enter the network must have public key +presented in the access list. The Inner Ring will deny everyone else access to +the network map. + +### Domain record format + +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 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 + +## Private subnet entrance + +By default, storage nodes do not belong to private groups. Any node wishing to +enter the private subnet of storage nodes must first find out the corresponding +domain name. To request a binding to a given subnet, a node needs to set +related domain name in its information about when registering in the network +map. The domain is set via `VerifiedNodesDomain` attribute. To be admitted to +the network, a node must be present in the access list. diff --git a/go.mod b/go.mod index afdcf6e6165..dbaff42e72c 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/nspcc-dev/neo-go v0.102.0 github.com/nspcc-dev/neofs-api-go/v2 v2.14.0 github.com/nspcc-dev/neofs-contract v0.18.0 - github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11 + github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20230926161529-a5cb78a74aed github.com/nspcc-dev/tzhash v1.7.0 github.com/olekukonko/tablewriter v0.0.5 github.com/panjf2000/ants/v2 v2.4.0 diff --git a/go.sum b/go.sum index 916d3417163..d3863bae7dc 100644 --- a/go.sum +++ b/go.sum @@ -300,8 +300,8 @@ github.com/nspcc-dev/neofs-contract v0.18.0 h1:9g50b16s0mQFFskG93yRSWh4KL7yYOW+x github.com/nspcc-dev/neofs-contract v0.18.0/go.mod h1:UQr1rUjg0eibLwJd6vfsJJEUBnmRysCg8XQd1HYiS2w= github.com/nspcc-dev/neofs-crypto v0.4.0 h1:5LlrUAM5O0k1+sH/sktBtrgfWtq1pgpDs09fZo+KYi4= github.com/nspcc-dev/neofs-crypto v0.4.0/go.mod h1:6XJ8kbXgOfevbI2WMruOtI+qUJXNwSGM/E9eClXxPHs= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11 h1:QOc8ZRN5DXlAeRPh5QG9u8rMLgoeRNiZF5/vL7QupWg= -github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11/go.mod h1:W+ImTNRnSNMH8w43H1knCcIqwu7dLHePXtlJNZ7EFIs= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20230926161529-a5cb78a74aed h1:ySOlpzLNU3djblNtjZFiTrfoE9zZ5fd1bwjxbbI3gvM= +github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.11.0.20230926161529-a5cb78a74aed/go.mod h1:W+ImTNRnSNMH8w43H1knCcIqwu7dLHePXtlJNZ7EFIs= github.com/nspcc-dev/rfc6979 v0.2.0 h1:3e1WNxrN60/6N0DW7+UYisLeZJyfqZTNOjeV/toYvOE= github.com/nspcc-dev/rfc6979 v0.2.0/go.mod h1:exhIh1PdpDC5vQmyEsGvc4YDM/lyQp/452QxGq/UEso= github.com/nspcc-dev/tzhash v1.7.0 h1:/+aL33NC7y5OIGnY2kYgjZt8mg7LVGFMdj/KAJLndnk= diff --git a/pkg/innerring/innerring.go b/pkg/innerring/innerring.go index 1bd2d8b0b33..caa3341fa49 100644 --- a/pkg/innerring/innerring.go +++ b/pkg/innerring/innerring.go @@ -25,6 +25,7 @@ import ( "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap" nodevalidator "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation" availabilityvalidator "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/availability" + "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/privatedomains" statevalidation "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/state" addrvalidator "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/structure" "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/reputation" @@ -724,6 +725,13 @@ func New(ctx context.Context, log *zap.Logger, cfg *viper.Viper, errChan chan<- var netMapCandidateStateValidator statevalidation.NetMapCandidateValidator netMapCandidateStateValidator.SetNetworkSettings(netSettings) + nnsContractAddr, err := server.morphClient.NNSHash() + if err != nil { + return nil, fmt.Errorf("get NeoFS NNS contract address: %w", err) + } + + nnsService := newNeoFSNNS(nnsContractAddr, server.morphClient) + // create netmap processor server.netmapProcessor, err = netmap.New(&netmap.Params{ Log: log, @@ -749,6 +757,7 @@ func New(ctx context.Context, log *zap.Logger, cfg *viper.Viper, errChan chan<- &netMapCandidateStateValidator, addrvalidator.New(), availabilityvalidator.New(), + privatedomains.New(nnsService), locodeValidator, ), NodeStateSettings: netSettings, diff --git a/pkg/innerring/nns.go b/pkg/innerring/nns.go new file mode 100644 index 00000000000..1cd205c9346 --- /dev/null +++ b/pkg/innerring/nns.go @@ -0,0 +1,190 @@ +package innerring + +import ( + "errors" + "fmt" + "strings" + + "github.com/nspcc-dev/neo-go/pkg/rpcclient/nep11" + "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" + "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/privatedomains" +) + +// provides services of the NeoFS Name Service consumed by the Inner Ring node. +type neoFSNNS struct { + invoker nep11.Invoker + contract *nnsrpc.ContractReader +} + +// creates NeoFS Name Service provider working with the Neo smart contract +// deployed in the Neo network accessed through the specified [nep11.Invoker]. +func newNeoFSNNS(contractAddress util.Uint160, contractCaller nep11.Invoker) *neoFSNNS { + return &neoFSNNS{ + invoker: contractCaller, + contract: nnsrpc.NewReader(contractCaller, contractAddress), + } +} + +var errDomainNotFound = errors.New("domain not found") + +type errWrongStackItemType struct { + expected, got stackitem.Type +} + +func wrongStackItemTypeError(expected, got stackitem.Type) errWrongStackItemType { + return errWrongStackItemType{ + expected: expected, + got: got, + } +} + +func (x errWrongStackItemType) Error() string { + return fmt.Sprintf("wrong type: expected %s, got %s", x.expected, x.got) +} + +type errInvalidNumberOfStructFields struct { + expected, got int +} + +func invalidNumberOfStructFieldsError(expected, got int) errInvalidNumberOfStructFields { + return errInvalidNumberOfStructFields{ + expected: expected, + got: got, + } +} + +func (x errInvalidNumberOfStructFields) Error() string { + return fmt.Sprintf("invalid number of struct fields: expected %d, got %d", x.expected, x.got) +} + +type errInvalidStructField struct { + index uint + cause error +} + +func invalidStructFieldError(index uint, cause error) errInvalidStructField { + return errInvalidStructField{ + index: index, + cause: cause, + } +} + +func (x errInvalidStructField) Error() string { + return fmt.Sprintf("invalid struct field #%d: %v", x.index, x.cause) +} + +func (x errInvalidStructField) Unwrap() error { + return x.cause +} + +type errInvalidNNSDomainRecord struct { + domain string + record string + cause error +} + +func invalidNNSDomainRecordError(domain, record string, cause error) errInvalidNNSDomainRecord { + return errInvalidNNSDomainRecord{ + domain: domain, + record: record, + cause: cause, + } +} + +func (x errInvalidNNSDomainRecord) Error() string { + if x.record != "" { + return fmt.Sprintf("invalid record %q of the NNS domain %q: %v", x.record, x.domain, x.cause) + } + + return fmt.Sprintf("invalid record of the NNS domain %q: %v", x.domain, x.cause) +} + +func (x errInvalidNNSDomainRecord) Unwrap() error { + return x.cause +} + +// CheckDomainRecord calls iterating 'getAllRecords' method of the parameterized +// Neo smart contract passing the given domain name. If contract throws 'token +// not found' exception, CheckDomainRecord returns errDomainNotFound. Each value +// in the resulting iterator is expected to be structure with at least 3 fields. +// If any value has the 2nd field is a number equal to 16 (TXT record type in +// the NNS) and the 3rd one is a string equal to the specified record, +// CheckDomainRecord returns nil. Otherwise, +// [privatedomains.ErrMissingDomainRecord] is returned. +func (x *neoFSNNS) CheckDomainRecord(domain string, record string) error { + sessionID, iter, err := x.contract.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 iterator over all records of the NNS domain %q: %w", domain, err) + } + + defer func() { + _ = x.invoker.TerminateSession(sessionID) + }() + + hasRecords := false + + for { + items, err := x.invoker.TraverseIterator(sessionID, &iter, 10) + if err != nil { + return fmt.Errorf("traverse iterator over all records of the NNS domain %q: %w", domain, err) + } + + if len(items) == 0 { + break + } + + hasRecords = true + + for i := range items { + fields, ok := items[i].Value().([]stackitem.Item) + if !ok { + return invalidNNSDomainRecordError(domain, "", + wrongStackItemTypeError(stackitem.StructT, items[i].Type())) + } + + if len(fields) < 3 { + return invalidNNSDomainRecordError(domain, "", + invalidNumberOfStructFieldsError(3, len(fields))) + } + + _, err = fields[0].TryBytes() + if err != nil { + return invalidNNSDomainRecordError(domain, "", + invalidStructFieldError(0, wrongStackItemTypeError(stackitem.ByteArrayT, fields[0].Type()))) + } + + typ, err := fields[1].TryInteger() + if err != nil { + return invalidNNSDomainRecordError(domain, "", + invalidStructFieldError(1, wrongStackItemTypeError(stackitem.IntegerT, fields[1].Type()))) + } + + if typ.Cmp(nnsrpc.TXT) != 0 { + continue + } + + data, err := fields[2].TryBytes() + if err != nil { + return invalidNNSDomainRecordError(domain, "", + invalidStructFieldError(2, wrongStackItemTypeError(stackitem.ByteArrayT, fields[2].Type()))) + } + + if string(data) == record { + return nil + } + } + } + + if hasRecords { + return privatedomains.ErrMissingDomainRecord + } + + return nil +} diff --git a/pkg/innerring/nns_test.go b/pkg/innerring/nns_test.go new file mode 100644 index 00000000000..e664c84a204 --- /dev/null +++ b/pkg/innerring/nns_test.go @@ -0,0 +1,310 @@ +package innerring + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" + "github.com/nspcc-dev/neo-go/pkg/vm/vmstate" + nnsrpc "github.com/nspcc-dev/neofs-contract/rpc/nns" + "github.com/nspcc-dev/neofs-node/pkg/innerring/processors/netmap/nodevalidation/privatedomains" + "github.com/stretchr/testify/require" +) + +type testInvoker struct { + tb testing.TB + contractAddr util.Uint160 + + domain string + + callRes *result.Invoke + callErr error + + traverseErr error + + items []stackitem.Item + readItems int +} + +func newTestInvoker(tb testing.TB, contractAddr util.Uint160, domain string) *testInvoker { + return &testInvoker{ + tb: tb, + contractAddr: contractAddr, + domain: domain, + } +} + +func (x *testInvoker) Call(contract util.Uint160, method string, args ...any) (*result.Invoke, error) { + require.Equal(x.tb, x.contractAddr, contract) + require.Equal(x.tb, "getAllRecords", method) + require.Len(x.tb, args, 1) + require.Equal(x.tb, x.domain, args[0]) + + if x.callErr != nil { + return nil, x.callErr + } + + return x.callRes, nil +} + +func (x *testInvoker) CallAndExpandIterator(contract util.Uint160, method string, maxItems int, args ...any) (*result.Invoke, error) { + panic("not expected to be called") +} + +func (x *testInvoker) TerminateSession(sessionID uuid.UUID) error { + require.Equal(x.tb, x.callRes.Session, sessionID) + return nil +} + +func (x *testInvoker) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) { + require.Equal(x.tb, x.callRes.Session, sessionID) + + if x.traverseErr != nil { + return nil, x.traverseErr + } + + if num > len(x.items)-x.readItems { + num = len(x.items) - x.readItems + } + + defer func() { + x.readItems += num + }() + + return x.items[x.readItems : x.readItems+num], nil +} + +func TestNeoFSNNS_CheckDomainRecord(t *testing.T) { + var contractAddr util.Uint160 + rand.Read(contractAddr[:]) + const domain = "l2.l1.tld" + searchedRecord := "abcdef" + + t.Run("call failure", func(t *testing.T) { + inv := newTestInvoker(t, contractAddr, domain) + inv.callErr = errors.New("any error") + + nnsService := newNeoFSNNS(contractAddr, inv) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.ErrorIs(t, err, inv.callErr) + }) + + t.Run("fault exception", func(t *testing.T) { + inv := newTestInvoker(t, contractAddr, domain) + inv.callRes = &result.Invoke{ + State: vmstate.Fault.String(), + FaultException: "any fault exception", + } + + nnsService := newNeoFSNNS(contractAddr, inv) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), inv.callRes.FaultException)) + + inv.callRes.FaultException = "token not found" + + err = nnsService.CheckDomainRecord(domain, searchedRecord) + require.ErrorIs(t, err, errDomainNotFound) + }) + + t.Run("invalid stack", func(t *testing.T) { + inv := newTestInvoker(t, contractAddr, domain) + inv.callRes = &result.Invoke{ + State: vmstate.Halt.String(), + Stack: make([]stackitem.Item, 0), + } + + nnsService := newNeoFSNNS(contractAddr, inv) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.Error(t, err) + + inv.callRes.Stack = make([]stackitem.Item, 2) + + err = nnsService.CheckDomainRecord(domain, searchedRecord) + require.Error(t, err) + + nonIteratorItem := stackitem.NewBool(true) + inv.callRes.Stack = []stackitem.Item{nonIteratorItem} + + err = nnsService.CheckDomainRecord(domain, searchedRecord) + require.Error(t, err) + }) + + newWithRecords := func(items ...stackitem.Item) *neoFSNNS { + var iter result.Iterator + inv := newTestInvoker(t, contractAddr, domain) + inv.callRes = &result.Invoke{ + State: vmstate.Halt.String(), + Stack: []stackitem.Item{stackitem.NewInterop(iter)}, + } + inv.items = items + + return newNeoFSNNS(contractAddr, inv) + } + + t.Run("no domain records", func(t *testing.T) { + nnsService := newWithRecords() + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.NoError(t, err) + }) + + checkInvalidRecordError := func(record string, err error) { + var e errInvalidNNSDomainRecord + require.ErrorAs(t, err, &e) + require.Equal(t, domain, e.domain) + require.Equal(t, record, e.record) + } + + checkInvalidStructFieldError := func(index uint, err error) { + var e errInvalidStructField + require.ErrorAs(t, err, &e) + require.Equal(t, index, e.index) + } + + t.Run("with invalid record", func(t *testing.T) { + t.Run("non-struct element", func(t *testing.T) { + nnsService := newWithRecords(stackitem.NewBool(true)) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + + checkInvalidRecordError("", err) + + var wrongTypeErr errWrongStackItemType + require.ErrorAs(t, err, &wrongTypeErr) + require.Equal(t, stackitem.StructT, wrongTypeErr.expected) + require.Equal(t, stackitem.BooleanT, wrongTypeErr.got) + }) + + t.Run("invalid number of fields", func(t *testing.T) { + for i := 0; i < 3; i++ { + nnsService := newWithRecords(stackitem.NewStruct(make([]stackitem.Item, i))) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + + checkInvalidRecordError("", err) + + var invalidFieldNumErr errInvalidNumberOfStructFields + require.ErrorAs(t, err, &invalidFieldNumErr) + require.Equal(t, 3, invalidFieldNumErr.expected, i) + require.Equal(t, i, invalidFieldNumErr.got, i) + } + }) + + t.Run("invalid 1st field", func(t *testing.T) { + nnsService := newWithRecords(stackitem.NewStruct([]stackitem.Item{ + stackitem.NewMap(), + stackitem.NewBigInteger(nnsrpc.TXT), + stackitem.NewByteArray([]byte("any")), + })) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + + checkInvalidRecordError("", err) + checkInvalidStructFieldError(0, err) + + var wrongTypeErr errWrongStackItemType + require.ErrorAs(t, err, &wrongTypeErr) + require.Equal(t, stackitem.ByteArrayT, wrongTypeErr.expected) + require.Equal(t, stackitem.MapT, wrongTypeErr.got) + }) + + t.Run("invalid 2nd field", func(t *testing.T) { + t.Run("non-integer", func(t *testing.T) { + nnsService := newWithRecords(stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewMap(), + stackitem.NewByteArray([]byte("any")), + })) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + + checkInvalidRecordError("", err) + checkInvalidStructFieldError(1, err) + + var wrongTypeErr errWrongStackItemType + require.ErrorAs(t, err, &wrongTypeErr) + require.Equal(t, stackitem.IntegerT, wrongTypeErr.expected) + require.Equal(t, stackitem.MapT, wrongTypeErr.got) + }) + + t.Run("non-TXT record", func(t *testing.T) { + nnsService := newWithRecords(stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewBigInteger(nnsrpc.CNAME), + stackitem.NewByteArray([]byte("any")), + })) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.ErrorIs(t, err, privatedomains.ErrMissingDomainRecord) + }) + }) + + t.Run("invalid 3rd field", func(t *testing.T) { + t.Run("invalid type", func(t *testing.T) { + nnsService := newWithRecords(stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewBigInteger(nnsrpc.TXT), + stackitem.NewMap(), + })) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + + checkInvalidRecordError("", err) + checkInvalidStructFieldError(2, err) + + var wrongTypeErr errWrongStackItemType + require.ErrorAs(t, err, &wrongTypeErr) + require.Equal(t, stackitem.ByteArrayT, wrongTypeErr.expected) + require.Equal(t, stackitem.MapT, wrongTypeErr.got) + }) + }) + }) + + t.Run("without searched record", func(t *testing.T) { + records := make([]stackitem.Item, 100) + for i := range records { + records[i] = stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewBigInteger(nnsrpc.TXT), + stackitem.NewByteArray([]byte(searchedRecord + hex.EncodeToString([]byte{byte(i)}))), + }) + } + + nnsService := newWithRecords(records...) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.ErrorIs(t, err, privatedomains.ErrMissingDomainRecord) + }) + + t.Run("with searched record", func(t *testing.T) { + records := make([]stackitem.Item, 100) + for i := range records { + records[i] = stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewBigInteger(nnsrpc.TXT), + stackitem.NewByteArray([]byte(searchedRecord + hex.EncodeToString([]byte{byte(i)}))), + }) + } + + records = append(records, stackitem.NewStruct([]stackitem.Item{ + stackitem.NewByteArray([]byte("any")), + stackitem.NewBigInteger(nnsrpc.TXT), + stackitem.NewByteArray([]byte(searchedRecord)), + })) + + nnsService := newWithRecords(records...) + + err := nnsService.CheckDomainRecord(domain, searchedRecord) + require.NoError(t, err) + }) +} diff --git a/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator.go b/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator.go new file mode 100644 index 00000000000..c8ab674f325 --- /dev/null +++ b/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator.go @@ -0,0 +1,90 @@ +package privatedomains + +import ( + "errors" + "fmt" + + "github.com/nspcc-dev/neo-go/pkg/crypto/hash" + "github.com/nspcc-dev/neo-go/pkg/encoding/address" + "github.com/nspcc-dev/neo-go/pkg/io" + "github.com/nspcc-dev/neo-go/pkg/vm/emit" + "github.com/nspcc-dev/neofs-sdk-go/netmap" +) + +// ErrMissingDomainRecord is returned when some record is missing in the +// particular domain. +var ErrMissingDomainRecord = errors.New("missing domain record") + +// NNS provides services of the NeoFS NNS consumed by [Validator] to process. +type NNS interface { + // CheckDomainRecord checks whether NNS domain with the specified name exists + // and has given TXT record. Returns [ErrMissingDomainRecord] if domain exists + // but has no given record, or any other error encountered prevented the check. + // + // Both domain name and record are non-empty. + CheckDomainRecord(domainName string, record string) error +} + +// Validator validates NNS domains declared by the storage nodes on their +// attempts to enter the NeoFS network map. +// +// There is an option to specify name of the verified nodes' domain. Such +// domains allow to combine several nodes into a private group (kind of subnet). +// Access is controlled using access lists: Validator checks that any incoming +// node declaring private node domain is presented in the corresponding access +// list. Access lists are stored in the NeoFS NNS: for each private node group, +// there is a registered NNS domain. TXT records of each such domain are Neo +// addresses of the nodes' public keys. To be allowed to use a specific verified +// domain value, the storage node must have a Neo address from this list. +// Otherwise, the storage node will be denied access to the network map. Note +// that if domain exists but has no records, then access is forbidden for +// anyone. +type Validator struct { + nns NNS +} + +// New returns new Validator that uses provided [NNS] as a source of node access +// records. +func New(nns NNS) *Validator { + return &Validator{ + nns: nns, + } +} + +// various errors useful for testing. +var ( + errMissingNodeBinaryKey = errors.New("missing node binary key") + errAccessDenied = errors.New("access denied") +) + +// VerifyAndUpdate checks allowance of the storage node represented by the given +// descriptor to enter the private node group (if any). Returns an error if on +// access denial or the check cannot be done at the moment. +// +// VerifyAndUpdate does not mutate the argument. +func (x *Validator) VerifyAndUpdate(info *netmap.NodeInfo) error { + verifiedNodesDomain := info.VerifiedNodesDomain() + if verifiedNodesDomain == "" { + return nil + } + + bNodeKey := info.PublicKey() + if len(bNodeKey) == 0 { + return errMissingNodeBinaryKey + } + + buf := io.NewBufBinWriter() + emit.CheckSig(buf.BinWriter, bNodeKey) + nodeNeoAddress := address.Uint160ToString(hash.Hash160(buf.Bytes())) + + err := x.nns.CheckDomainRecord(verifiedNodesDomain, nodeNeoAddress) + if err != nil { + if errors.Is(err, ErrMissingDomainRecord) { + return errAccessDenied + } + + return fmt.Errorf("check records of the domain %q: %w", verifiedNodesDomain, err) + } + + return nil +} diff --git a/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator_test.go b/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator_test.go new file mode 100644 index 00000000000..74d7d5bcb89 --- /dev/null +++ b/pkg/innerring/processors/netmap/nodevalidation/privatedomains/validator_test.go @@ -0,0 +1,150 @@ +package privatedomains + +import ( + "encoding/hex" + "errors" + "testing" + + "github.com/nspcc-dev/neofs-sdk-go/netmap" + "github.com/stretchr/testify/require" +) + +type testNNS struct { + mDomains map[string][]string + + staticErr error +} + +// creates test NNS provider without any domains. +func newTestNNS() *testNNS { + return &testNNS{ + mDomains: make(map[string][]string), + } +} + +// creates test NNS provider that always fails with the given error. +func newTestNNSWithStaticErr(err error) *testNNS { + return &testNNS{ + staticErr: err, + } +} + +func (x *testNNS) registerDomain(domain string) { + x.mDomains[domain] = nil +} + +func (x *testNNS) addDomainRecord(domain string, record string) { + x.mDomains[domain] = append(x.mDomains[domain], record) +} + +func (x *testNNS) removeDomainRecord(domain string, record string) { + recs, ok := x.mDomains[domain] + if !ok { + return + } + + for i := 0; i < len(recs); i++ { // do not use range, slice is mutated inside + if recs[i] == record { + recs = append(recs[:i], recs[i+1:]...) + i-- + } + } + + x.mDomains[domain] = recs +} + +func (x *testNNS) CheckDomainRecord(domainName string, record string) error { + if x.staticErr != nil { + return x.staticErr + } + + recs, ok := x.mDomains[domainName] + if !ok { + return errors.New("missing domain") + } + + for i := range recs { + if recs[i] == record { + return nil + } + } + + return ErrMissingDomainRecord +} + +func TestValidator_VerifyAndUpdate(t *testing.T) { + const verifiedDomain = "nodes.some-org.neofs" + const hNodeKey = "02a70577a832b338772c8cd07e7eaf526cae8d9b025a51b41671de5a4363eafe07" + const nodeNeoAddress = "NZ1czz5gkEDamTg6Tiw6cxqp9Me1KLs8ae" + const anyOtherNeoAddress = "NfMvD6WmBiCr4erfEnFFLs7jdj4Y5CM7nN" + + bNodeKey, err := hex.DecodeString(hNodeKey) + require.NoError(t, err) + + var node netmap.NodeInfo + node.SetPublicKey(bNodeKey) + node.SetVerifiedNodesDomain(verifiedDomain) + + t.Run("unspecified verified nodes domain", func(t *testing.T) { + var node netmap.NodeInfo + require.Zero(t, node.VerifiedNodesDomain()) + + v := New(newTestNNS()) + + err := v.VerifyAndUpdate(&node) + require.NoError(t, err) + }) + + t.Run("invalid node key", func(t *testing.T) { + var node netmap.NodeInfo + node.SetVerifiedNodesDomain(verifiedDomain) + + v := New(newTestNNS()) + + node.SetPublicKey(nil) + err := v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, errMissingNodeBinaryKey) + + node.SetPublicKey([]byte{}) + err = v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, errMissingNodeBinaryKey) + }) + + t.Run("other failure", func(t *testing.T) { + anyErr := errors.New("any error") + v := New(newTestNNSWithStaticErr(anyErr)) + + err := v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, anyErr) + }) + + t.Run("missing domain", func(t *testing.T) { + v := New(newTestNNS()) + + err := v.VerifyAndUpdate(&node) + require.Error(t, err) + require.NotErrorIs(t, err, errAccessDenied) + }) + + t.Run("existing domain", func(t *testing.T) { + nns := newTestNNS() + v := New(nns) + + nns.registerDomain(verifiedDomain) + + err := v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, errAccessDenied) + + nns.addDomainRecord(verifiedDomain, anyOtherNeoAddress) + err = v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, errAccessDenied) + + nns.addDomainRecord(verifiedDomain, nodeNeoAddress) + err = v.VerifyAndUpdate(&node) + require.NoError(t, err) + + nns.removeDomainRecord(verifiedDomain, nodeNeoAddress) + err = v.VerifyAndUpdate(&node) + require.ErrorIs(t, err, errAccessDenied) + }) +} diff --git a/pkg/morph/client/client.go b/pkg/morph/client/client.go index 1c959322913..268d971d31b 100644 --- a/pkg/morph/client/client.go +++ b/pkg/morph/client/client.go @@ -8,12 +8,14 @@ import ( "sync" "time" + "github.com/google/uuid" lru "github.com/hashicorp/golang-lru/v2" "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/core/native/noderoles" "github.com/nspcc-dev/neo-go/pkg/core/transaction" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neo-go/pkg/encoding/fixedn" + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" "github.com/nspcc-dev/neo-go/pkg/rpcclient" "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" "github.com/nspcc-dev/neo-go/pkg/rpcclient/gas" @@ -79,6 +81,57 @@ type Client struct { inactive bool } +// Call calls specified method of the Neo smart contract with provided arguments. +func (c *Client) Call(contract util.Uint160, method string, args ...any) (*result.Invoke, error) { + c.switchLock.RLock() + defer c.switchLock.RUnlock() + + if c.inactive { + return nil, ErrConnectionLost + } + + return c.rpcActor.Call(contract, method, args...) +} + +// CallAndExpandIterator calls specified iterating method of the Neo smart +// contract with provided arguments, and fetches iterator from the response +// carrying up to limited number of items. +func (c *Client) CallAndExpandIterator(contract util.Uint160, method string, maxItems int, args ...any) (*result.Invoke, error) { + c.switchLock.RLock() + defer c.switchLock.RUnlock() + + if c.inactive { + return nil, ErrConnectionLost + } + + return c.rpcActor.CallAndExpandIterator(contract, method, maxItems, args...) +} + +// TerminateSession closes opened session by its ID. +func (c *Client) TerminateSession(sessionID uuid.UUID) error { + c.switchLock.RLock() + defer c.switchLock.RUnlock() + + if c.inactive { + return ErrConnectionLost + } + + return c.rpcActor.TerminateSession(sessionID) +} + +// TraverseIterator reads specified number of items from the provided iterator +// initialized within given session. +func (c *Client) TraverseIterator(sessionID uuid.UUID, iterator *result.Iterator, num int) ([]stackitem.Item, error) { + c.switchLock.RLock() + defer c.switchLock.RUnlock() + + if c.inactive { + return nil, ErrConnectionLost + } + + return c.rpcActor.TraverseIterator(sessionID, iterator, num) +} + type cache struct { m *sync.RWMutex @@ -174,24 +227,17 @@ func (c *Client) Invoke(contract util.Uint160, fee fixedn.Fixed8, method string, // TestInvoke invokes contract method locally in neo-go node. This method should // be used to read data from smart-contract. -func (c *Client) TestInvoke(contract util.Uint160, method string, args ...any) (res []stackitem.Item, err error) { - c.switchLock.RLock() - defer c.switchLock.RUnlock() - - if c.inactive { - return nil, ErrConnectionLost - } - - val, err := c.rpcActor.Call(contract, method, args...) +func (c *Client) TestInvoke(contract util.Uint160, method string, args ...any) ([]stackitem.Item, error) { + resInvoke, err := c.Call(contract, method, args...) if err != nil { return nil, err } - if val.State != HaltState { - return nil, ¬HaltStateError{state: val.State, exception: val.FaultException} + if resInvoke.State != HaltState { + return nil, ¬HaltStateError{state: resInvoke.State, exception: resInvoke.FaultException} } - return val.Stack, nil + return resInvoke.Stack, nil } // TestInvokeIterator is the same [Client.TestInvoke] but expands an iterator placed