diff --git a/cmd/neofs-adm/internal/modules/root.go b/cmd/neofs-adm/internal/modules/root.go index da4778ce7..10389c8f8 100644 --- a/cmd/neofs-adm/internal/modules/root.go +++ b/cmd/neofs-adm/internal/modules/root.go @@ -5,6 +5,7 @@ import ( "github.com/nspcc-dev/neofs-node/cmd/neofs-adm/internal/modules/config" "github.com/nspcc-dev/neofs-node/cmd/neofs-adm/internal/modules/morph" + "github.com/nspcc-dev/neofs-node/cmd/neofs-adm/internal/modules/storagecfg" "github.com/nspcc-dev/neofs-node/misc" "github.com/nspcc-dev/neofs-node/pkg/util/autocomplete" "github.com/spf13/cobra" @@ -34,6 +35,7 @@ func init() { rootCmd.AddCommand(config.RootCmd) rootCmd.AddCommand(morph.RootCmd) + rootCmd.AddCommand(storagecfg.RootCmd) rootCmd.AddCommand(autocomplete.Command("neofs-adm")) } diff --git a/cmd/neofs-adm/internal/modules/storagecfg/config.go b/cmd/neofs-adm/internal/modules/storagecfg/config.go new file mode 100644 index 000000000..6ba1658ab --- /dev/null +++ b/cmd/neofs-adm/internal/modules/storagecfg/config.go @@ -0,0 +1,143 @@ +package storagecfg + +const configTemplate = `logger: + level: info # logger level: one of "debug", "info" (default), "warn", "error", "dpanic", "panic", "fatal" + +node: + wallet: + path: {{ .Wallet.Path }} # path to a NEO wallet; ignored if key is presented + address: {{ .Wallet.Account }} # address of a NEO account in the wallet; ignored if key is presented + password: {{ .Wallet.Password }} # password for a NEO account in the wallet; ignored if key is presented + addresses: # list of addresses announced by Storage node in the Network map + - {{ .AnnouncedAddress }} + attribute_0: UN-LOCODE:{{ .Attribute.Locode }} + relay: {{ .Relay }} # start Storage node in relay mode without bootstrapping into the Network map + subnet: + exit_zero: false # toggle entrance to zero subnet (overrides corresponding attribute and occurrence in entries) + entries: [] # list of IDs of subnets to enter in a text format of NeoFS API protocol (overrides corresponding attributes) + +grpc: + num: 1 # total number of listener endpoints + 0: + endpoint: {{ .Endpoint }} # endpoint for gRPC server + tls:{{if .TLSCert}} + enabled: true # enable TLS for a gRPC connection (min version is TLS 1.2) + certificate: {{ .TLSCert }} # path to TLS certificate + key: {{ .TLSKey }} # path to TLS key + {{- else }} + enabled: false # disable TLS for a gRPC connection + {{- end}} + +control: + authorized_keys: # list of hex-encoded public keys that have rights to use the Control Service + {{- range .AuthorizedKeys }} + - {{.}}{{end}} + grpc: + endpoint: {{.ControlEndpoint}} # endpoint that is listened by the Control Service + +morph: + dial_timeout: 20s # timeout for side chain NEO RPC client connection + disable_cache: false # use TTL cache for side chain GET operations + rpc_endpoint: # side chain N3 RPC endpoints + {{- range .MorphRPC }} + - https://{{.}}{{end}} + notification_endpoint: # side chain N3 RPC notification endpoints + {{- range .MorphRPC }} + - wss://{{.}}/ws{{end}} +{{if not .Relay }} +storage: + shard_pool_size: 15 # size of per-shard worker pools used for PUT operations + shard_num: 1 # total number of shards + + default: # section with the default shard parameters + metabase: + perm: 0644 # permissions for metabase files(directories: +x for current user and group) + + blobstor: + perm: 0644 # permissions for blobstor files(directories: +x for current user and group) + depth: 2 # max depth of object tree storage in FS + small_object_size: 102400 # 100KiB, size threshold for "small" objects which are stored in key-value DB, not in FS, bytes + compress: true # turn on/off Zstandard compression (level 3) of stored objects + compression_exclude_content_types: + - audio/* + - video/* + + blobovnicza: + size: 1073741824 # approximate size limit of single blobovnicza instance, total size will be: size*width^(depth+1), bytes + depth: 1 # max depth of object tree storage in key-value DB + width: 4 # max width of object tree storage in key-value DB + opened_cache_capacity: 50 # maximum number of opened database files + + gc: + remover_batch_size: 200 # number of objects to be removed by the garbage collector + remover_sleep_interval: 5m # frequency of the garbage collector invocation + + shard: + 0: + mode: "read-write" # mode of the shard, must be one of the: "read-write" (default), "read-only" + + metabase: + path: {{ .MetabasePath }} # path to the metabase + + blobstor: + path: {{ .BlobstorPath }} # path to the blobstor +{{end}}` + +const ( + neofsMainnetAddress = "2cafa46838e8b564468ebd868dcafdd99dce6221" + balanceMainnetAddress = "dc1ec98d9d0c5f9dfade16144defe08cffc5ca55" + neofsTestnetAddress = "b65d8243ac63983206d17e5221af0653a7266fa1" + balanceTestnetAddress = "e0420c216003747626670d1424569c17c79015bf" +) + +var n3config = map[string]struct { + MorphRPC []string + RPC []string + NeoFSContract string + BalanceContract string +}{ + "testnet": { + MorphRPC: []string{ + "rpc01.morph.testnet.fs.neo.org:51331", + "rpc02.morph.testnet.fs.neo.org:51331", + "rpc03.morph.testnet.fs.neo.org:51331", + "rpc04.morph.testnet.fs.neo.org:51331", + "rpc05.morph.testnet.fs.neo.org:51331", + "rpc06.morph.testnet.fs.neo.org:51331", + "rpc07.morph.testnet.fs.neo.org:51331", + }, + RPC: []string{ + "rpc01.testnet.n3.nspcc.ru:21331", + "rpc02.testnet.n3.nspcc.ru:21331", + "rpc03.testnet.n3.nspcc.ru:21331", + "rpc04.testnet.n3.nspcc.ru:21331", + "rpc05.testnet.n3.nspcc.ru:21331", + "rpc06.testnet.n3.nspcc.ru:21331", + "rpc07.testnet.n3.nspcc.ru:21331", + }, + NeoFSContract: neofsTestnetAddress, + BalanceContract: balanceTestnetAddress, + }, + "mainnet": { + MorphRPC: []string{ + "rpc1.morph.fs.neo.org:40341", + "rpc2.morph.fs.neo.org:40341", + "rpc3.morph.fs.neo.org:40341", + "rpc4.morph.fs.neo.org:40341", + "rpc5.morph.fs.neo.org:40341", + "rpc6.morph.fs.neo.org:40341", + "rpc7.morph.fs.neo.org:40341", + }, + RPC: []string{ + "rpc1.n3.nspcc.ru:10331", + "rpc2.n3.nspcc.ru:10331", + "rpc3.n3.nspcc.ru:10331", + "rpc4.n3.nspcc.ru:10331", + "rpc5.n3.nspcc.ru:10331", + "rpc6.n3.nspcc.ru:10331", + "rpc7.n3.nspcc.ru:10331", + }, + NeoFSContract: neofsMainnetAddress, + BalanceContract: balanceMainnetAddress, + }, +} diff --git a/cmd/neofs-adm/internal/modules/storagecfg/root.go b/cmd/neofs-adm/internal/modules/storagecfg/root.go new file mode 100644 index 000000000..7f0a3ee56 --- /dev/null +++ b/cmd/neofs-adm/internal/modules/storagecfg/root.go @@ -0,0 +1,405 @@ +package storagecfg + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "math/big" + "math/rand" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/chzyer/readline" + "github.com/nspcc-dev/neo-go/cli/flags" + "github.com/nspcc-dev/neo-go/cli/input" + "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" + "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/encoding/fixedn" + "github.com/nspcc-dev/neo-go/pkg/rpc/client" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm" + "github.com/nspcc-dev/neo-go/pkg/wallet" + "github.com/spf13/cobra" +) + +const ( + walletFlag = "wallet" + accountFlag = "account" +) + +const ( + defaultControlEndpoint = "127.0.0.1:8090" + defaultDataEndpoint = "127.0.0.1" +) + +// RootCmd is a root command of config section. +var RootCmd = &cobra.Command{ + Use: "storage-config [-w wallet] [-a acccount] []", + Short: "Section for storage node configuration commands.", + Run: storageConfig, +} + +func init() { + fs := RootCmd.Flags() + + fs.StringP(walletFlag, "w", "", "path to wallet") + fs.StringP(accountFlag, "a", "", "wallet account") +} + +type config struct { + AnnouncedAddress string + AuthorizedKeys []string + ControlEndpoint string + Endpoint string + TLSCert string + TLSKey string + MorphRPC []string + Attribute struct { + Locode string + } + Wallet struct { + Path string + Account string + Password string + } + Relay bool + BlobstorPath string + MetabasePath string +} + +func storageConfig(cmd *cobra.Command, args []string) { + var outPath string + if len(args) != 0 { + outPath = args[0] + } else { + outPath = getPath("File to write config at [./config.yml]: ") + if outPath == "" { + outPath = "./config.yml" + } + } + + var c config + + c.Wallet.Path, _ = cmd.Flags().GetString(walletFlag) + if c.Wallet.Path == "" { + c.Wallet.Path = getPath("Path to the storage node wallet: ") + } + + w, err := wallet.NewWalletFromFile(c.Wallet.Path) + fatalOnErr(err) + + c.Wallet.Account, _ = cmd.Flags().GetString(accountFlag) + if c.Wallet.Account == "" { + addr := address.Uint160ToString(w.GetChangeAddress()) + c.Wallet.Account = getWalletAccount(w, fmt.Sprintf("Wallet account [%s]: ", addr)) + if c.Wallet.Account == "" { + c.Wallet.Account = addr + } + } + + accH, err := flags.ParseAddress(c.Wallet.Account) + fatalOnErr(err) + + acc := w.GetAccount(accH) + if acc == nil { + fatalOnErr(errors.New("can't find account in wallet")) + } + + c.Wallet.Password, err = input.ReadPassword(fmt.Sprintf("Account password for %s: ", c.Wallet.Account)) + fatalOnErr(err) + + err = acc.Decrypt(c.Wallet.Password, keys.NEP2ScryptParams()) + fatalOnErr(err) + + c.AuthorizedKeys = append(c.AuthorizedKeys, hex.EncodeToString(acc.PrivateKey().PublicKey().Bytes())) + + var network string + for { + network = getString("Choose network [mainnet]/testnet: ") + switch network { + case "": + network = "mainnet" + case "testnet", "mainnet": + default: + cmd.Println(`Network must be either "mainnet" or "testnet"`) + continue + } + break + } + + c.MorphRPC = n3config[network].MorphRPC + + depositGas(cmd, acc, network) + + c.Attribute.Locode = getString("UN-LOCODE attribute in [XX YYY] format: ") + var addr, port string + for { + c.AnnouncedAddress = getString("Publicly announced address: ") + addr, port, err = net.SplitHostPort(c.AnnouncedAddress) + if err != nil { + cmd.Println("Address must have form A.B.C.D:PORT") + continue + } + + ip, err := net.ResolveIPAddr("ip", addr) + if err != nil { + cmd.Printf("Can't resolve IP address %s: %v\n", addr, err) + continue + } + + if !ip.IP.IsGlobalUnicast() { + cmd.Println("IP must be global unicast.") + continue + } + cmd.Printf("Resolved IP address: %s\n", ip.String()) + + _, err = strconv.ParseUint(port, 10, 16) + if err != nil { + cmd.Println("Port must be an integer.") + continue + } + + break + } + + defaultAddr := net.JoinHostPort(defaultDataEndpoint, port) + c.Endpoint = getString(fmt.Sprintf("Listening address [%s]: ", defaultAddr)) + if c.Endpoint == "" { + c.Endpoint = defaultAddr + } + + c.ControlEndpoint = getString(fmt.Sprintf("Listening address (control endpoint) [%s]: ", defaultControlEndpoint)) + if c.ControlEndpoint == "" { + c.ControlEndpoint = defaultControlEndpoint + } + + c.TLSCert = getPath("TLS Certificate (optional): ") + if c.TLSCert != "" { + c.TLSKey = getPath("TLS Key: ") + } + + c.Relay = getConfirmation(false, "Use node as a relay? yes/[no]: ") + if !c.Relay { + p := getPath("Path to the storage directory (all available storage will be used): ") + c.BlobstorPath = filepath.Join(p, "blob") + c.MetabasePath = filepath.Join(p, "meta") + } + + out := applyTemplate(c) + fatalOnErr(ioutil.WriteFile(outPath, out, 0644)) + + cmd.Println("Node is ready for work! Run `neofs-node -config " + outPath + "`") +} + +func getWalletAccount(w *wallet.Wallet, prompt string) string { + addrs := make([]readline.PrefixCompleterInterface, len(w.Accounts)) + for i := range w.Accounts { + addrs[i] = readline.PcItem(w.Accounts[i].Address) + } + + readline.SetAutoComplete(readline.NewPrefixCompleter(addrs...)) + defer readline.SetAutoComplete(nil) + + s, err := readline.Line(prompt) + fatalOnErr(err) + return strings.TrimSpace(s) // autocompleter can return a string with a trailing space +} + +func getString(prompt string) string { + s, err := input.ReadLine(prompt) + fatalOnErr(err) + return s +} + +type filenameCompleter struct{} + +func (filenameCompleter) Do(line []rune, pos int) (newLine [][]rune, length int) { + prefix := string(line[:pos]) + dir := filepath.Dir(prefix) + de, err := os.ReadDir(dir) + if err != nil { + return nil, 0 + } + + for i := range de { + name := filepath.Join(dir, de[i].Name()) + if strings.HasPrefix(name, prefix) { + tail := []rune(strings.TrimPrefix(name, prefix)) + if de[i].IsDir() { + tail = append(tail, filepath.Separator) + } + newLine = append(newLine, tail) + } + } + if pos != 0 { + return newLine, pos - len([]rune(dir)) + } + return newLine, 0 +} + +func getPath(prompt string) string { + readline.SetAutoComplete(filenameCompleter{}) + defer readline.SetAutoComplete(nil) + + p, err := readline.Line(prompt) + fatalOnErr(err) + + if p == "" { + return p + } + + abs, err := filepath.Abs(p) + if err != nil { + fatalOnErr(fmt.Errorf("can't create an absolute path: %w", err)) + } + + return abs +} + +func getConfirmation(def bool, prompt string) bool { + for { + s, err := readline.Line(prompt) + fatalOnErr(err) + + switch strings.ToLower(s) { + case "y", "yes": + return true + case "n", "no": + return false + default: + if len(s) == 0 { + return def + } + } + } +} + +func applyTemplate(c config) []byte { + tmpl, err := template.New("config").Parse(configTemplate) + fatalOnErr(err) + + b := bytes.NewBuffer(nil) + fatalOnErr(tmpl.Execute(b, c)) + + return b.Bytes() +} + +func fatalOnErr(err error) { + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func depositGas(cmd *cobra.Command, acc *wallet.Account, network string) { + sideClient := initClient(n3config[network].MorphRPC) + balanceHash, _ := util.Uint160DecodeStringLE(n3config[network].BalanceContract) + + res, err := sideClient.InvokeFunction(balanceHash, "balanceOf", []smartcontract.Parameter{{ + Type: smartcontract.Hash160Type, + Value: acc.Contract.ScriptHash(), + }}, nil) + fatalOnErr(err) + + if res.State != vm.HaltState.String() { + fatalOnErr(fmt.Errorf("invalid response from balance contract: %s", res.FaultException)) + } + + var balance *big.Int + if len(res.Stack) != 0 { + balance, _ = res.Stack[0].TryInteger() + } + + if balance == nil { + fatalOnErr(errors.New("invalid response from balance contract")) + } + + ok := getConfirmation(false, fmt.Sprintf("Current NeoFS balance is %s, make a deposit? y/[n]: ", + fixedn.ToString(balance, 12))) + if !ok { + return + } + + amountStr := getString("Enter amount in GAS: ") + amount, err := fixedn.FromString(amountStr, 8) + if err != nil { + fatalOnErr(fmt.Errorf("invalid amount: %w", err)) + } + + mainClient := initClient(n3config[network].RPC) + neofsHash, _ := util.Uint160DecodeStringLE(n3config[network].NeoFSContract) + + gasHash, err := mainClient.GetNativeContractHash(nativenames.Gas) + fatalOnErr(err) + + tx, err := mainClient.CreateNEP17TransferTx(acc, neofsHash, gasHash, amount.Int64(), 0, nil, nil) + fatalOnErr(err) + + txHash, err := mainClient.SignAndPushTx(tx, acc, nil) + fatalOnErr(err) + + cmd.Print("Waiting for transactions to persist.") + tick := time.NewTicker(time.Second / 2) + defer tick.Stop() + + timer := time.NewTimer(time.Second * 20) + defer timer.Stop() + + at := trigger.Application + +loop: + for { + select { + case <-tick.C: + _, err := mainClient.GetApplicationLog(txHash, &at) + if err == nil { + cmd.Print("\n") + break loop + } + cmd.Print(".") + case <-timer.C: + cmd.Printf("\nTimeout while waiting for transaction to persist.\n") + if getConfirmation(false, "Continue configuration? yes/[no]: ") { + return + } + os.Exit(1) + } + } +} + +func initClient(rpc []string) *client.Client { + var c *client.Client + var err error + + shuffled := make([]string, len(rpc)) + copy(shuffled, rpc) + rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + + for _, endpoint := range shuffled { + c, err = client.New(context.Background(), "https://"+endpoint, client.Options{ + DialTimeout: time.Second * 2, + RequestTimeout: time.Second * 5, + }) + if err != nil { + continue + } + if err = c.Init(); err != nil { + continue + } + return c + } + + fatalOnErr(fmt.Errorf("can't create N3 client: %w", err)) + panic("unreachable") +} diff --git a/go.mod b/go.mod index 07bf7ebb8..b01bcf2cf 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/nspcc-dev/neofs-node go 1.16 require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 github.com/google/go-github/v39 v39.2.0 github.com/google/uuid v1.2.0 diff --git a/go.sum b/go.sum index f4fa05aa0..587110c2f 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,11 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=