2021-05-18 18:49:09 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2021-05-28 20:48:23 +00:00
|
|
|
"crypto/ecdsa"
|
2021-07-16 12:35:07 +00:00
|
|
|
"encoding/json"
|
2021-05-18 18:49:09 +00:00
|
|
|
"fmt"
|
|
|
|
"os"
|
2021-05-25 10:30:54 +00:00
|
|
|
"os/signal"
|
2021-06-02 18:53:20 +00:00
|
|
|
"strings"
|
2021-05-25 10:30:54 +00:00
|
|
|
"syscall"
|
2021-05-18 18:49:09 +00:00
|
|
|
"time"
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
|
2021-06-04 13:01:42 +00:00
|
|
|
cid "github.com/nspcc-dev/neofs-api-go/pkg/container/id"
|
2021-05-19 09:28:17 +00:00
|
|
|
"github.com/nspcc-dev/neofs-s3-gw/authmate"
|
2021-05-20 12:41:32 +00:00
|
|
|
"github.com/nspcc-dev/neofs-s3-gw/internal/version"
|
2021-06-24 15:21:34 +00:00
|
|
|
"github.com/nspcc-dev/neofs-s3-gw/internal/wallet"
|
2021-08-27 12:48:59 +00:00
|
|
|
"github.com/nspcc-dev/neofs-sdk-go/pool"
|
2021-06-24 15:21:34 +00:00
|
|
|
"github.com/spf13/viper"
|
2021-05-18 18:49:09 +00:00
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"go.uber.org/zap/zapcore"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
poolConnectTimeout = 5 * time.Second
|
|
|
|
poolRequestTimeout = 5 * time.Second
|
2021-09-10 08:07:26 +00:00
|
|
|
// a number of 1-hour epochs in a month.
|
|
|
|
defaultLifetime = 720
|
2021-05-18 18:49:09 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
2021-06-24 15:21:34 +00:00
|
|
|
walletPathFlag string
|
|
|
|
accountAddressFlag string
|
|
|
|
peerAddressFlag string
|
|
|
|
eaclRulesFlag string
|
|
|
|
contextRulesFlag string
|
|
|
|
gateWalletPathFlag string
|
|
|
|
gateAccountAddressFlag string
|
|
|
|
accessKeyIDFlag string
|
|
|
|
containerIDFlag string
|
|
|
|
containerFriendlyName string
|
|
|
|
gatesPublicKeysFlag cli.StringSlice
|
|
|
|
logEnabledFlag bool
|
|
|
|
logDebugEnabledFlag bool
|
|
|
|
sessionTokenFlag bool
|
2021-10-26 12:03:51 +00:00
|
|
|
lifetimeFlag time.Duration
|
2021-07-16 12:35:07 +00:00
|
|
|
containerPolicies string
|
2021-09-01 11:30:15 +00:00
|
|
|
awcCliCredFile string
|
2021-06-24 15:21:34 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
envWalletPassphrase = "wallet.passphrase"
|
|
|
|
envWalletGatePassphrase = "wallet.gate.passphrase"
|
2021-05-18 18:49:09 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var zapConfig = zap.Config{
|
|
|
|
Development: true,
|
|
|
|
Encoding: "console",
|
|
|
|
Level: zap.NewAtomicLevelAt(zapcore.FatalLevel),
|
|
|
|
OutputPaths: []string{"stdout"},
|
|
|
|
EncoderConfig: zapcore.EncoderConfig{
|
|
|
|
MessageKey: "message",
|
|
|
|
LevelKey: "level",
|
|
|
|
EncodeLevel: zapcore.CapitalLevelEncoder,
|
|
|
|
TimeKey: "time",
|
|
|
|
EncodeTime: zapcore.ISO8601TimeEncoder,
|
|
|
|
CallerKey: "caller",
|
|
|
|
EncodeCaller: zapcore.ShortCallerEncoder,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
func prepare() (context.Context, *zap.Logger) {
|
|
|
|
var (
|
2021-05-25 10:30:54 +00:00
|
|
|
err error
|
|
|
|
log = zap.NewNop()
|
|
|
|
ctx, _ = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
2021-05-18 18:49:09 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
if !logEnabledFlag {
|
2021-05-25 10:30:54 +00:00
|
|
|
return ctx, log
|
2021-05-18 18:49:09 +00:00
|
|
|
} else if logDebugEnabledFlag {
|
|
|
|
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
|
|
|
|
}
|
|
|
|
|
|
|
|
if log, err = zapConfig.Build(); err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
2021-05-25 10:30:54 +00:00
|
|
|
return ctx, log
|
2021-05-18 18:49:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
app := &cli.App{
|
|
|
|
Name: "NeoFS gate authentication manager",
|
|
|
|
Usage: "Helps manage delegated access via gates to data stored in NeoFS network",
|
2021-05-20 12:41:32 +00:00
|
|
|
Version: version.Version,
|
2021-05-18 18:49:09 +00:00
|
|
|
Flags: appFlags(),
|
|
|
|
Commands: appCommands(),
|
|
|
|
}
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
viper.AutomaticEnv()
|
|
|
|
viper.SetEnvPrefix("AUTHMATE")
|
|
|
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
|
|
viper.AllowEmptyEnv(true)
|
|
|
|
|
2021-05-18 18:49:09 +00:00
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
|
|
_, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
|
|
|
os.Exit(100)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func appFlags() []cli.Flag {
|
|
|
|
return []cli.Flag{
|
|
|
|
&cli.BoolFlag{
|
|
|
|
Name: "with-log",
|
|
|
|
Usage: "Enable logger",
|
|
|
|
Destination: &logEnabledFlag,
|
|
|
|
},
|
|
|
|
&cli.BoolFlag{
|
|
|
|
Name: "debug",
|
|
|
|
Usage: "Enable debug logger level",
|
|
|
|
Destination: &logDebugEnabledFlag,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func appCommands() []*cli.Command {
|
|
|
|
return []*cli.Command{
|
|
|
|
issueSecret(),
|
|
|
|
obtainSecret(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func issueSecret() *cli.Command {
|
|
|
|
return &cli.Command{
|
|
|
|
Name: "issue-secret",
|
|
|
|
Usage: "Issue a secret in NeoFS network",
|
|
|
|
Flags: []cli.Flag{
|
|
|
|
&cli.StringFlag{
|
2021-06-24 15:21:34 +00:00
|
|
|
Name: "wallet",
|
2021-05-18 18:49:09 +00:00
|
|
|
Value: "",
|
2021-06-24 15:21:34 +00:00
|
|
|
Usage: "path to the wallet",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: true,
|
2021-06-24 15:21:34 +00:00
|
|
|
Destination: &walletPathFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "address",
|
|
|
|
Value: "",
|
|
|
|
Usage: "address of wallet account",
|
|
|
|
Required: false,
|
|
|
|
Destination: &accountAddressFlag,
|
2021-05-18 18:49:09 +00:00
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "peer",
|
|
|
|
Value: "",
|
|
|
|
Usage: "address of a neofs peer to connect to",
|
|
|
|
Required: true,
|
|
|
|
Destination: &peerAddressFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
2021-06-16 20:09:51 +00:00
|
|
|
Name: "bearer-rules",
|
|
|
|
Usage: "rules for bearer token as plain json string",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: false,
|
|
|
|
Destination: &eaclRulesFlag,
|
|
|
|
},
|
2021-06-16 20:09:51 +00:00
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "session-rules",
|
|
|
|
Usage: "rules for session token as plain json string",
|
|
|
|
Required: false,
|
|
|
|
Destination: &contextRulesFlag,
|
|
|
|
},
|
2021-05-18 18:49:09 +00:00
|
|
|
&cli.StringSliceFlag{
|
|
|
|
Name: "gate-public-key",
|
2021-06-16 14:07:31 +00:00
|
|
|
Usage: "public 256r1 key of a gate (use flags repeatedly for multiple gates)",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: true,
|
|
|
|
Destination: &gatesPublicKeysFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "container-id",
|
|
|
|
Usage: "auth container id to put the secret into",
|
|
|
|
Required: false,
|
|
|
|
Destination: &containerIDFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "container-friendly-name",
|
|
|
|
Usage: "friendly name of auth container to put the secret into",
|
|
|
|
Required: false,
|
|
|
|
Destination: &containerFriendlyName,
|
|
|
|
Value: "auth-container",
|
|
|
|
},
|
2021-06-16 20:09:51 +00:00
|
|
|
&cli.BoolFlag{
|
|
|
|
Name: "create-session-token",
|
|
|
|
Usage: "create session token",
|
|
|
|
Required: false,
|
|
|
|
Destination: &sessionTokenFlag,
|
|
|
|
Value: false,
|
|
|
|
},
|
2021-10-26 12:03:51 +00:00
|
|
|
&cli.DurationFlag{
|
2021-06-28 13:20:11 +00:00
|
|
|
Name: "lifetime",
|
2021-10-26 12:03:51 +00:00
|
|
|
Usage: "Lifetime of tokens (e.g. 1d2h30m, it will be ceil rounded to the nearest amount of epoch)",
|
2021-06-28 13:20:11 +00:00
|
|
|
Required: false,
|
|
|
|
Destination: &lifetimeFlag,
|
|
|
|
Value: defaultLifetime,
|
|
|
|
},
|
2021-07-16 12:35:07 +00:00
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "container-policy",
|
|
|
|
Usage: "mapping AWS storage class to NeoFS storage policy as plain json string or path to json file",
|
|
|
|
Required: false,
|
|
|
|
Destination: &containerPolicies,
|
|
|
|
},
|
2021-09-01 11:30:15 +00:00
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "aws-cli-credentials",
|
|
|
|
Usage: "path to the aws cli credential file",
|
|
|
|
Required: false,
|
|
|
|
Destination: &awcCliCredFile,
|
|
|
|
},
|
2021-05-18 18:49:09 +00:00
|
|
|
},
|
|
|
|
Action: func(c *cli.Context) error {
|
|
|
|
ctx, log := prepare()
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
password := wallet.GetPassword(viper.GetViper(), envWalletPassphrase)
|
|
|
|
key, err := wallet.GetKeyFromPath(walletPathFlag, accountAddressFlag, password)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to load neofs private key: %s", err), 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
defer cancel()
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
client, err := createSDKClient(ctx, log, &key.PrivateKey, peerAddressFlag)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to create sdk client: %s", err), 2)
|
|
|
|
}
|
|
|
|
|
2021-05-19 09:28:17 +00:00
|
|
|
agent := authmate.New(log, client)
|
2021-06-04 13:01:42 +00:00
|
|
|
var containerID *cid.ID
|
2021-05-18 18:49:09 +00:00
|
|
|
if len(containerIDFlag) > 0 {
|
2021-06-04 13:01:42 +00:00
|
|
|
containerID = cid.New()
|
|
|
|
if err := containerID.Parse(containerIDFlag); err != nil {
|
2021-05-18 18:49:09 +00:00
|
|
|
return cli.Exit(fmt.Sprintf("failed to parse auth container id: %s", err), 3)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
var gatesPublicKeys []*keys.PublicKey
|
2021-05-18 18:49:09 +00:00
|
|
|
for _, key := range gatesPublicKeysFlag.Value() {
|
2021-06-24 15:21:34 +00:00
|
|
|
gpk, err := keys.NewPublicKeyFromString(key)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
2021-06-28 13:20:11 +00:00
|
|
|
return cli.Exit(fmt.Sprintf("failed to load gate's public key: %s", err), 4)
|
2021-05-18 18:49:09 +00:00
|
|
|
}
|
|
|
|
gatesPublicKeys = append(gatesPublicKeys, gpk)
|
|
|
|
}
|
|
|
|
|
2021-06-28 13:20:11 +00:00
|
|
|
if lifetimeFlag <= 0 {
|
2021-10-26 12:03:51 +00:00
|
|
|
return cli.Exit(fmt.Sprintf("lifetime must be greater 0, current value: %d", lifetimeFlag), 5)
|
2021-06-28 13:20:11 +00:00
|
|
|
}
|
|
|
|
|
2021-07-16 12:35:07 +00:00
|
|
|
policies, err := parsePolicies(containerPolicies)
|
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("couldn't parse container policy: %s", err.Error()), 6)
|
|
|
|
}
|
|
|
|
|
2021-05-19 09:28:17 +00:00
|
|
|
issueSecretOptions := &authmate.IssueSecretOptions{
|
2021-06-04 13:01:42 +00:00
|
|
|
ContainerID: containerID,
|
2021-05-18 18:49:09 +00:00
|
|
|
ContainerFriendlyName: containerFriendlyName,
|
2021-05-28 20:48:23 +00:00
|
|
|
NeoFSKey: key,
|
2021-05-18 18:49:09 +00:00
|
|
|
GatesPublicKeys: gatesPublicKeys,
|
2021-06-29 12:24:44 +00:00
|
|
|
EACLRules: getJSONRules(eaclRulesFlag),
|
|
|
|
ContextRules: getJSONRules(contextRulesFlag),
|
2021-07-16 12:35:07 +00:00
|
|
|
ContainerPolicies: policies,
|
2021-06-16 20:09:51 +00:00
|
|
|
SessionTkn: sessionTokenFlag,
|
2021-06-28 13:20:11 +00:00
|
|
|
Lifetime: lifetimeFlag,
|
2021-09-01 11:30:15 +00:00
|
|
|
AwsCliCredentialsFile: awcCliCredFile,
|
2021-05-18 18:49:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err = agent.IssueSecret(ctx, os.Stdout, issueSecretOptions); err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to issue secret: %s", err), 6)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-16 12:35:07 +00:00
|
|
|
func parsePolicies(val string) (authmate.ContainerPolicies, error) {
|
|
|
|
if val == "" {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
data, err := os.ReadFile(val)
|
|
|
|
if err != nil {
|
|
|
|
data = []byte(val)
|
|
|
|
}
|
|
|
|
|
|
|
|
var policies authmate.ContainerPolicies
|
|
|
|
if err = json.Unmarshal(data, &policies); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return policies, nil
|
|
|
|
}
|
|
|
|
|
2021-06-29 12:24:44 +00:00
|
|
|
func getJSONRules(val string) []byte {
|
|
|
|
if data, err := os.ReadFile(val); err == nil {
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
|
|
|
|
return []byte(val)
|
|
|
|
}
|
|
|
|
|
2021-05-18 18:49:09 +00:00
|
|
|
func obtainSecret() *cli.Command {
|
|
|
|
command := &cli.Command{
|
|
|
|
Name: "obtain-secret",
|
|
|
|
Usage: "Obtain a secret from NeoFS network",
|
|
|
|
Flags: []cli.Flag{
|
|
|
|
&cli.StringFlag{
|
2021-06-24 15:21:34 +00:00
|
|
|
Name: "wallet",
|
2021-05-18 18:49:09 +00:00
|
|
|
Value: "",
|
2021-06-24 15:21:34 +00:00
|
|
|
Usage: "path to the wallet",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: true,
|
2021-06-24 15:21:34 +00:00
|
|
|
Destination: &walletPathFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "address",
|
|
|
|
Value: "",
|
|
|
|
Usage: "address of wallet account",
|
|
|
|
Required: false,
|
|
|
|
Destination: &accountAddressFlag,
|
2021-05-18 18:49:09 +00:00
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "peer",
|
|
|
|
Value: "",
|
|
|
|
Usage: "address of neofs peer to connect to",
|
|
|
|
Required: true,
|
|
|
|
Destination: &peerAddressFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
2021-06-24 15:21:34 +00:00
|
|
|
Name: "gate-wallet",
|
|
|
|
Value: "",
|
|
|
|
Usage: "path to the wallet",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: true,
|
2021-06-24 15:21:34 +00:00
|
|
|
Destination: &gateWalletPathFlag,
|
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
|
|
|
Name: "gate-address",
|
|
|
|
Value: "",
|
|
|
|
Usage: "address of wallet account",
|
|
|
|
Required: false,
|
|
|
|
Destination: &gateAccountAddressFlag,
|
2021-05-18 18:49:09 +00:00
|
|
|
},
|
|
|
|
&cli.StringFlag{
|
2021-06-02 18:53:20 +00:00
|
|
|
Name: "access-key-id",
|
|
|
|
Usage: "access key id for s3",
|
2021-05-18 18:49:09 +00:00
|
|
|
Required: true,
|
2021-06-02 18:53:20 +00:00
|
|
|
Destination: &accessKeyIDFlag,
|
2021-05-18 18:49:09 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
Action: func(c *cli.Context) error {
|
|
|
|
ctx, log := prepare()
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
password := wallet.GetPassword(viper.GetViper(), envWalletPassphrase)
|
|
|
|
key, err := wallet.GetKeyFromPath(walletPathFlag, accountAddressFlag, password)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to load neofs private key: %s", err), 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
defer cancel()
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
client, err := createSDKClient(ctx, log, &key.PrivateKey, peerAddressFlag)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to create sdk client: %s", err), 2)
|
|
|
|
}
|
|
|
|
|
2021-05-19 09:28:17 +00:00
|
|
|
agent := authmate.New(log, client)
|
2021-05-18 18:49:09 +00:00
|
|
|
|
|
|
|
var _ = agent
|
|
|
|
|
2021-06-24 15:21:34 +00:00
|
|
|
password = wallet.GetPassword(viper.GetViper(), envWalletGatePassphrase)
|
|
|
|
gateCreds, err := wallet.GetKeyFromPath(gateWalletPathFlag, gateAccountAddressFlag, password)
|
2021-05-18 18:49:09 +00:00
|
|
|
if err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to create owner's private key: %s", err), 4)
|
|
|
|
}
|
|
|
|
|
2021-06-02 18:53:20 +00:00
|
|
|
secretAddress := strings.Replace(accessKeyIDFlag, "_", "/", 1)
|
|
|
|
|
2021-05-19 09:28:17 +00:00
|
|
|
obtainSecretOptions := &authmate.ObtainSecretOptions{
|
2021-06-02 18:53:20 +00:00
|
|
|
SecretAddress: secretAddress,
|
2021-06-16 14:07:31 +00:00
|
|
|
GatePrivateKey: gateCreds,
|
2021-05-18 18:49:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err = agent.ObtainSecret(ctx, os.Stdout, obtainSecretOptions); err != nil {
|
|
|
|
return cli.Exit(fmt.Sprintf("failed to obtain secret: %s", err), 5)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
},
|
|
|
|
}
|
|
|
|
return command
|
|
|
|
}
|
|
|
|
|
2021-05-28 20:48:23 +00:00
|
|
|
func createSDKClient(ctx context.Context, log *zap.Logger, key *ecdsa.PrivateKey, peerAddress string) (pool.Pool, error) {
|
2021-05-18 18:49:09 +00:00
|
|
|
log.Debug("prepare connection pool")
|
|
|
|
|
2021-05-28 08:46:13 +00:00
|
|
|
pb := new(pool.Builder)
|
2021-05-26 16:48:27 +00:00
|
|
|
pb.AddNode(peerAddress, 1)
|
|
|
|
|
2021-05-28 08:46:13 +00:00
|
|
|
opts := &pool.BuilderOptions{
|
2021-05-28 20:48:23 +00:00
|
|
|
Key: key,
|
2021-05-26 16:48:27 +00:00
|
|
|
NodeConnectionTimeout: poolConnectTimeout,
|
|
|
|
NodeRequestTimeout: poolRequestTimeout,
|
|
|
|
}
|
2021-05-28 20:48:23 +00:00
|
|
|
return pb.Build(ctx, opts)
|
2021-05-18 18:49:09 +00:00
|
|
|
}
|