Ver código fonte

add support for monitoring and reloading externally provided TLS certs

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 anos atrás
pai
commit
61199172d0

+ 3 - 3
docs/full-configuration.md

@@ -183,7 +183,7 @@ The configuration file contains the following sections:
   - `hash_support`, integer. Set to `1` to enable FTP commands that allow to calculate the hash value of files. These FTP commands will be enabled: `HASH`, `XCRC`, `MD5/XMD5`, `XSHA/XSHA1`, `XSHA256`, `XSHA512`. Please keep in mind that to calculate the hash we need to read the whole file, for remote backends this means downloading the file, for the encrypted backend this means decrypting the file. Default `0`.
   - `combine_support`, integer. Set to 1 to enable support for the non standard `COMB` FTP command. Combine is only supported for local filesystem, for cloud backends it has no advantage as it will download the partial files and will upload the combined one. Cloud backends natively support multipart uploads. Default `0`.
   - `certificate_file`, string. Certificate for FTPS. This can be an absolute path or a path relative to the config dir.
-  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and the private key are required to enable explicit and implicit TLS. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
+  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and the private key are required to enable explicit and implicit TLS. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. If the integrated ACME protocol is disabled and therefore the certificates are not automatically renewed and reloaded, the certificates are polled for changes every 8 hours.
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
   - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
 
@@ -208,7 +208,7 @@ The configuration file contains the following sections:
   - `certificate_file`, string. Certificate for WebDAV over HTTPS. This can be an absolute path or a path relative to the config dir.
   - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and a private key are required to enable HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
-  - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
+  - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. If the integrated ACME protocol is disabled and therefore the certificates are not automatically renewed and reloaded, the certificates are polled for changes every 8 hours.
   - `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values.
     - `enabled`, boolean, set to true to enable CORS.
     - `allowed_origins`, list of strings.
@@ -354,7 +354,7 @@ The configuration file contains the following sections:
   - `openapi_path`, string. Path to the directory that contains the OpenAPI schema and the default renderer. This can be an absolute path or a path relative to the config dir. If empty the OpenAPI schema and the renderer will not be served regardless of the `render_openapi` directive
   - `web_root`, string.  Defines a base URL for the web admin and client interfaces. If empty web admin and client resources will be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored
   - `certificate_file`, string. Certificate for HTTPS. This can be an absolute path or a path relative to the config dir.
-  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, you can enable HTTPS for the configured bindings. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
+  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, you can enable HTTPS for the configured bindings. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. If the integrated ACME protocol is disabled and therefore the certificates are not automatically renewed and reloaded, the certificates are polled for changes every 8 hours.
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
   - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `signing_passphrase`, string. Passphrase to use to derive the signing key for JWT and CSRF tokens. If empty a random signing key will be generated each time SFTPGo starts. If you set a signing passphrase you should consider rotating it periodically for added security.

+ 2 - 2
internal/acme/acme.go

@@ -143,6 +143,7 @@ type Configuration struct {
 
 // Initialize validates and set the configuration
 func (c *Configuration) Initialize(configDir string, checkRenew bool) error {
+	common.SetCertAutoReloadMode(true)
 	config = nil
 	setLogMode(checkRenew)
 	c.checkDomains()
@@ -200,6 +201,7 @@ func (c *Configuration) Initialize(configDir string, checkRenew bool) error {
 	}
 
 	acmeLog(logger.LevelInfo, "configured domains: %+v", c.Domains)
+	common.SetCertAutoReloadMode(false)
 	config = c
 	if checkRenew {
 		return startScheduler()
@@ -679,9 +681,7 @@ func stopScheduler() {
 func startScheduler() error {
 	stopScheduler()
 
-	rand.Seed(time.Now().UnixNano())
 	randSecs := rand.Intn(59)
-
 	scheduler = cron.New()
 	_, err := scheduler.AddFunc(fmt.Sprintf("@every 12h0m%ds", randSecs), renewCertificates)
 	if err != nil {

+ 1 - 0
internal/common/protocol_test.go

@@ -114,6 +114,7 @@ func TestMain(m *testing.M) {
 		logger.WarnToConsole("error initializing common: %v", err)
 		os.Exit(1)
 	}
+	common.SetCertAutoReloadMode(true)
 
 	err = dataprovider.Initialize(providerConf, configDir, true)
 	if err != nil {

+ 72 - 11
internal/common/tlsutils.go

@@ -20,6 +20,8 @@ import (
 	"crypto/x509/pkix"
 	"errors"
 	"fmt"
+	"io/fs"
+	"math/rand"
 	"os"
 	"path/filepath"
 	"sync"
@@ -34,6 +36,17 @@ const (
 	DefaultTLSKeyPaidID = "default"
 )
 
+var (
+	certAutoReload bool
+)
+
+// SetCertAutoReloadMode sets if the certificate must be monitored for changes and
+// automatically reloaded
+func SetCertAutoReloadMode(val bool) {
+	certAutoReload = val
+	logger.Debug(logSender, "", "is certificate monitoring enabled? %t", certAutoReload)
+}
+
 // TLSKeyPair defines the paths and the unique identifier for a TLS key pair
 type TLSKeyPair struct {
 	Cert string
@@ -49,7 +62,9 @@ type CertManager struct {
 	sync.RWMutex
 	caCertificates    []string
 	caRevocationLists []string
+	monitorList       []string
 	certs             map[string]*tls.Certificate
+	certsInfo         map[string]fs.FileInfo
 	rootCAs           *x509.CertPool
 	crls              []*pkix.CertificateList
 }
@@ -77,15 +92,18 @@ func (m *CertManager) loadCertificates() error {
 		}
 		newCert, err := tls.LoadX509KeyPair(keyPair.Cert, keyPair.Key)
 		if err != nil {
-			logger.Warn(m.logSender, "", "unable to load X509 key pair, cert file %#v key file %#v error: %v",
+			logger.Warn(m.logSender, "", "unable to load X509 key pair, cert file %q key file %q error: %v",
 				keyPair.Cert, keyPair.Key, err)
 			return err
 		}
 		if _, ok := certs[keyPair.ID]; ok {
-			return fmt.Errorf("TLS certificate with id %#v is duplicated", keyPair.ID)
+			return fmt.Errorf("TLS certificate with id %q is duplicated", keyPair.ID)
 		}
-		logger.Debug(m.logSender, "", "TLS certificate %#v successfully loaded, id %v", keyPair.Cert, keyPair.ID)
+		logger.Debug(m.logSender, "", "TLS certificate %q successfully loaded, id %v", keyPair.Cert, keyPair.ID)
 		certs[keyPair.ID] = &newCert
+		if !util.Contains(m.monitorList, keyPair.Cert) {
+			m.monitorList = append(m.monitorList, keyPair.Cert)
+		}
 	}
 
 	m.Lock()
@@ -116,7 +134,7 @@ func (m *CertManager) IsRevoked(crt *x509.Certificate, caCrt *x509.Certificate)
 	defer m.RUnlock()
 
 	if crt == nil || caCrt == nil {
-		logger.Warn(m.logSender, "", "unable to verify crt %v ca crt %v", crt, caCrt)
+		logger.Warn(m.logSender, "", "unable to verify crt %v, ca crt %v", crt, caCrt)
 		return len(m.crls) > 0
 	}
 
@@ -143,24 +161,27 @@ func (m *CertManager) LoadCRLs() error {
 
 	for _, revocationList := range m.caRevocationLists {
 		if !util.IsFileInputValid(revocationList) {
-			return fmt.Errorf("invalid root CA revocation list %#v", revocationList)
+			return fmt.Errorf("invalid root CA revocation list %q", revocationList)
 		}
 		if revocationList != "" && !filepath.IsAbs(revocationList) {
 			revocationList = filepath.Join(m.configDir, revocationList)
 		}
 		crlBytes, err := os.ReadFile(revocationList)
 		if err != nil {
-			logger.Warn(m.logSender, "unable to read revocation list %#v", revocationList)
+			logger.Warn(m.logSender, "", "unable to read revocation list %q", revocationList)
 			return err
 		}
 		crl, err := x509.ParseCRL(crlBytes)
 		if err != nil {
-			logger.Warn(m.logSender, "unable to parse revocation list %#v", revocationList)
+			logger.Warn(m.logSender, "", "unable to parse revocation list %q", revocationList)
 			return err
 		}
 
-		logger.Debug(m.logSender, "", "CRL %#v successfully loaded", revocationList)
+		logger.Debug(m.logSender, "", "CRL %q successfully loaded", revocationList)
 		crls = append(crls, crl)
+		if !util.Contains(m.monitorList, revocationList) {
+			m.monitorList = append(m.monitorList, revocationList)
+		}
 	}
 
 	m.Lock()
@@ -190,7 +211,7 @@ func (m *CertManager) LoadRootCAs() error {
 
 	for _, rootCA := range m.caCertificates {
 		if !util.IsFileInputValid(rootCA) {
-			return fmt.Errorf("invalid root CA certificate %#v", rootCA)
+			return fmt.Errorf("invalid root CA certificate %q", rootCA)
 		}
 		if rootCA != "" && !filepath.IsAbs(rootCA) {
 			rootCA = filepath.Join(m.configDir, rootCA)
@@ -200,9 +221,9 @@ func (m *CertManager) LoadRootCAs() error {
 			return err
 		}
 		if rootCAs.AppendCertsFromPEM(crt) {
-			logger.Debug(m.logSender, "", "TLS certificate authority %#v successfully loaded", rootCA)
+			logger.Debug(m.logSender, "", "TLS certificate authority %q successfully loaded", rootCA)
 		} else {
-			err := fmt.Errorf("unable to load TLS certificate authority %#v", rootCA)
+			err := fmt.Errorf("unable to load TLS certificate authority %q", rootCA)
 			logger.Warn(m.logSender, "", "%v", err)
 			return err
 		}
@@ -227,11 +248,45 @@ func (m *CertManager) SetCARevocationLists(caRevocationLists []string) {
 	m.caRevocationLists = util.RemoveDuplicates(caRevocationLists, true)
 }
 
+func (m *CertManager) monitor() {
+	certsInfo := make(map[string]fs.FileInfo)
+
+	for _, crt := range m.monitorList {
+		info, err := os.Stat(crt)
+		if err != nil {
+			logger.Warn(m.logSender, "", "unable to stat certificate to monitor %q: %v", crt, err)
+			return
+		}
+		certsInfo[crt] = info
+	}
+
+	m.Lock()
+
+	isChanged := false
+	for k, oldInfo := range m.certsInfo {
+		newInfo, ok := certsInfo[k]
+		if ok {
+			if newInfo.Size() != oldInfo.Size() || newInfo.ModTime() != oldInfo.ModTime() {
+				logger.Debug(m.logSender, "", "change detected for certificate %q, reload required", k)
+				isChanged = true
+			}
+		}
+	}
+	m.certsInfo = certsInfo
+
+	m.Unlock()
+
+	if isChanged {
+		m.Reload() //nolint:errcheck
+	}
+}
+
 // NewCertManager creates a new certificate manager
 func NewCertManager(keyPairs []TLSKeyPair, configDir, logSender string) (*CertManager, error) {
 	manager := &CertManager{
 		keyPairs:  keyPairs,
 		certs:     make(map[string]*tls.Certificate),
+		certsInfo: make(map[string]fs.FileInfo),
 		configDir: configDir,
 		logSender: logSender,
 	}
@@ -239,5 +294,11 @@ func NewCertManager(keyPairs []TLSKeyPair, configDir, logSender string) (*CertMa
 	if err != nil {
 		return nil, err
 	}
+	if certAutoReload {
+		randSecs := rand.Intn(59)
+		manager.monitor()
+		_, err := eventScheduler.AddFunc(fmt.Sprintf("@every 8h0m%ds", randSecs), manager.monitor)
+		util.PanicOnError(err)
+	}
 	return manager, nil
 }

+ 63 - 0
internal/common/tlsutils_test.go

@@ -20,8 +20,10 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 const (
@@ -282,6 +284,7 @@ uaPG3o9EBDf5eFKi76o+pVtqxrwhY88M/Yw0ykEA6Nf7RCo2ucdemg==
 )
 
 func TestLoadCertificate(t *testing.T) {
+	startEventScheduler()
 	caCrtPath := filepath.Join(os.TempDir(), "testca.crt")
 	caCrlPath := filepath.Join(os.TempDir(), "testcrl.crt")
 	certPath := filepath.Join(os.TempDir(), "test.crt")
@@ -427,9 +430,11 @@ func TestLoadCertificate(t *testing.T) {
 
 	err = os.Remove(caCrtPath)
 	assert.NoError(t, err)
+	stopEventScheduler()
 }
 
 func TestLoadInvalidCert(t *testing.T) {
+	startEventScheduler()
 	certManager, err := NewCertManager(nil, configDir, logSenderTest)
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "no key pairs defined")
@@ -458,4 +463,62 @@ func TestLoadInvalidCert(t *testing.T) {
 		assert.Contains(t, err.Error(), "TLS certificate without ID")
 	}
 	assert.Nil(t, certManager)
+	stopEventScheduler()
+}
+
+func TestCertificateMonitor(t *testing.T) {
+	startEventScheduler()
+	defer stopEventScheduler()
+
+	certPath := filepath.Join(os.TempDir(), "test.crt")
+	keyPath := filepath.Join(os.TempDir(), "test.key")
+	caCrlPath := filepath.Join(os.TempDir(), "testcrl.crt")
+	err := os.WriteFile(certPath, []byte(serverCert), os.ModePerm)
+	assert.NoError(t, err)
+	err = os.WriteFile(keyPath, []byte(serverKey), os.ModePerm)
+	assert.NoError(t, err)
+	err = os.WriteFile(caCrlPath, []byte(caCRL), os.ModePerm)
+	assert.NoError(t, err)
+
+	keyPairs := []TLSKeyPair{
+		{
+			Cert: certPath,
+			Key:  keyPath,
+			ID:   DefaultTLSKeyPaidID,
+		},
+	}
+	certManager, err := NewCertManager(keyPairs, configDir, logSenderTest)
+	assert.NoError(t, err)
+	assert.Len(t, certManager.monitorList, 1)
+	require.Len(t, certManager.certsInfo, 1)
+	info := certManager.certsInfo[certPath]
+	require.NotNil(t, info)
+	certManager.SetCARevocationLists([]string{caCrlPath})
+	err = certManager.LoadCRLs()
+	assert.NoError(t, err)
+	assert.Len(t, certManager.monitorList, 2)
+	certManager.monitor()
+	require.Len(t, certManager.certsInfo, 2)
+
+	err = os.Remove(certPath)
+	assert.NoError(t, err)
+	certManager.monitor()
+
+	time.Sleep(100 * time.Millisecond)
+	err = os.WriteFile(certPath, []byte(serverCert), os.ModePerm)
+	assert.NoError(t, err)
+	certManager.monitor()
+	require.Len(t, certManager.certsInfo, 2)
+	newInfo := certManager.certsInfo[certPath]
+	require.NotNil(t, newInfo)
+	assert.Equal(t, info.Size(), newInfo.Size())
+	assert.NotEqual(t, info.ModTime(), newInfo.ModTime())
+
+	err = os.Remove(caCrlPath)
+	assert.NoError(t, err)
+
+	err = os.Remove(certPath)
+	assert.NoError(t, err)
+	err = os.Remove(keyPath)
+	assert.NoError(t, err)
 }

+ 0 - 2
internal/service/service_portable.go

@@ -21,7 +21,6 @@ import (
 	"fmt"
 	"math/rand"
 	"strings"
-	"time"
 
 	"github.com/sftpgo/sdk"
 
@@ -42,7 +41,6 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
 	if s.PortableMode != 1 {
 		return fmt.Errorf("service is not configured for portable mode")
 	}
-	rand.Seed(time.Now().UnixNano())
 	err := config.LoadConfig(s.ConfigDir, s.ConfigFile)
 	if err != nil {
 		fmt.Printf("error loading configuration file: %v using defaults\n", err)

+ 3 - 0
main.go

@@ -21,6 +21,8 @@ package main // import "github.com/drakkan/sftpgo"
 
 import (
 	"fmt"
+	"math/rand"
+	"time"
 
 	"go.uber.org/automaxprocs/maxprocs"
 
@@ -32,5 +34,6 @@ func main() {
 		fmt.Printf("error setting max procs: %v\n", err)
 		undo()
 	}
+	rand.Seed(time.Now().UnixNano())
 	cmd.Execute()
 }