add support for monitoring and reloading externally provided TLS certs

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-01-22 18:31:14 +01:00
parent 3ce4d04b27
commit 61199172d0
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
7 changed files with 144 additions and 18 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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()
}