diff --git a/cmd/config/config.go b/cmd/config/config.go index 120d902fb..3fd8cf37d 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -18,6 +18,7 @@ package config import ( "crypto/aes" + "path/filepath" "github.com/kelseyhightower/envconfig" ) @@ -39,9 +40,11 @@ var ( // EverestConfig stores the configuration for the application. type EverestConfig struct { - DSN string `default:"postgres://admin:pwd@127.0.0.1:5432/postgres?sslmode=disable" envconfig:"DSN"` - HTTPPort int `default:"8080" envconfig:"HTTP_PORT"` - Verbose bool `default:"false" envconfig:"VERBOSE"` + DSN string `default:"postgres://admin:pwd@127.0.0.1:5432/postgres?sslmode=disable" envconfig:"DSN"` + // DEPRECATED: Use ListenPort instead. + HTTPPort int `envconfig:"HTTP_PORT"` + ListenPort int `default:"8080" envconfig:"PORT"` + Verbose bool `default:"false" envconfig:"VERBOSE"` // TelemetryURL Everest telemetry endpoint. TelemetryURL string `envconfig:"TELEMETRY_URL"` // TelemetryInterval Everest telemetry sending frequency. @@ -54,6 +57,9 @@ type EverestConfig struct { CreateSessionRateLimit int `default:"1" envconfig:"CREATE_SESSION_RATE_LIMIT"` // VersionServiceURL contains the URL of the version service. VersionServiceURL string `default:"https://check.percona.com" envconfig:"VERSION_SERVICE_URL"` + // TLSCertsPath contains the path to the directory with the TLS certificates. + // Setting this will enable HTTPS on ListenPort. + TLSCertsPath string `envconfig:"TLS_CERTS_PATH"` } // ParseConfig parses env vars and fills EverestConfig. @@ -71,5 +77,9 @@ func ParseConfig() (*EverestConfig, error) { c.TelemetryInterval = TelemetryInterval } + if c.TLSCertsPath != "" { + c.TLSCertsPath = filepath.Clean(c.TLSCertsPath) + } + return c, nil } diff --git a/cmd/main.go b/cmd/main.go index a1873711c..fa7903649 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -64,7 +64,7 @@ func main() { } go func() { - err := server.Start() + err := server.Start(tCtx) if err != nil && !errors.Is(err, http.ErrServerClosed) { l.Fatal(err) } diff --git a/go.mod b/go.mod index 059e88aaa..a8eff4cc1 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.3.0 github.com/fatih/color v1.18.0 + github.com/fsnotify/fsnotify v1.7.0 github.com/getkin/kin-openapi v0.128.0 github.com/go-logr/zapr v1.3.0 github.com/golang-jwt/jwt/v5 v5.2.1 diff --git a/internal/server/everest.go b/internal/server/everest.go index 15cbd00c1..0c727c1fb 100644 --- a/internal/server/everest.go +++ b/internal/server/everest.go @@ -18,11 +18,13 @@ package server import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" "io/fs" "net/http" + "path" "slices" "github.com/getkin/kin-openapi/openapi3filter" @@ -42,6 +44,7 @@ import ( k8shandler "github.com/percona/everest/internal/server/handlers/k8s" rbachandler "github.com/percona/everest/internal/server/handlers/rbac" valhandler "github.com/percona/everest/internal/server/handlers/validation" + "github.com/percona/everest/pkg/certwatcher" "github.com/percona/everest/pkg/common" "github.com/percona/everest/pkg/kubernetes" "github.com/percona/everest/pkg/oidc" @@ -67,6 +70,11 @@ func NewEverestServer(ctx context.Context, c *config.EverestConfig, l *zap.Sugar return nil, errors.Join(err, errors.New("failed creating Kubernetes client")) } + if c.HTTPPort != 0 { + l.Warn("HTTP_PORT is deprecated, use PORT instead") + c.ListenPort = c.HTTPPort + } + echoServer := echo.New() echoServer.Use(echomiddleware.RateLimiter(echomiddleware.NewRateLimiterMemoryStore(rate.Limit(c.APIRequestsRateLimit)))) middleware, store := sessionRateLimiter(c.CreateSessionRateLimit) @@ -293,8 +301,34 @@ func newSkipperFunc() (echomiddleware.Skipper, error) { } // Start starts everest server. -func (e *EverestServer) Start() error { - return e.echo.Start(fmt.Sprintf("0.0.0.0:%d", e.config.HTTPPort)) +func (e *EverestServer) Start(ctx context.Context) error { + addr := fmt.Sprintf("0.0.0.0:%d", e.config.ListenPort) + if e.config.TLSCertsPath != "" { + return e.startHTTPS(ctx, addr) + } + return e.echo.Start(addr) +} + +func (e *EverestServer) startHTTPS(ctx context.Context, addr string) error { + tlsKeyPath := path.Join(e.config.TLSCertsPath, "tls.key") + tlsCertPath := path.Join(e.config.TLSCertsPath, "tls.crt") + + watcher, err := certwatcher.New(e.l, tlsCertPath, tlsKeyPath) + if err != nil { + return fmt.Errorf("failed to create cert watcher: %w", err) + } + if err := watcher.Start(ctx); err != nil { + return fmt.Errorf("failed to start cert watcher: %w", err) + } + + e.echo.TLSServer = &http.Server{ + Addr: addr, + TLSConfig: &tls.Config{ + // server periodically calls GetCertificate and reloads the certificate. + GetCertificate: watcher.GetCertificate, + }, + } + return e.echo.StartServer(e.echo.TLSServer) } // Shutdown gracefully stops the Everest server. diff --git a/pkg/certwatcher/watcher.go b/pkg/certwatcher/watcher.go new file mode 100644 index 000000000..17f9f1981 --- /dev/null +++ b/pkg/certwatcher/watcher.go @@ -0,0 +1,96 @@ +// everest +// Copyright (C) 2023 Percona LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certwatcher + +import ( + "context" + "crypto/tls" + "sync" + + "github.com/fsnotify/fsnotify" + "go.uber.org/zap" +) + +type certWatcher struct { + cert *tls.Certificate + certFile, keyFile string + log *zap.SugaredLogger + + mutex sync.RWMutex +} + +// GetCertificate returns the certificate. +func (w *certWatcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + w.mutex.RLock() + defer w.mutex.RUnlock() + + certCopy := *w.cert + return &certCopy, nil +} + +// New returns a new cert watcher. +func New(log *zap.SugaredLogger, certFile, keyFile string) (*certWatcher, error) { + w := &certWatcher{ + certFile: certFile, + keyFile: keyFile, + log: log, + } + if err := w.loadCertificate(); err != nil { + return nil, err + } + return w, nil +} + +func (w *certWatcher) loadCertificate() error { + w.mutex.Lock() + defer w.mutex.Unlock() + + cert, err := tls.LoadX509KeyPair(w.certFile, w.keyFile) + if err != nil { + return err + } + w.cert = &cert + return nil +} + +// Start the certificate files watcher until the context is closed. +func (w *certWatcher) Start(ctx context.Context) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + watcher.Add(w.certFile) + watcher.Add(w.keyFile) + + w.log.Infow("Watching certificate files for changes", "certFile", w.certFile, "keyFile", w.keyFile) + + go func() { + for { + select { + case <-ctx.Done(): + if err := watcher.Close(); err != nil { + w.log.Errorw("Failed to close watcher", "error", err) + } + case <-watcher.Events: + w.log.Info("Certificate updated") + if err := w.loadCertificate(); err != nil { + w.log.Errorw("Failed to reload certificate", "error", err) + } + } + } + }() + return nil +}