Reload TLS certificates on SIGHUP

* Add utils.CertStorage for holding cert data that can be updated
  at runtime.
* Add utils.NewTLSListener() to have a central place to control e.g. TLS
  MinVersion across different servers.
* Add WithTLS() to webserver code so it looks more like the other
  servers.

Fixes #1299
This commit is contained in:
Patrik Lundin
2026-01-22 14:12:07 +01:00
parent 1d30567129
commit 0c520a30cf
6 changed files with 123 additions and 36 deletions
+41 -10
View File
@@ -16,7 +16,6 @@ package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
@@ -733,11 +732,12 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("TLS cert specified without key file")
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
cs := utils.NewCertStorage()
err := cs.SetCertificate(certFile, keyFile)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, s3api.WithTLS(cert))
opts = append(opts, s3api.WithTLS(cs))
}
if admPort == "" {
opts = append(opts, s3api.WithAdminServer())
@@ -873,11 +873,12 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("TLS cert specified without key file")
}
cert, err := tls.LoadX509KeyPair(admCertFile, admKeyFile)
cs := utils.NewCertStorage()
err = cs.SetCertificate(admCertFile, admKeyFile)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, s3api.WithAdminSrvTLS(cert))
opts = append(opts, s3api.WithAdminSrvTLS(cs))
}
if quiet {
opts = append(opts, s3api.WithAdminQuiet())
@@ -891,6 +892,8 @@ func runGateway(ctx context.Context, be backend.Backend) error {
var webSrv *webui.Server
webuiSSLEnabled := false
webTLSCert := ""
webTLSKey := ""
if webuiAddr != "" {
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
@@ -904,8 +907,7 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("webui port must be between 0 and 65535")
}
webTLSCert := ""
webTLSKey := ""
var webOpts []webui.Option
if !webuiNoTLS {
// WebUI can either use explicitly provided TLS files or reuse the
// gateway's TLS files by default.
@@ -923,6 +925,14 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("webui TLS cert specified without key file")
}
webuiSSLEnabled = true
cs := utils.NewCertStorage()
err := cs.SetCertificate(webTLSCert, webTLSKey)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
webOpts = append(webOpts, webui.WithTLS(cs))
}
}
@@ -945,7 +955,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
}
}
var webOpts []webui.Option
if quiet {
webOpts = append(webOpts, webui.WithQuiet())
}
@@ -955,8 +964,6 @@ func runGateway(ctx context.Context, be backend.Backend) error {
Gateways: gateways,
AdminGateways: adminGateways,
Region: region,
TLSCert: webTLSCert,
TLSKey: webTLSKey,
}, webOpts...)
}
@@ -1003,6 +1010,30 @@ Loop:
break Loop
}
}
if certFile != "" && keyFile != "" {
err = srv.CertStorage.SetCertificate(certFile, keyFile)
if err != nil {
debuglogger.InernalError(fmt.Errorf("srv cert reload failed: %w", err))
} else {
fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", certFile, keyFile)
}
}
if admPort != "" && admCertFile != "" && admKeyFile != "" {
err = admSrv.CertStorage.SetCertificate(admCertFile, admKeyFile)
if err != nil {
debuglogger.InernalError(fmt.Errorf("admSrv cert reload failed: %w", err))
} else {
fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", admCertFile, admKeyFile)
}
}
if webSrv != nil && webTLSCert != "" && webTLSKey != "" {
err := webSrv.CertStorage.SetCertificate(webTLSCert, webTLSKey)
if err != nil {
debuglogger.InernalError(fmt.Errorf("webSrv cert reload failed: %w", err))
} else {
fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey)
}
}
}
}
saveErr := err
+11 -7
View File
@@ -15,8 +15,6 @@
package s3api
import (
"crypto/tls"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
@@ -25,6 +23,7 @@ import (
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3log"
)
@@ -33,7 +32,7 @@ type S3AdminServer struct {
backend backend.Backend
router *S3AdminRouter
port string
cert *tls.Certificate
CertStorage *utils.CertStorage
quiet bool
debug bool
corsAllowOrigin string
@@ -88,8 +87,8 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, r
type AdminOpt func(s *S3AdminServer)
func WithAdminSrvTLS(cert tls.Certificate) AdminOpt {
return func(s *S3AdminServer) { s.cert = &cert }
func WithAdminSrvTLS(cs *utils.CertStorage) AdminOpt {
return func(s *S3AdminServer) { s.CertStorage = cs }
}
// WithQuiet silences default logging output
@@ -109,8 +108,13 @@ func WithAdminCORSAllowOrigin(origin string) AdminOpt {
}
func (sa *S3AdminServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
if err != nil {
return err
}
return sa.app.Listener(ln)
}
return sa.app.Listen(sa.port)
}
+10 -6
View File
@@ -15,7 +15,6 @@
package s3api
import (
"crypto/tls"
"errors"
"net/http"
"strings"
@@ -45,7 +44,7 @@ type S3ApiServer struct {
app *fiber.App
backend backend.Backend
port string
cert *tls.Certificate
CertStorage *utils.CertStorage
quiet bool
readonly bool
keepAlive bool
@@ -133,8 +132,8 @@ func New(
type Option func(*S3ApiServer)
// WithTLS sets TLS Credentials
func WithTLS(cert tls.Certificate) Option {
return func(s *S3ApiServer) { s.cert = &cert }
func WithTLS(cs *utils.CertStorage) Option {
return func(s *S3ApiServer) { s.CertStorage = cs }
}
// WithAdminServer runs admin endpoints with the gateway in the same network
@@ -173,8 +172,13 @@ func WithCORSAllowOrigin(origin string) Option {
}
func (sa *S3ApiServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
if err != nil {
return err
}
return sa.app.Listener(ln)
}
return sa.app.Listen(sa.port)
}
+6 -6
View File
@@ -15,11 +15,11 @@
package s3api
import (
"crypto/tls"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/utils"
)
func TestS3ApiServer_Serve(t *testing.T) {
@@ -42,11 +42,11 @@ func TestS3ApiServer_Serve(t *testing.T) {
name: "Serve-invalid-address-with-certificate",
wantErr: true,
sa: &S3ApiServer{
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
cert: &tls.Certificate{},
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
CertStorage: &utils.CertStorage{},
},
},
}
+38
View File
@@ -16,11 +16,13 @@ package utils
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@@ -910,3 +912,39 @@ func GenerateObjectLocation(ctx *fiber.Ctx, virtualDomain, bucket, object string
obj,
)
}
type CertStorage struct {
cert atomic.Pointer[tls.Certificate]
}
func NewCertStorage() *CertStorage {
return &CertStorage{}
}
func (cs *CertStorage) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
return cs.cert.Load(), nil
}
func (cs *CertStorage) SetCertificate(certFile string, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("unable to set certificate: %w", err)
}
cs.cert.Store(&cert)
return nil
}
func NewTLSListener(network string, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: getCertificateFunc,
}
ln, err := net.Listen(network, address)
if err != nil {
return nil, err
}
return tls.NewListener(ln, config), nil
}
+17 -7
View File
@@ -23,6 +23,7 @@ import (
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/versity/versitygw/s3api/utils"
)
// ServerConfig holds the server configuration
@@ -31,16 +32,15 @@ type ServerConfig struct {
Gateways []string // S3 API gateways
AdminGateways []string // Admin API gateways (defaults to Gateways if empty)
Region string
TLSCert string
TLSKey string
CORSOrigin string
}
// Server is the main GUI server
type Server struct {
app *fiber.App
config *ServerConfig
quiet bool
app *fiber.App
CertStorage *utils.CertStorage
config *ServerConfig
quiet bool
}
// Option sets various options for NewServer()
@@ -51,6 +51,11 @@ func WithQuiet() Option {
return func(s *Server) { s.quiet = true }
}
// WithTLS sets TLS Credentials
func WithTLS(cs *utils.CertStorage) Option {
return func(s *Server) { s.CertStorage = cs }
}
// NewServer creates a new GUI server instance
func NewServer(cfg *ServerConfig, opts ...Option) *Server {
app := fiber.New(fiber.Config{
@@ -127,8 +132,13 @@ func (s *Server) Serve() error {
}
// Check if TLS is configured
if s.config.TLSCert != "" && s.config.TLSKey != "" {
return s.app.ListenTLS(addr, s.config.TLSCert, s.config.TLSKey)
if s.CertStorage != nil {
ln, err := utils.NewTLSListener(s.app.Config().Network, addr, s.CertStorage.GetCertificate)
if err != nil {
return err
}
return s.app.Listener(ln)
}
return s.app.Listen(addr)