2019-04-07 06:38:08 +00:00
|
|
|
package consul
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
metrics "github.com/armon/go-metrics"
|
|
|
|
"github.com/hashicorp/consul/acl"
|
|
|
|
"github.com/hashicorp/consul/agent/consul/state"
|
|
|
|
"github.com/hashicorp/consul/agent/structs"
|
|
|
|
memdb "github.com/hashicorp/go-memdb"
|
2019-05-06 16:09:59 +00:00
|
|
|
"github.com/mitchellh/copystructure"
|
2019-04-07 06:38:08 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// The ConfigEntry endpoint is used to query centralized config information
|
|
|
|
type ConfigEntry struct {
|
|
|
|
srv *Server
|
|
|
|
}
|
|
|
|
|
|
|
|
// Apply does an upsert of the given config entry.
|
2019-04-30 23:27:16 +00:00
|
|
|
func (c *ConfigEntry) Apply(args *structs.ConfigEntryRequest, reply *bool) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(args.Entry.GetEnterpriseMeta(), true); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-08-20 16:01:13 +00:00
|
|
|
// Ensure that all config entry writes go to the primary datacenter. These will then
|
|
|
|
// be replicated to all the other datacenters.
|
|
|
|
args.Datacenter = c.srv.config.PrimaryDatacenter
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.Apply", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "apply"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
entMeta := args.Entry.GetEnterpriseMeta()
|
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, entMeta, nil)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
// Normalize and validate the incoming config entry.
|
|
|
|
if err := args.Entry.Normalize(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if err := args.Entry.Validate(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
if authz != nil && !args.Entry.CanWrite(authz) {
|
2019-04-10 21:27:28 +00:00
|
|
|
return acl.ErrPermissionDenied
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
|
2019-04-30 23:27:16 +00:00
|
|
|
if args.Op != structs.ConfigEntryUpsert && args.Op != structs.ConfigEntryUpsertCAS {
|
|
|
|
args.Op = structs.ConfigEntryUpsert
|
|
|
|
}
|
2019-04-07 06:38:08 +00:00
|
|
|
resp, err := c.srv.raftApply(structs.ConfigEntryRequestType, args)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if respErr, ok := resp.(error); ok {
|
|
|
|
return respErr
|
|
|
|
}
|
2019-04-30 23:27:16 +00:00
|
|
|
if respBool, ok := resp.(bool); ok {
|
|
|
|
*reply = respBool
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get returns a single config entry by Kind/Name.
|
2019-04-29 22:08:09 +00:00
|
|
|
func (c *ConfigEntry) Get(args *structs.ConfigEntryQuery, reply *structs.ConfigEntryResponse) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(&args.EnterpriseMeta, false); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.Get", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "get"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, &args.EnterpriseMeta, nil)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2019-04-10 21:27:28 +00:00
|
|
|
|
|
|
|
// Create a dummy config entry to check the ACL permissions.
|
|
|
|
lookupEntry, err := structs.MakeConfigEntry(args.Kind, args.Name)
|
|
|
|
if err != nil {
|
2019-04-07 06:38:08 +00:00
|
|
|
return err
|
|
|
|
}
|
2020-02-07 13:30:40 +00:00
|
|
|
lookupEntry.GetEnterpriseMeta().Merge(&args.EnterpriseMeta)
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
if authz != nil && !lookupEntry.CanRead(authz) {
|
2019-04-10 21:27:28 +00:00
|
|
|
return acl.ErrPermissionDenied
|
|
|
|
}
|
2019-04-07 06:38:08 +00:00
|
|
|
|
|
|
|
return c.srv.blockingQuery(
|
|
|
|
&args.QueryOptions,
|
|
|
|
&reply.QueryMeta,
|
|
|
|
func(ws memdb.WatchSet, state *state.Store) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
index, entry, err := state.ConfigEntry(ws, args.Kind, args.Name, &args.EnterpriseMeta)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
reply.Index = index
|
2019-04-10 21:27:28 +00:00
|
|
|
if entry == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-04-29 22:08:09 +00:00
|
|
|
reply.Entry = entry
|
2019-04-07 06:38:08 +00:00
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// List returns all the config entries of the given kind. If Kind is blank,
|
|
|
|
// all existing config entries will be returned.
|
|
|
|
func (c *ConfigEntry) List(args *structs.ConfigEntryQuery, reply *structs.IndexedConfigEntries) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(&args.EnterpriseMeta, false); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.List", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "list"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, &args.EnterpriseMeta, nil)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-30 22:19:19 +00:00
|
|
|
if args.Kind != "" && !structs.ValidateConfigEntryKind(args.Kind) {
|
|
|
|
return fmt.Errorf("invalid config entry kind: %s", args.Kind)
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
return c.srv.blockingQuery(
|
|
|
|
&args.QueryOptions,
|
|
|
|
&reply.QueryMeta,
|
|
|
|
func(ws memdb.WatchSet, state *state.Store) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
index, entries, err := state.ConfigEntriesByKind(ws, args.Kind, &args.EnterpriseMeta)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter the entries returned by ACL permissions.
|
|
|
|
filteredEntries := make([]structs.ConfigEntry, 0, len(entries))
|
|
|
|
for _, entry := range entries {
|
2020-01-24 15:04:58 +00:00
|
|
|
if authz != nil && !entry.CanRead(authz) {
|
2019-04-10 21:27:28 +00:00
|
|
|
continue
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
filteredEntries = append(filteredEntries, entry)
|
|
|
|
}
|
|
|
|
|
2019-04-10 21:27:28 +00:00
|
|
|
reply.Kind = args.Kind
|
2019-04-07 06:38:08 +00:00
|
|
|
reply.Index = index
|
|
|
|
reply.Entries = filteredEntries
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2019-04-26 17:38:39 +00:00
|
|
|
// ListAll returns all the known configuration entries
|
|
|
|
func (c *ConfigEntry) ListAll(args *structs.DCSpecificRequest, reply *structs.IndexedGenericConfigEntries) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(&args.EnterpriseMeta, false); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-26 17:38:39 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.ListAll", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "listAll"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, &args.EnterpriseMeta, nil)
|
2019-04-26 17:38:39 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.srv.blockingQuery(
|
|
|
|
&args.QueryOptions,
|
|
|
|
&reply.QueryMeta,
|
|
|
|
func(ws memdb.WatchSet, state *state.Store) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
index, entries, err := state.ConfigEntries(ws, &args.EnterpriseMeta)
|
2019-04-26 17:38:39 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter the entries returned by ACL permissions.
|
|
|
|
filteredEntries := make([]structs.ConfigEntry, 0, len(entries))
|
|
|
|
for _, entry := range entries {
|
2020-01-24 15:04:58 +00:00
|
|
|
if authz != nil && !entry.CanRead(authz) {
|
2019-04-26 17:38:39 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
filteredEntries = append(filteredEntries, entry)
|
|
|
|
}
|
|
|
|
|
|
|
|
reply.Entries = filteredEntries
|
|
|
|
reply.Index = index
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
// Delete deletes a config entry.
|
|
|
|
func (c *ConfigEntry) Delete(args *structs.ConfigEntryRequest, reply *struct{}) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(args.Entry.GetEnterpriseMeta(), true); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-08-20 16:01:13 +00:00
|
|
|
// Ensure that all config entry writes go to the primary datacenter. These will then
|
|
|
|
// be replicated to all the other datacenters.
|
|
|
|
args.Datacenter = c.srv.config.PrimaryDatacenter
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.Delete", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "delete"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, args.Entry.GetEnterpriseMeta(), nil)
|
|
|
|
if err != nil {
|
2019-04-07 06:38:08 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
// Normalize the incoming entry.
|
|
|
|
if err := args.Entry.Normalize(); err != nil {
|
2019-04-07 06:38:08 +00:00
|
|
|
return err
|
|
|
|
}
|
2020-01-24 15:04:58 +00:00
|
|
|
|
|
|
|
if authz != nil && !args.Entry.CanWrite(authz) {
|
2019-04-10 21:27:28 +00:00
|
|
|
return acl.ErrPermissionDenied
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
args.Op = structs.ConfigEntryDelete
|
|
|
|
resp, err := c.srv.raftApply(structs.ConfigEntryRequestType, args)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if respErr, ok := resp.(error); ok {
|
|
|
|
return respErr
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ResolveServiceConfig
|
|
|
|
func (c *ConfigEntry) ResolveServiceConfig(args *structs.ServiceConfigRequest, reply *structs.ServiceConfigResponse) error {
|
2020-01-24 15:04:58 +00:00
|
|
|
if err := c.srv.validateEnterpriseRequest(&args.EnterpriseMeta, false); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-07 06:38:08 +00:00
|
|
|
if done, err := c.srv.forward("ConfigEntry.ResolveServiceConfig", args, args, reply); done {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer metrics.MeasureSince([]string{"config_entry", "resolve_service_config"}, time.Now())
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
var authzContext acl.AuthorizerContext
|
|
|
|
authz, err := c.srv.ResolveTokenAndDefaultMeta(args.Token, &args.EnterpriseMeta, &authzContext)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-01-24 15:04:58 +00:00
|
|
|
if authz != nil && authz.ServiceRead(args.Name, &authzContext) != acl.Allow {
|
2019-04-07 06:38:08 +00:00
|
|
|
return acl.ErrPermissionDenied
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.srv.blockingQuery(
|
|
|
|
&args.QueryOptions,
|
|
|
|
&reply.QueryMeta,
|
|
|
|
func(ws memdb.WatchSet, state *state.Store) error {
|
2019-08-14 14:08:46 +00:00
|
|
|
reply.Reset()
|
|
|
|
|
2019-06-18 00:52:01 +00:00
|
|
|
reply.MeshGateway.Mode = structs.MeshGatewayModeDefault
|
2019-04-07 06:38:08 +00:00
|
|
|
// Pass the WatchSet to both the service and proxy config lookups. If either is updated
|
|
|
|
// during the blocking query, this function will be rerun and these state store lookups
|
|
|
|
// will both be current.
|
2020-01-24 15:04:58 +00:00
|
|
|
index, serviceEntry, err := state.ConfigEntry(ws, structs.ServiceDefaults, args.Name, &args.EnterpriseMeta)
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2019-04-23 06:39:02 +00:00
|
|
|
var serviceConf *structs.ServiceConfigEntry
|
|
|
|
var ok bool
|
|
|
|
if serviceEntry != nil {
|
|
|
|
serviceConf, ok = serviceEntry.(*structs.ServiceConfigEntry)
|
|
|
|
if !ok {
|
|
|
|
return fmt.Errorf("invalid service config type %T", serviceEntry)
|
|
|
|
}
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
// Use the default enterprise meta to look up the global proxy defaults. In the future we may allow per-namespace proxy-defaults
|
|
|
|
// but not yet.
|
|
|
|
_, proxyEntry, err := state.ConfigEntry(ws, structs.ProxyDefaults, structs.ProxyConfigGlobal, structs.DefaultEnterpriseMeta())
|
2019-04-07 06:38:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2019-04-23 06:39:02 +00:00
|
|
|
var proxyConf *structs.ProxyConfigEntry
|
|
|
|
if proxyEntry != nil {
|
|
|
|
proxyConf, ok = proxyEntry.(*structs.ProxyConfigEntry)
|
|
|
|
if !ok {
|
|
|
|
return fmt.Errorf("invalid proxy config type %T", proxyEntry)
|
|
|
|
}
|
2019-05-02 13:12:21 +00:00
|
|
|
// Apply the proxy defaults to the sidecar's proxy config
|
2019-05-06 16:09:59 +00:00
|
|
|
mapCopy, err := copystructure.Copy(proxyConf.Config)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to copy global proxy-defaults: %v", err)
|
|
|
|
}
|
|
|
|
reply.ProxyConfig = mapCopy.(map[string]interface{})
|
2019-06-18 00:52:01 +00:00
|
|
|
reply.MeshGateway = proxyConf.MeshGateway
|
2019-09-26 02:55:52 +00:00
|
|
|
reply.Expose = proxyConf.Expose
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
|
2019-05-01 23:39:31 +00:00
|
|
|
reply.Index = index
|
|
|
|
|
2019-06-18 00:52:01 +00:00
|
|
|
if serviceConf != nil {
|
2019-09-26 02:55:52 +00:00
|
|
|
if serviceConf.Expose.Checks {
|
|
|
|
reply.Expose.Checks = true
|
|
|
|
}
|
|
|
|
if len(serviceConf.Expose.Paths) >= 1 {
|
|
|
|
reply.Expose.Paths = serviceConf.Expose.Paths
|
|
|
|
}
|
2019-06-18 00:52:01 +00:00
|
|
|
if serviceConf.MeshGateway.Mode != structs.MeshGatewayModeDefault {
|
|
|
|
reply.MeshGateway.Mode = serviceConf.MeshGateway.Mode
|
|
|
|
}
|
|
|
|
if serviceConf.Protocol != "" {
|
|
|
|
if reply.ProxyConfig == nil {
|
|
|
|
reply.ProxyConfig = make(map[string]interface{})
|
|
|
|
}
|
|
|
|
reply.ProxyConfig["protocol"] = serviceConf.Protocol
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
}
|
2019-05-01 23:39:31 +00:00
|
|
|
|
2019-08-05 19:52:35 +00:00
|
|
|
// Extract the global protocol from proxyConf for upstream configs.
|
|
|
|
var proxyConfGlobalProtocol interface{}
|
|
|
|
if proxyConf != nil && proxyConf.Config != nil {
|
|
|
|
proxyConfGlobalProtocol = proxyConf.Config["protocol"]
|
|
|
|
}
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
// map the legacy request structure using only service names
|
|
|
|
// to the new ServiceID type.
|
|
|
|
upstreamIDs := args.UpstreamIDs
|
|
|
|
legacyUpstreams := false
|
|
|
|
|
|
|
|
if len(upstreamIDs) == 0 {
|
|
|
|
legacyUpstreams = true
|
|
|
|
|
|
|
|
upstreamIDs = make([]structs.ServiceID, 0)
|
|
|
|
for _, upstream := range args.Upstreams {
|
|
|
|
upstreamIDs = append(upstreamIDs, structs.NewServiceID(upstream, &args.EnterpriseMeta))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
usConfigs := make(map[structs.ServiceID]map[string]interface{})
|
|
|
|
|
|
|
|
for _, upstream := range upstreamIDs {
|
|
|
|
_, upstreamEntry, err := state.ConfigEntry(ws, structs.ServiceDefaults, upstream.ID, &upstream.EnterpriseMeta)
|
2019-05-01 23:39:31 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
var upstreamConf *structs.ServiceConfigEntry
|
|
|
|
var ok bool
|
|
|
|
if upstreamEntry != nil {
|
|
|
|
upstreamConf, ok = upstreamEntry.(*structs.ServiceConfigEntry)
|
|
|
|
if !ok {
|
|
|
|
return fmt.Errorf("invalid service config type %T", upstreamEntry)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-08-05 19:52:35 +00:00
|
|
|
// Fallback to proxyConf global protocol.
|
|
|
|
protocol := proxyConfGlobalProtocol
|
2020-05-21 21:08:39 +00:00
|
|
|
if upstreamConf != nil && upstreamConf.Protocol != "" {
|
2019-08-05 19:52:35 +00:00
|
|
|
protocol = upstreamConf.Protocol
|
|
|
|
}
|
|
|
|
|
2019-05-01 23:39:31 +00:00
|
|
|
// Nothing to configure if a protocol hasn't been set.
|
2019-08-05 19:52:35 +00:00
|
|
|
if protocol == nil {
|
2019-05-01 23:39:31 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2020-01-24 15:04:58 +00:00
|
|
|
usConfigs[upstream] = map[string]interface{}{
|
|
|
|
"protocol": protocol,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// don't allocate the slices just to not fill them
|
|
|
|
if len(usConfigs) == 0 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if legacyUpstreams {
|
2019-05-01 23:39:31 +00:00
|
|
|
if reply.UpstreamConfigs == nil {
|
|
|
|
reply.UpstreamConfigs = make(map[string]map[string]interface{})
|
|
|
|
}
|
2020-01-24 15:04:58 +00:00
|
|
|
for us, conf := range usConfigs {
|
|
|
|
reply.UpstreamConfigs[us.ID] = conf
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if reply.UpstreamIDConfigs == nil {
|
|
|
|
reply.UpstreamIDConfigs = make(structs.UpstreamConfigs, 0, len(usConfigs))
|
|
|
|
}
|
|
|
|
|
|
|
|
for us, conf := range usConfigs {
|
|
|
|
reply.UpstreamIDConfigs = append(reply.UpstreamIDConfigs, structs.UpstreamConfig{Upstream: us, Config: conf})
|
2019-05-01 23:39:31 +00:00
|
|
|
}
|
2019-04-07 06:38:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|