2017-04-17 20:47:53 +00:00
|
|
|
package server
|
2016-01-13 21:45:49 +00:00
|
|
|
|
|
|
|
import (
|
2016-08-16 15:26:10 +00:00
|
|
|
"context"
|
2016-01-29 19:34:17 +00:00
|
|
|
"encoding/json"
|
2016-02-26 14:29:53 +00:00
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"os/signal"
|
2017-04-04 09:36:23 +00:00
|
|
|
"sync"
|
2016-02-26 14:29:53 +00:00
|
|
|
"time"
|
2016-02-19 22:55:23 +00:00
|
|
|
|
2019-07-10 07:26:04 +00:00
|
|
|
"github.com/containous/traefik/pkg/config/dynamic"
|
2019-07-15 15:04:04 +00:00
|
|
|
"github.com/containous/traefik/pkg/config/runtime"
|
2019-03-15 08:42:03 +00:00
|
|
|
"github.com/containous/traefik/pkg/config/static"
|
|
|
|
"github.com/containous/traefik/pkg/log"
|
|
|
|
"github.com/containous/traefik/pkg/metrics"
|
|
|
|
"github.com/containous/traefik/pkg/middlewares/accesslog"
|
|
|
|
"github.com/containous/traefik/pkg/middlewares/requestdecorator"
|
|
|
|
"github.com/containous/traefik/pkg/provider"
|
|
|
|
"github.com/containous/traefik/pkg/safe"
|
|
|
|
"github.com/containous/traefik/pkg/server/middleware"
|
|
|
|
"github.com/containous/traefik/pkg/tls"
|
|
|
|
"github.com/containous/traefik/pkg/tracing"
|
|
|
|
"github.com/containous/traefik/pkg/tracing/jaeger"
|
|
|
|
"github.com/containous/traefik/pkg/types"
|
2016-01-13 21:45:49 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// Server is the reverse-proxy/load-balancer engine
|
|
|
|
type Server struct {
|
2019-03-14 08:30:04 +00:00
|
|
|
entryPointsTCP TCPEntryPoints
|
2019-07-10 07:26:04 +00:00
|
|
|
configurationChan chan dynamic.Message
|
|
|
|
configurationValidatedChan chan dynamic.Message
|
2018-11-14 09:18:03 +00:00
|
|
|
signals chan os.Signal
|
|
|
|
stopChan chan bool
|
|
|
|
currentConfigurations safe.Safe
|
2019-07-10 07:26:04 +00:00
|
|
|
providerConfigUpdateMap map[string]chan dynamic.Message
|
2018-11-14 09:18:03 +00:00
|
|
|
accessLoggerMiddleware *accesslog.Handler
|
|
|
|
tracer *tracing.Tracing
|
|
|
|
routinesPool *safe.Pool
|
|
|
|
defaultRoundTripper http.RoundTripper
|
|
|
|
metricsRegistry metrics.Registry
|
|
|
|
provider provider.Provider
|
2019-07-10 07:26:04 +00:00
|
|
|
configurationListeners []func(dynamic.Configuration)
|
2018-11-14 09:18:03 +00:00
|
|
|
requestDecorator *requestdecorator.RequestDecorator
|
2018-11-27 16:42:04 +00:00
|
|
|
providersThrottleDuration time.Duration
|
2019-03-14 08:30:04 +00:00
|
|
|
tlsManager *tls.Manager
|
2018-11-14 09:18:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// RouteAppenderFactory the route appender factory interface
|
|
|
|
type RouteAppenderFactory interface {
|
2019-07-15 15:04:04 +00:00
|
|
|
NewAppender(ctx context.Context, middlewaresBuilder *middleware.Builder, runtimeConfiguration *runtime.Configuration) types.RouteAppender
|
2018-04-23 13:30:03 +00:00
|
|
|
}
|
|
|
|
|
2019-06-27 22:16:04 +00:00
|
|
|
func setupTracing(conf *static.Tracing) tracing.Backend {
|
|
|
|
var backend tracing.Backend
|
|
|
|
|
|
|
|
if conf.Jaeger != nil {
|
|
|
|
backend = conf.Jaeger
|
|
|
|
}
|
|
|
|
|
|
|
|
if conf.Zipkin != nil {
|
|
|
|
if backend != nil {
|
|
|
|
log.WithoutContext().Error("Multiple tracing backend are not supported: cannot create Zipkin backend.")
|
|
|
|
} else {
|
|
|
|
backend = conf.Zipkin
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if conf.DataDog != nil {
|
|
|
|
if backend != nil {
|
|
|
|
log.WithoutContext().Error("Multiple tracing backend are not supported: cannot create DataDog backend.")
|
|
|
|
} else {
|
|
|
|
backend = conf.DataDog
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if conf.Instana != nil {
|
|
|
|
if backend != nil {
|
|
|
|
log.WithoutContext().Error("Multiple tracing backend are not supported: cannot create Instana backend.")
|
|
|
|
} else {
|
|
|
|
backend = conf.Instana
|
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
}
|
2019-06-27 22:16:04 +00:00
|
|
|
|
|
|
|
if conf.Haystack != nil {
|
|
|
|
if backend != nil {
|
|
|
|
log.WithoutContext().Error("Multiple tracing backend are not supported: cannot create Haystack backend.")
|
|
|
|
} else {
|
|
|
|
backend = conf.Haystack
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if backend == nil {
|
|
|
|
log.WithoutContext().Debug("Could not initialize tracing, use Jaeger by default")
|
|
|
|
backend := &jaeger.Config{}
|
|
|
|
backend.SetDefaults()
|
|
|
|
}
|
|
|
|
|
|
|
|
return backend
|
2018-11-14 09:18:03 +00:00
|
|
|
}
|
|
|
|
|
2016-01-13 21:45:49 +00:00
|
|
|
// NewServer returns an initialized Server.
|
2019-03-14 08:30:04 +00:00
|
|
|
func NewServer(staticConfiguration static.Configuration, provider provider.Provider, entryPoints TCPEntryPoints, tlsManager *tls.Manager) *Server {
|
2018-06-11 09:36:03 +00:00
|
|
|
server := &Server{}
|
2016-01-13 21:45:49 +00:00
|
|
|
|
2018-01-29 13:58:03 +00:00
|
|
|
server.provider = provider
|
2019-03-14 08:30:04 +00:00
|
|
|
server.entryPointsTCP = entryPoints
|
2019-07-10 07:26:04 +00:00
|
|
|
server.configurationChan = make(chan dynamic.Message, 100)
|
|
|
|
server.configurationValidatedChan = make(chan dynamic.Message, 100)
|
2016-01-29 19:34:17 +00:00
|
|
|
server.signals = make(chan os.Signal, 1)
|
2016-04-13 18:36:23 +00:00
|
|
|
server.stopChan = make(chan bool, 1)
|
2017-08-11 10:04:58 +00:00
|
|
|
server.configureSignals()
|
2019-07-10 07:26:04 +00:00
|
|
|
currentConfigurations := make(dynamic.Configurations)
|
2016-04-13 18:36:23 +00:00
|
|
|
server.currentConfigurations.Set(currentConfigurations)
|
2019-07-10 07:26:04 +00:00
|
|
|
server.providerConfigUpdateMap = make(map[string]chan dynamic.Message)
|
2019-03-14 08:30:04 +00:00
|
|
|
server.tlsManager = tlsManager
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
if staticConfiguration.Providers != nil {
|
|
|
|
server.providersThrottleDuration = time.Duration(staticConfiguration.Providers.ProvidersThrottleDuration)
|
|
|
|
}
|
|
|
|
|
|
|
|
transport, err := createHTTPTransport(staticConfiguration.ServersTransport)
|
2018-11-14 09:18:03 +00:00
|
|
|
if err != nil {
|
2018-11-27 16:42:04 +00:00
|
|
|
log.WithoutContext().Errorf("Could not configure HTTP Transport, fallbacking on default transport: %v", err)
|
2018-11-14 09:18:03 +00:00
|
|
|
server.defaultRoundTripper = http.DefaultTransport
|
|
|
|
} else {
|
|
|
|
server.defaultRoundTripper = transport
|
|
|
|
}
|
2018-06-11 09:36:03 +00:00
|
|
|
|
2016-08-18 11:03:10 +00:00
|
|
|
server.routinesPool = safe.NewPool(context.Background())
|
2018-06-11 09:36:03 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
if staticConfiguration.Tracing != nil {
|
2019-06-27 22:16:04 +00:00
|
|
|
tracingBackend := setupTracing(staticConfiguration.Tracing)
|
|
|
|
if tracingBackend != nil {
|
|
|
|
server.tracer, err = tracing.NewTracing(staticConfiguration.Tracing.ServiceName, staticConfiguration.Tracing.SpanNameLimit, tracingBackend)
|
|
|
|
if err != nil {
|
|
|
|
log.WithoutContext().Warnf("Unable to create tracer: %v", err)
|
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
}
|
2018-06-11 09:36:03 +00:00
|
|
|
}
|
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
server.requestDecorator = requestdecorator.New(staticConfiguration.HostResolver)
|
2017-08-23 18:46:03 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
server.metricsRegistry = registerMetricClients(staticConfiguration.Metrics)
|
2016-01-13 21:45:49 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
if staticConfiguration.AccessLog != nil {
|
2017-05-25 11:25:53 +00:00
|
|
|
var err error
|
2018-11-27 16:42:04 +00:00
|
|
|
server.accessLoggerMiddleware, err = accesslog.NewHandler(staticConfiguration.AccessLog)
|
2017-05-25 11:25:53 +00:00
|
|
|
if err != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
log.WithoutContext().Warnf("Unable to create access logger : %v", err)
|
2017-05-25 11:25:53 +00:00
|
|
|
}
|
2017-05-22 19:39:29 +00:00
|
|
|
}
|
2016-01-13 21:45:49 +00:00
|
|
|
return server
|
|
|
|
}
|
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
// Start starts the server and Stop/Close it when context is Done
|
|
|
|
func (s *Server) Start(ctx context.Context) {
|
|
|
|
go func() {
|
|
|
|
defer s.Close()
|
|
|
|
<-ctx.Done()
|
|
|
|
logger := log.FromContext(ctx)
|
|
|
|
logger.Info("I have to go...")
|
|
|
|
logger.Info("Stopping server gracefully")
|
|
|
|
s.Stop()
|
|
|
|
}()
|
|
|
|
|
2019-03-14 08:30:04 +00:00
|
|
|
s.startTCPServers()
|
2017-11-24 18:18:03 +00:00
|
|
|
s.routinesPool.Go(func(stop chan bool) {
|
|
|
|
s.listenProviders(stop)
|
2016-03-31 16:57:08 +00:00
|
|
|
})
|
2017-11-24 18:18:03 +00:00
|
|
|
s.routinesPool.Go(func(stop chan bool) {
|
|
|
|
s.listenConfigurations(stop)
|
2016-03-31 16:57:08 +00:00
|
|
|
})
|
2018-01-29 13:58:03 +00:00
|
|
|
s.startProvider()
|
2018-09-06 12:24:03 +00:00
|
|
|
s.routinesPool.Go(func(stop chan bool) {
|
|
|
|
s.listenSignals(stop)
|
|
|
|
})
|
2016-10-25 15:59:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Wait blocks until server is shutted down.
|
2017-11-24 18:18:03 +00:00
|
|
|
func (s *Server) Wait() {
|
|
|
|
<-s.stopChan
|
2016-01-13 21:45:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Stop stops the server
|
2017-11-24 18:18:03 +00:00
|
|
|
func (s *Server) Stop() {
|
2018-11-14 09:18:03 +00:00
|
|
|
defer log.WithoutContext().Info("Server stopped")
|
|
|
|
|
2017-03-09 22:27:09 +00:00
|
|
|
var wg sync.WaitGroup
|
2019-03-14 08:30:04 +00:00
|
|
|
for epn, ep := range s.entryPointsTCP {
|
2017-03-09 22:27:09 +00:00
|
|
|
wg.Add(1)
|
2019-03-14 08:30:04 +00:00
|
|
|
go func(entryPointName string, entryPoint *TCPEntryPoint) {
|
2018-11-27 16:42:04 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.EntryPointName, entryPointName))
|
2017-03-09 22:27:09 +00:00
|
|
|
defer wg.Done()
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
entryPoint.Shutdown(ctx)
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2018-11-27 16:42:04 +00:00
|
|
|
log.FromContext(ctx).Debugf("Entry point %s closed", entryPointName)
|
|
|
|
}(epn, ep)
|
2016-01-29 19:34:17 +00:00
|
|
|
}
|
2017-03-09 22:27:09 +00:00
|
|
|
wg.Wait()
|
2017-11-24 18:18:03 +00:00
|
|
|
s.stopChan <- true
|
2016-01-13 21:45:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Close destroys the server
|
2017-11-24 18:18:03 +00:00
|
|
|
func (s *Server) Close() {
|
2018-03-14 12:14:03 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
2016-07-13 15:50:57 +00:00
|
|
|
go func(ctx context.Context) {
|
|
|
|
<-ctx.Done()
|
|
|
|
if ctx.Err() == context.Canceled {
|
|
|
|
return
|
|
|
|
} else if ctx.Err() == context.DeadlineExceeded {
|
2018-03-14 12:14:03 +00:00
|
|
|
panic("Timeout while stopping traefik, killing instance ✝")
|
2016-07-13 15:50:57 +00:00
|
|
|
}
|
|
|
|
}(ctx)
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2017-08-23 18:46:03 +00:00
|
|
|
stopMetricsClients()
|
2017-11-24 18:18:03 +00:00
|
|
|
s.routinesPool.Cleanup()
|
|
|
|
close(s.configurationChan)
|
|
|
|
close(s.configurationValidatedChan)
|
|
|
|
signal.Stop(s.signals)
|
|
|
|
close(s.signals)
|
|
|
|
close(s.stopChan)
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2017-11-24 18:18:03 +00:00
|
|
|
if s.accessLoggerMiddleware != nil {
|
|
|
|
if err := s.accessLoggerMiddleware.Close(); err != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
log.WithoutContext().Errorf("Could not close the access log file: %s", err)
|
2017-05-22 19:39:29 +00:00
|
|
|
}
|
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
|
|
|
|
if s.tracer != nil {
|
|
|
|
s.tracer.Close()
|
|
|
|
}
|
|
|
|
|
2016-07-13 15:50:57 +00:00
|
|
|
cancel()
|
2016-01-13 21:45:49 +00:00
|
|
|
}
|
|
|
|
|
2019-03-14 08:30:04 +00:00
|
|
|
func (s *Server) startTCPServers() {
|
2018-11-27 16:42:04 +00:00
|
|
|
// Use an empty configuration in order to initialize the default handlers with internal routes
|
2019-07-10 07:26:04 +00:00
|
|
|
routers := s.loadConfigurationTCP(dynamic.Configurations{})
|
2019-03-14 08:30:04 +00:00
|
|
|
for entryPointName, router := range routers {
|
|
|
|
s.entryPointsTCP[entryPointName].switchRouter(router)
|
2018-11-27 16:42:04 +00:00
|
|
|
}
|
2017-01-12 13:34:54 +00:00
|
|
|
|
2019-03-14 08:30:04 +00:00
|
|
|
for entryPointName, serverEntryPoint := range s.entryPointsTCP {
|
2018-11-27 16:42:04 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.EntryPointName, entryPointName))
|
2019-03-14 08:30:04 +00:00
|
|
|
go serverEntryPoint.startTCP(ctx)
|
2017-07-08 10:21:14 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-11-24 18:18:03 +00:00
|
|
|
func (s *Server) listenProviders(stop chan bool) {
|
2016-01-13 21:45:49 +00:00
|
|
|
for {
|
2016-04-13 18:36:23 +00:00
|
|
|
select {
|
|
|
|
case <-stop:
|
|
|
|
return
|
2017-11-24 18:18:03 +00:00
|
|
|
case configMsg, ok := <-s.configurationChan:
|
2018-11-26 17:52:03 +00:00
|
|
|
if !ok {
|
2016-04-13 18:36:23 +00:00
|
|
|
return
|
|
|
|
}
|
2018-11-26 17:52:03 +00:00
|
|
|
if configMsg.Configuration != nil {
|
|
|
|
s.preLoadConfiguration(configMsg)
|
|
|
|
} else {
|
|
|
|
log.Debugf("Received nil configuration from provider %q, skipping.", configMsg.ProviderName)
|
|
|
|
}
|
2017-11-09 11:16:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-03-05 19:54:04 +00:00
|
|
|
// AddListener adds a new listener function used when new configuration is provided
|
2019-07-10 07:26:04 +00:00
|
|
|
func (s *Server) AddListener(listener func(dynamic.Configuration)) {
|
2018-03-05 19:54:04 +00:00
|
|
|
if s.configurationListeners == nil {
|
2019-07-10 07:26:04 +00:00
|
|
|
s.configurationListeners = make([]func(dynamic.Configuration), 0)
|
2018-03-05 19:54:04 +00:00
|
|
|
}
|
|
|
|
s.configurationListeners = append(s.configurationListeners, listener)
|
|
|
|
}
|
|
|
|
|
2018-01-29 13:58:03 +00:00
|
|
|
func (s *Server) startProvider() {
|
|
|
|
jsonConf, err := json.Marshal(s.provider)
|
|
|
|
if err != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
log.WithoutContext().Debugf("Unable to marshal provider configuration %T: %v", s.provider, err)
|
2016-01-13 21:45:49 +00:00
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
|
|
|
|
log.WithoutContext().Infof("Starting provider %T %s", s.provider, jsonConf)
|
2018-01-29 13:58:03 +00:00
|
|
|
currentProvider := s.provider
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2018-01-29 13:58:03 +00:00
|
|
|
safe.Go(func() {
|
2018-07-11 07:08:03 +00:00
|
|
|
err := currentProvider.Provide(s.configurationChan, s.routinesPool)
|
2018-01-29 13:58:03 +00:00
|
|
|
if err != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
log.WithoutContext().Errorf("Error starting provider %T: %s", s.provider, err)
|
2018-01-29 13:58:03 +00:00
|
|
|
}
|
|
|
|
})
|
2016-01-13 21:45:49 +00:00
|
|
|
}
|
|
|
|
|
2018-01-26 10:58:03 +00:00
|
|
|
func registerMetricClients(metricsConfig *types.Metrics) metrics.Registry {
|
|
|
|
if metricsConfig == nil {
|
|
|
|
return metrics.NewVoidRegistry()
|
|
|
|
}
|
2017-07-20 22:26:43 +00:00
|
|
|
|
2018-01-31 18:10:04 +00:00
|
|
|
var registries []metrics.Registry
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2017-08-23 18:46:03 +00:00
|
|
|
if metricsConfig.Prometheus != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.MetricsProviderName, "prometheus"))
|
|
|
|
prometheusRegister := metrics.RegisterPrometheus(ctx, metricsConfig.Prometheus)
|
2018-08-06 12:58:03 +00:00
|
|
|
if prometheusRegister != nil {
|
|
|
|
registries = append(registries, prometheusRegister)
|
2018-11-14 09:18:03 +00:00
|
|
|
log.FromContext(ctx).Debug("Configured Prometheus metrics")
|
2018-08-06 12:58:03 +00:00
|
|
|
}
|
2017-08-23 18:46:03 +00:00
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2019-07-01 09:30:05 +00:00
|
|
|
if metricsConfig.DataDog != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.MetricsProviderName, "datadog"))
|
2019-07-01 09:30:05 +00:00
|
|
|
registries = append(registries, metrics.RegisterDatadog(ctx, metricsConfig.DataDog))
|
2018-11-14 09:18:03 +00:00
|
|
|
log.FromContext(ctx).Debugf("Configured DataDog metrics: pushing to %s once every %s",
|
2019-07-01 09:30:05 +00:00
|
|
|
metricsConfig.DataDog.Address, metricsConfig.DataDog.PushInterval)
|
2017-08-23 18:46:03 +00:00
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2017-08-23 18:46:03 +00:00
|
|
|
if metricsConfig.StatsD != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.MetricsProviderName, "statsd"))
|
|
|
|
registries = append(registries, metrics.RegisterStatsd(ctx, metricsConfig.StatsD))
|
|
|
|
log.FromContext(ctx).Debugf("Configured StatsD metrics: pushing to %s once every %s",
|
|
|
|
metricsConfig.StatsD.Address, metricsConfig.StatsD.PushInterval)
|
2017-04-18 06:22:06 +00:00
|
|
|
}
|
2018-11-14 09:18:03 +00:00
|
|
|
|
2017-11-08 14:14:03 +00:00
|
|
|
if metricsConfig.InfluxDB != nil {
|
2018-11-14 09:18:03 +00:00
|
|
|
ctx := log.With(context.Background(), log.Str(log.MetricsProviderName, "influxdb"))
|
|
|
|
registries = append(registries, metrics.RegisterInfluxDB(ctx, metricsConfig.InfluxDB))
|
|
|
|
log.FromContext(ctx).Debugf("Configured InfluxDB metrics: pushing to %s once every %s",
|
|
|
|
metricsConfig.InfluxDB.Address, metricsConfig.InfluxDB.PushInterval)
|
2017-11-08 14:14:03 +00:00
|
|
|
}
|
2017-04-18 06:22:06 +00:00
|
|
|
|
2018-01-26 10:58:03 +00:00
|
|
|
return metrics.NewMultiRegistry(registries)
|
2017-07-20 22:26:43 +00:00
|
|
|
}
|
|
|
|
|
2017-08-23 18:46:03 +00:00
|
|
|
func stopMetricsClients() {
|
|
|
|
metrics.StopDatadog()
|
|
|
|
metrics.StopStatsd()
|
2017-11-08 14:14:03 +00:00
|
|
|
metrics.StopInfluxDB()
|
2017-07-20 22:26:43 +00:00
|
|
|
}
|