diff --git a/README.md b/README.md index dd15f54d..971eb5fa 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/httpd/httpd.go b/httpd/httpd.go index b0e51dda..ad899fdf 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -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) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index d7508914..4eb30264 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -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) { diff --git a/httpd/tlsutils.go b/httpd/tlsutils.go new file mode 100644 index 00000000..950a54f0 --- /dev/null +++ b/httpd/tlsutils.go @@ -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 +} diff --git a/service/service.go b/service/service.go index ed3dc237..fa50ec9d 100644 --- a/service/service.go +++ b/service/service.go @@ -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 } diff --git a/service/service_windows.go b/service/service_windows.go index 2b95324d..eb4c5e19 100644 --- a/service/service_windows.go +++ b/service/service_windows.go @@ -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 } diff --git a/service/sighup_unix.go b/service/sighup_unix.go index 1805a99e..078c30da 100644 --- a/service/sighup_unix.go +++ b/service/sighup_unix.go @@ -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() } }() }