diff --git a/README.md b/README.md index dd15f54d1112a36079a33298d12639169ff8bc02..971eb5fa1b60bde55552580eb452b5825e44f1b3 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 b0e51ddacebbd34e05674b408050a57a3cad3e76..ad899fdf5e79973a9597ead35c82056d86a23f36 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 d75089143ad0725cef04cedc39ddb675cabd9f6c..4eb30264332fa1f487c722eb937d2b6d1721d19f 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 0000000000000000000000000000000000000000..950a54f0a8df4081f00b2f592d6c02275c894091 --- /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 ed3dc2370d09bd48c1ecc26a2129d90bf696299d..fa50ec9dfba1dc1bb4816b8152cb38ad253af587 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 2b95324d285c08e25e17d822cb0e97b1750f3534..eb4c5e19dc6069c08abf935cc92edb90c08b4069 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 1805a99ea91c484145a91062d1ee1e003a7f588f..078c30da5c7094db833dc31738b0f1805e942f3e 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() } }() }