add support for monitoring and reloading externally provided TLS certs
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
3ce4d04b27
commit
61199172d0
7 changed files with 144 additions and 18 deletions
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
main.go
3
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()
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue