From 61199172d0a34964f9897f5000e25c30c68b5a81 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 22 Jan 2023 18:31:14 +0100 Subject: [PATCH] add support for monitoring and reloading externally provided TLS certs Signed-off-by: Nicola Murino --- docs/full-configuration.md | 6 +- internal/acme/acme.go | 4 +- internal/common/protocol_test.go | 1 + internal/common/tlsutils.go | 83 ++++++++++++++++++++++++---- internal/common/tlsutils_test.go | 63 +++++++++++++++++++++ internal/service/service_portable.go | 2 - main.go | 3 + 7 files changed, 144 insertions(+), 18 deletions(-) diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 6a1e4356..a9ff5cba 100644 --- a/docs/full-configuration.md +++ b/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. diff --git a/internal/acme/acme.go b/internal/acme/acme.go index dd4caddb..b52f506b 100644 --- a/internal/acme/acme.go +++ b/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 { diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 5a99b2a6..7edb45e7 100644 --- a/internal/common/protocol_test.go +++ b/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 { diff --git a/internal/common/tlsutils.go b/internal/common/tlsutils.go index 6b3a6340..e49a04aa 100644 --- a/internal/common/tlsutils.go +++ b/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 } diff --git a/internal/common/tlsutils_test.go b/internal/common/tlsutils_test.go index 7f7492ed..e276ba0f 100644 --- a/internal/common/tlsutils_test.go +++ b/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) } diff --git a/internal/service/service_portable.go b/internal/service/service_portable.go index fa58d1bb..e34c3323 100644 --- a/internal/service/service_portable.go +++ b/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) diff --git a/main.go b/main.go index a372b2ab..67821d3c 100644 --- a/main.go +++ b/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() }