httpd: allow to reload the https certificate without restarting the service

HTTPS certificate can be reloaded on demand sending a SIGHUP signal on
Unix based systems and a "paramchange" request to the running service on
Windows
This commit is contained in:
Nicola Murino 2020-02-04 23:21:33 +01:00
parent 9359669cd4
commit 5bfaae9202
7 changed files with 134 additions and 11 deletions

View file

@ -187,7 +187,7 @@ The `sftpgo` configuration file contains the following sections:
- `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons
- `auth_user_file`, string. Path to a file used to store usernames and password for basic authentication. This can be an absolute path or a path relative to the config dir. We support HTTP basic authentication and the file format must conform to the one generated using the Apache tool. The supported password formats are bcrypt (`$2y$` prefix) and md5 crypt (`$apr1$` prefix). If empty HTTP authentication is disabled.
- `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 the the server will expect HTTPS connections.
- `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 the the server will expect 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.
Here is a full example showing the default config in JSON format:

View file

@ -7,6 +7,7 @@
package httpd
import (
"crypto/tls"
"fmt"
"net/http"
"path/filepath"
@ -42,6 +43,7 @@ var (
dataProvider dataprovider.Provider
backupsPath string
httpAuth httpAuthProvider
certMgr *certManager
)
// Conf httpd daemon configuration
@ -63,7 +65,9 @@ type Conf struct {
// If empty HTTP authentication is disabled
AuthUserFile string `json:"auth_user_file" mapstructure:"auth_user_file"`
// If files containing a certificate and matching private key for the server are provided the server will expect
// HTTPS connections
// 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.
CertificateFile string `json:"certificate_file" mapstructure:"certificate_file"`
CertificateKeyFile string `json:"certificate_key_file" mapstructure:"certificate_key_file"`
}
@ -103,11 +107,26 @@ func (c Conf) Initialize(configDir string) error {
MaxHeaderBytes: 1 << 16, // 64KB
}
if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
return httpServer.ListenAndServeTLS(certificateFile, certificateKeyFile)
certMgr, err = newCertManager(certificateFile, certificateKeyFile)
if err != nil {
return err
}
config := &tls.Config{
GetCertificate: certMgr.GetCertificateFunc(),
}
httpServer.TLSConfig = config
return httpServer.ListenAndServeTLS("", "")
}
return httpServer.ListenAndServe()
}
// ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
func ReloadTLSCertificate() {
if certMgr != nil {
certMgr.loadCertificate()
}
}
func getConfigPath(name, configDir string) string {
if len(name) > 0 && !filepath.IsAbs(name) {
return filepath.Join(configDir, name)

View file

@ -53,6 +53,29 @@ const (
webUserPath = "/web/user"
webConnectionsPath = "/web/connections"
configDir = ".."
httpsCert = `-----BEGIN CERTIFICATE-----
MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu
dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDAyMDQwOTUzMDRaFw0zMDAyMDEw
OTUzMDRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYD
VQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAARCjRMqJ85rzMC998X5z761nJ+xL3bkmGVqWvrJ51t5OxV0v25NsOgR82CA
NXUgvhVYs7vNFN+jxtb2aj6Xg+/2G/BNxkaFspIVCzgWkxiz7XE4lgUwX44FCXZM
3+JeUbKjUzBRMB0GA1UdDgQWBBRhLw+/o3+Z02MI/d4tmaMui9W16jAfBgNVHSME
GDAWgBRhLw+/o3+Z02MI/d4tmaMui9W16jAPBgNVHRMBAf8EBTADAQH/MAoGCCqG
SM49BAMCA2kAMGYCMQDqLt2lm8mE+tGgtjDmtFgdOcI72HSbRQ74D5rYTzgST1rY
/8wTi5xl8TiFUyLMUsICMQC5ViVxdXbhuG7gX6yEqSkMKZICHpO8hqFwOD/uaFVI
dV4vKmHUzwK/eIx+8Ay3neE=
-----END CERTIFICATE-----`
httpsKey = `-----BEGIN EC PARAMETERS-----
BgUrgQQAIg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCfMNsN6miEE3rVyUPwElfiJSWaR5huPCzUenZOfJT04GAcQdWvEju3
UM2lmBLIXpGgBwYFK4EEACKhZANiAARCjRMqJ85rzMC998X5z761nJ+xL3bkmGVq
WvrJ51t5OxV0v25NsOgR82CANXUgvhVYs7vNFN+jxtb2aj6Xg+/2G/BNxkaFspIV
CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI=
-----END EC PRIVATE KEY-----`
)
var (
@ -93,14 +116,28 @@ func TestMain(m *testing.M) {
httpd.SetDataProvider(dataProvider)
go func() {
go func() {
if err := httpdConf.Initialize(configDir); err != nil {
logger.Error(logSender, "", "could not start HTTP server: %v", err)
}
}()
if err := httpdConf.Initialize(configDir); err != nil {
logger.Error(logSender, "", "could not start HTTP server: %v", err)
}
}()
waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
// now start an https server
certPath := filepath.Join(os.TempDir(), "test.crt")
keyPath := filepath.Join(os.TempDir(), "test.key")
ioutil.WriteFile(certPath, []byte(httpsCert), 0666)
ioutil.WriteFile(keyPath, []byte(httpsKey), 0666)
httpdConf.BindPort = 8443
httpdConf.CertificateFile = certPath
httpdConf.CertificateKeyFile = keyPath
go func() {
if err := httpdConf.Initialize(configDir); err != nil {
logger.Error(logSender, "", "could not start HTTPS server: %v", err)
}
}()
waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
httpd.ReloadTLSCertificate()
testServer = httptest.NewServer(httpd.GetHTTPRouter())
defer testServer.Close()
@ -109,6 +146,8 @@ func TestMain(m *testing.M) {
os.Remove(logfilePath)
os.RemoveAll(backupsPath)
os.RemoveAll(credentialsPath)
os.Remove(certPath)
os.Remove(keyPath)
os.Exit(exitCode)
}
@ -932,6 +971,17 @@ func TestLoaddataMode(t *testing.T) {
os.Remove(backupFilePath)
}
func TestHTTPSConnection(t *testing.T) {
client := &http.Client{
Timeout: 5 * time.Second,
}
_, err := client.Get("https://localhost:8443" + metricsPath)
if err == nil || (!strings.Contains(err.Error(), "certificate is not valid") &&
!strings.Contains(err.Error(), "certificate signed by unknown authority")) {
t.Errorf("unexpected error: %v", err)
}
}
// test using mock http server
func TestBasicUserHandlingMock(t *testing.T) {

50
httpd/tlsutils.go Normal file
View file

@ -0,0 +1,50 @@
package httpd
import (
"crypto/tls"
"sync"
"github.com/drakkan/sftpgo/logger"
)
type certManager struct {
cert *tls.Certificate
certPath string
keyPath string
lock *sync.RWMutex
}
func (m *certManager) loadCertificate() error {
newCert, err := tls.LoadX509KeyPair(m.certPath, m.keyPath)
if err != nil {
logger.Warn(logSender, "", "unable to load https certificate: %v", err)
return err
}
logger.Debug(logSender, "", "https certificate successfully loaded")
m.lock.Lock()
defer m.lock.Unlock()
m.cert = &newCert
return nil
}
func (m *certManager) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
m.lock.RLock()
defer m.lock.RUnlock()
return m.cert, nil
}
}
func newCertManager(certificateFile, certificateKeyFile string) (*certManager, error) {
manager := &certManager{
cert: nil,
certPath: certificateFile,
keyPath: certificateKeyFile,
lock: new(sync.RWMutex),
}
err := manager.loadCertificate()
if err != nil {
return nil, err
}
return manager, nil
}

View file

@ -114,14 +114,14 @@ func (s *Service) Start() error {
logger.DebugToConsole("HTTP server not started, disabled in config file")
}
}
if s.PortableMode != 1 {
registerSigHup()
}
return nil
}
// Wait blocks until the service exits
func (s *Service) Wait() {
if s.PortableMode != 1 {
registerSigHup()
}
<-s.Shutdown
}

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/httpd"
"github.com/drakkan/sftpgo/logger"
"golang.org/x/sys/windows/svc"
@ -83,6 +84,7 @@ loop:
case svc.ParamChange:
logger.Debug(logSender, "", "Received reload request")
dataprovider.ReloadConfig()
httpd.ReloadTLSCertificate()
default:
continue loop
}

View file

@ -8,6 +8,7 @@ import (
"syscall"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/httpd"
"github.com/drakkan/sftpgo/logger"
)
@ -18,6 +19,7 @@ func registerSigHup() {
for range sig {
logger.Debug(logSender, "", "Received reload request")
dataprovider.ReloadConfig()
httpd.ReloadTLSCertificate()
}
}()
}