Explorar o código

telemetry server: add optional https and authentication

Nicola Murino %!s(int64=4) %!d(string=hai) anos
pai
achega
bcf0fa073e

+ 1 - 0
.github/workflows/development.yml

@@ -61,6 +61,7 @@ jobs:
           go test -v -p 1 -timeout 8m ./sftpd -covermode=atomic
           go test -v -p 1 -timeout 2m ./ftpd -covermode=atomic
           go test -v -p 1 -timeout 2m ./webdavd -covermode=atomic
+          go test -v -p 1 -timeout 2m ./telemetry -covermode=atomic
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: bolt
           SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'

+ 0 - 1
cmd/portable.go

@@ -138,7 +138,6 @@ Please take a look at the usage below to customize the serving parameters`,
 				LogMaxAge:     defaultLogMaxAge,
 				LogCompress:   defaultLogCompress,
 				LogVerbose:    portableLogVerbose,
-				Profiler:      defaultProfiler,
 				Shutdown:      make(chan bool),
 				PortableMode:  1,
 				PortableUser: dataprovider.User{

+ 0 - 14
cmd/root.go

@@ -28,8 +28,6 @@ const (
 	logCompressKey           = "log_compress"
 	logVerboseFlag           = "log-verbose"
 	logVerboseKey            = "log_verbose"
-	profilerFlag             = "profiler"
-	profilerKey              = "profiler"
 	loadDataFromFlag         = "loaddata-from"
 	loadDataFromKey          = "loaddata_from"
 	loadDataModeFlag         = "loaddata-mode"
@@ -46,7 +44,6 @@ const (
 	defaultLogMaxAge         = 28
 	defaultLogCompress       = false
 	defaultLogVerbose        = true
-	defaultProfiler          = false
 	defaultLoadDataFrom      = ""
 	defaultLoadDataMode      = 1
 	defaultLoadDataQuotaScan = 0
@@ -62,7 +59,6 @@ var (
 	logMaxAge         int
 	logCompress       bool
 	logVerbose        bool
-	profiler          bool
 	loadDataFrom      string
 	loadDataMode      int
 	loadDataQuotaScan int
@@ -183,16 +179,6 @@ using SFTPGO_LOG_VERBOSE env var too.
 `)
 	viper.BindPFlag(logVerboseKey, cmd.Flags().Lookup(logVerboseFlag)) //nolint:errcheck
 
-	viper.SetDefault(profilerKey, defaultProfiler)
-	viper.BindEnv(profilerKey, "SFTPGO_PROFILER") //nolint:errcheck
-	cmd.Flags().BoolVarP(&profiler, profilerFlag, "p", viper.GetBool(profilerKey),
-		`Enable the built-in profiler. The profiler will
-be accessible via HTTP/HTTPS using the base URL
-"/debug/pprof/".
-This flag can be set using SFTPGO_PROFILER env
-var too.`)
-	viper.BindPFlag(profilerKey, cmd.Flags().Lookup(profilerFlag)) //nolint:errcheck
-
 	viper.SetDefault(loadDataFromKey, defaultLoadDataFrom)
 	viper.BindEnv(loadDataFromKey, "SFTPGO_LOADDATA_FROM") //nolint:errcheck
 	cmd.Flags().StringVar(&loadDataFrom, loadDataFromFlag, viper.GetString(loadDataFromKey),

+ 0 - 1
cmd/serve.go

@@ -33,7 +33,6 @@ Please take a look at the usage below to customize the startup options`,
 				LoadDataMode:      loadDataMode,
 				LoadDataQuotaScan: loadDataQuotaScan,
 				LoadDataClean:     loadDataClean,
-				Profiler:          profiler,
 				Shutdown:          make(chan bool),
 			}
 			if err := service.Start(); err == nil {

+ 134 - 0
common/httpauth.go

@@ -0,0 +1,134 @@
+package common
+
+import (
+	"encoding/csv"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/GehirnInc/crypt/apr1_crypt"
+	"github.com/GehirnInc/crypt/md5_crypt"
+	"golang.org/x/crypto/bcrypt"
+
+	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/utils"
+)
+
+const (
+	// HTTPAuthenticationHeader defines the HTTP authentication
+	HTTPAuthenticationHeader = "WWW-Authenticate"
+	md5CryptPwdPrefix        = "$1$"
+	apr1CryptPwdPrefix       = "$apr1$"
+)
+
+var (
+	bcryptPwdPrefixes = []string{"$2a$", "$2$", "$2x$", "$2y$", "$2b$"}
+)
+
+// HTTPAuthProvider defines the interface for HTTP auth providers
+type HTTPAuthProvider interface {
+	ValidateCredentials(username, password string) bool
+	IsEnabled() bool
+}
+
+type basicAuthProvider struct {
+	Path string
+	sync.RWMutex
+	Info  os.FileInfo
+	Users map[string]string
+}
+
+// NewBasicAuthProvider returns an HTTPAuthProvider implementing Basic Auth
+func NewBasicAuthProvider(authUserFile string) (HTTPAuthProvider, error) {
+	basicAuthProvider := basicAuthProvider{
+		Path:  authUserFile,
+		Info:  nil,
+		Users: make(map[string]string),
+	}
+	return &basicAuthProvider, basicAuthProvider.loadUsers()
+}
+
+func (p *basicAuthProvider) IsEnabled() bool {
+	return p.Path != ""
+}
+
+func (p *basicAuthProvider) isReloadNeeded(info os.FileInfo) bool {
+	p.RLock()
+	defer p.RUnlock()
+
+	return p.Info == nil || p.Info.ModTime() != info.ModTime() || p.Info.Size() != info.Size()
+}
+
+func (p *basicAuthProvider) loadUsers() error {
+	if !p.IsEnabled() {
+		return nil
+	}
+	info, err := os.Stat(p.Path)
+	if err != nil {
+		logger.Debug(logSender, "", "unable to stat basic auth users file: %v", err)
+		return err
+	}
+	if p.isReloadNeeded(info) {
+		r, err := os.Open(p.Path)
+		if err != nil {
+			logger.Debug(logSender, "", "unable to open basic auth users file: %v", err)
+			return err
+		}
+		defer r.Close()
+		reader := csv.NewReader(r)
+		reader.Comma = ':'
+		reader.Comment = '#'
+		reader.TrimLeadingSpace = true
+		records, err := reader.ReadAll()
+		if err != nil {
+			logger.Debug(logSender, "", "unable to parse basic auth users file: %v", err)
+			return err
+		}
+		p.Lock()
+		defer p.Unlock()
+
+		p.Users = make(map[string]string)
+		for _, record := range records {
+			if len(record) == 2 {
+				p.Users[record[0]] = record[1]
+			}
+		}
+		logger.Debug(logSender, "", "number of users loaded for httpd basic auth: %v", len(p.Users))
+		p.Info = info
+	}
+	return nil
+}
+
+func (p *basicAuthProvider) getHashedPassword(username string) (string, bool) {
+	err := p.loadUsers()
+	if err != nil {
+		return "", false
+	}
+	p.RLock()
+	defer p.RUnlock()
+
+	pwd, ok := p.Users[username]
+	return pwd, ok
+}
+
+// ValidateCredentials returns true if the credentials are valid
+func (p *basicAuthProvider) ValidateCredentials(username, password string) bool {
+	if hashedPwd, ok := p.getHashedPassword(username); ok {
+		if utils.IsStringPrefixInSlice(hashedPwd, bcryptPwdPrefixes) {
+			err := bcrypt.CompareHashAndPassword([]byte(hashedPwd), []byte(password))
+			return err == nil
+		}
+		if strings.HasPrefix(hashedPwd, md5CryptPwdPrefix) {
+			crypter := md5_crypt.New()
+			err := crypter.Verify(hashedPwd, []byte(password))
+			return err == nil
+		}
+		if strings.HasPrefix(hashedPwd, apr1CryptPwdPrefix) {
+			crypter := apr1_crypt.New()
+			err := crypter.Verify(hashedPwd, []byte(password))
+			return err == nil
+		}
+	}
+
+	return false
+}

+ 72 - 0
common/httpauth_test.go

@@ -0,0 +1,72 @@
+package common
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestBasicAuth(t *testing.T) {
+	httpAuth, err := NewBasicAuthProvider("")
+	require.NoError(t, err)
+	require.False(t, httpAuth.IsEnabled())
+
+	_, err = NewBasicAuthProvider("missing path")
+	require.Error(t, err)
+
+	authUserFile := filepath.Join(os.TempDir(), "http_users.txt")
+	authUserData := []byte("test1:$2y$05$bcHSED7aO1cfLto6ZdDBOOKzlwftslVhtpIkRhAtSa4GuLmk5mola\n")
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+
+	httpAuth, err = NewBasicAuthProvider(authUserFile)
+	require.NoError(t, err)
+	require.True(t, httpAuth.IsEnabled())
+	require.False(t, httpAuth.ValidateCredentials("test1", "wrong1"))
+	require.False(t, httpAuth.ValidateCredentials("test2", "password2"))
+	require.True(t, httpAuth.ValidateCredentials("test1", "password1"))
+
+	authUserData = append(authUserData, []byte("test2:$1$OtSSTL8b$bmaCqEksI1e7rnZSjsIDR1\n")...)
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+	require.False(t, httpAuth.ValidateCredentials("test2", "wrong2"))
+	require.True(t, httpAuth.ValidateCredentials("test2", "password2"))
+
+	authUserData = append(authUserData, []byte("test2:$apr1$gLnIkRIf$Xr/6aJfmIrihP4b2N2tcs/\n")...)
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+	require.False(t, httpAuth.ValidateCredentials("test2", "wrong2"))
+	require.True(t, httpAuth.ValidateCredentials("test2", "password2"))
+
+	authUserData = append(authUserData, []byte("test3:$apr1$gLnIkRIf$Xr/6aJfmIrihP4b2N2tcs/\n")...)
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+	require.False(t, httpAuth.ValidateCredentials("test3", "password3"))
+
+	authUserData = append(authUserData, []byte("test4:$invalid$gLnIkRIf$Xr/6$aJfmIr$ihP4b2N2tcs/\n")...)
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+	require.False(t, httpAuth.ValidateCredentials("test4", "password3"))
+
+	if runtime.GOOS != "windows" {
+		authUserData = append(authUserData, []byte("test5:$apr1$gLnIkRIf$Xr/6aJfmIrihP4b2N2tcs/\n")...)
+		err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+		require.NoError(t, err)
+		err = os.Chmod(authUserFile, 0001)
+		require.NoError(t, err)
+		require.False(t, httpAuth.ValidateCredentials("test5", "password2"))
+		err = os.Chmod(authUserFile, os.ModePerm)
+		require.NoError(t, err)
+	}
+	authUserData = append(authUserData, []byte("\"foo\"bar\"\r\n")...)
+	err = ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+	require.False(t, httpAuth.ValidateCredentials("test2", "password2"))
+
+	err = os.Remove(authUserFile)
+	require.NoError(t, err)
+}

+ 10 - 2
config/config.go

@@ -185,8 +185,12 @@ func Init() {
 			},
 		},
 		TelemetryConfig: telemetry.Conf{
-			BindPort:    10000,
-			BindAddress: "127.0.0.1",
+			BindPort:           10000,
+			BindAddress:        "127.0.0.1",
+			EnableProfiler:     false,
+			AuthUserFile:       "",
+			CertificateFile:    "",
+			CertificateKeyFile: "",
 		},
 	}
 
@@ -514,4 +518,8 @@ func setViperDefaults() {
 	viper.SetDefault("kms.secrets.master_key_path", globalConf.KMSConfig.Secrets.MasterKeyPath)
 	viper.SetDefault("telemetry.bind_port", globalConf.TelemetryConfig.BindPort)
 	viper.SetDefault("telemetry.bind_address", globalConf.TelemetryConfig.BindAddress)
+	viper.SetDefault("telemetry.enable_profiler", globalConf.TelemetryConfig.EnableProfiler)
+	viper.SetDefault("telemetry.auth_user_file", globalConf.TelemetryConfig.AuthUserFile)
+	viper.SetDefault("telemetry.certificate_file", globalConf.TelemetryConfig.CertificateFile)
+	viper.SetDefault("telemetry.certificate_key_file", globalConf.TelemetryConfig.CertificateKeyFile)
 }

+ 4 - 0
docs/full-configuration.md

@@ -163,6 +163,10 @@ The configuration file contains the following sections:
 - **"telemetry"**, the configuration for the telemetry server, more details [below](#telemetry-server)
   - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 10000
   - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1"
+  - `enable_profiler`, boolean. Enable the built-in profiler. Default `false`
+  - `auth_user_file`, string. Path to a file used to store usernames and passwords 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 `htpasswd` tool. The supported password formats are bcrypt (`$2y$` prefix) and md5 crypt (`$apr1$` prefix). If empty, HTTP authentication is disabled. Authentication will be always disabled for the `/healthz` endpoint.
+  - `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 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.
 - **"http"**, the configuration for HTTP clients. HTTP clients are used for executing hooks such as the ones used for custom actions, external authentication and pre-login user modifications
   - `timeout`, integer. Timeout specifies a time limit, in seconds, for requests.
   - `ca_certificates`, list of strings. List of paths to extra CA certificates to trust. The paths can be absolute or relative to the config dir. Adding trusted CA certificates is a convenient way to use self-signed certificates without defeating the purpose of using TLS.

+ 3 - 1
docs/metrics.md

@@ -1,6 +1,6 @@
 # Metrics
 
-SFTPGo exposes [Prometheus](https://prometheus.io/) metrics at the `/metrics` HTTP endpoint.
+SFTPGo exposes [Prometheus](https://prometheus.io/) metrics at the `/metrics` HTTP endpoint of the telemetry server.
 Several counters and gauges are available, for example:
 
 - Total uploads and downloads
@@ -16,3 +16,5 @@ Several counters and gauges are available, for example:
 - Process information like CPU, memory, file descriptor usage and start time
 
 Please check the `/metrics` page for more details.
+
+We expose the `/metrics` endpoint in both HTTP server and the telemetry server, you should use the one from the telemetry server. The HTTP server `/metrics` endpoint is deprecated and it will be removed in future releases.

+ 1 - 1
docs/profiling.md

@@ -1,7 +1,7 @@
 # Profiling SFTPGo
 
 The built-in profiler lets you collect CPU profiles, traces, allocations and heap profiles that allow to identify and correct specific bottlenecks.
-You can enable the built-in profiler using the `--profiler` command flag.
+You can enable the built-in profiler using `telemetry` configuration section inside the configuration file.
 
 Profiling data are exposed via HTTP/HTTPS in the format expected by the [pprof](https://github.com/google/pprof/blob/master/doc/README.md) visualization tool. You can find the index page at the URL `/debug/pprof/`.
 

+ 2 - 2
httpd/api_utils.go

@@ -46,10 +46,10 @@ func sendHTTPRequest(method, url string, body io.Reader, contentType string) (*h
 	if err != nil {
 		return nil, err
 	}
-	if len(contentType) > 0 {
+	if contentType != "" {
 		req.Header.Set("Content-Type", "application/json")
 	}
-	if len(authUsername) > 0 || len(authPassword) > 0 {
+	if authUsername != "" || authPassword != "" {
 		req.SetBasicAuth(authUsername, authPassword)
 	}
 	return httpclient.GetHTTPClient().Do(req)

+ 6 - 125
httpd/auth.go

@@ -1,123 +1,20 @@
 package httpd
 
 import (
-	"encoding/csv"
-	"errors"
-	"fmt"
 	"net/http"
-	"os"
 	"strings"
-	"sync"
 
-	"github.com/GehirnInc/crypt/apr1_crypt"
-	"github.com/GehirnInc/crypt/md5_crypt"
-	"golang.org/x/crypto/bcrypt"
-
-	"github.com/drakkan/sftpgo/logger"
-	"github.com/drakkan/sftpgo/utils"
-)
-
-const (
-	authenticationHeader = "WWW-Authenticate"
-	authenticationRealm  = "SFTPGo Web"
-	unauthResponse       = "Unauthorized"
-	md5CryptPwdPrefix    = "$1$"
-	apr1CryptPwdPrefix   = "$apr1$"
-)
-
-var (
-	bcryptPwdPrefixes = []string{"$2a$", "$2$", "$2x$", "$2y$", "$2b$"}
+	"github.com/drakkan/sftpgo/common"
 )
 
-type httpAuthProvider interface {
-	getHashedPassword(username string) (string, bool)
-	isEnabled() bool
-}
-
-type basicAuthProvider struct {
-	Path string
-	sync.RWMutex
-	Info  os.FileInfo
-	Users map[string]string
-}
-
-func newBasicAuthProvider(authUserFile string) (httpAuthProvider, error) {
-	basicAuthProvider := basicAuthProvider{
-		Path:  authUserFile,
-		Info:  nil,
-		Users: make(map[string]string),
-	}
-	return &basicAuthProvider, basicAuthProvider.loadUsers()
-}
-
-func (p *basicAuthProvider) isEnabled() bool {
-	return len(p.Path) > 0
-}
-
-func (p *basicAuthProvider) isReloadNeeded(info os.FileInfo) bool {
-	p.RLock()
-	defer p.RUnlock()
-	return p.Info == nil || p.Info.ModTime() != info.ModTime() || p.Info.Size() != info.Size()
-}
-
-func (p *basicAuthProvider) loadUsers() error {
-	if !p.isEnabled() {
-		return nil
-	}
-	info, err := os.Stat(p.Path)
-	if err != nil {
-		logger.Debug(logSender, "", "unable to stat basic auth users file: %v", err)
-		return err
-	}
-	if p.isReloadNeeded(info) {
-		r, err := os.Open(p.Path)
-		if err != nil {
-			logger.Debug(logSender, "", "unable to open basic auth users file: %v", err)
-			return err
-		}
-		defer r.Close()
-		reader := csv.NewReader(r)
-		reader.Comma = ':'
-		reader.Comment = '#'
-		reader.TrimLeadingSpace = true
-		records, err := reader.ReadAll()
-		if err != nil {
-			logger.Debug(logSender, "", "unable to parse basic auth users file: %v", err)
-			return err
-		}
-		p.Lock()
-		defer p.Unlock()
-		p.Users = make(map[string]string)
-		for _, record := range records {
-			if len(record) == 2 {
-				p.Users[record[0]] = record[1]
-			}
-		}
-		logger.Debug(logSender, "", "number of users loaded for httpd basic auth: %v", len(p.Users))
-		p.Info = info
-	}
-	return nil
-}
-
-func (p *basicAuthProvider) getHashedPassword(username string) (string, bool) {
-	err := p.loadUsers()
-	if err != nil {
-		return "", false
-	}
-	p.RLock()
-	defer p.RUnlock()
-	pwd, ok := p.Users[username]
-	return pwd, ok
-}
-
 func checkAuth(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if !validateCredentials(r) {
-			w.Header().Set(authenticationHeader, fmt.Sprintf("Basic realm=\"%v\"", authenticationRealm))
+			w.Header().Set(common.HTTPAuthenticationHeader, "Basic realm=\"SFTPGo Web\"")
 			if strings.HasPrefix(r.RequestURI, apiPrefix) {
-				sendAPIResponse(w, r, errors.New(unauthResponse), "", http.StatusUnauthorized)
+				sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 			} else {
-				http.Error(w, unauthResponse, http.StatusUnauthorized)
+				http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
 			}
 			return
 		}
@@ -126,28 +23,12 @@ func checkAuth(next http.Handler) http.Handler {
 }
 
 func validateCredentials(r *http.Request) bool {
-	if !httpAuth.isEnabled() {
+	if !httpAuth.IsEnabled() {
 		return true
 	}
 	username, password, ok := r.BasicAuth()
 	if !ok {
 		return false
 	}
-	if hashedPwd, ok := httpAuth.getHashedPassword(username); ok {
-		if utils.IsStringPrefixInSlice(hashedPwd, bcryptPwdPrefixes) {
-			err := bcrypt.CompareHashAndPassword([]byte(hashedPwd), []byte(password))
-			return err == nil
-		}
-		if strings.HasPrefix(hashedPwd, md5CryptPwdPrefix) {
-			crypter := md5_crypt.New()
-			err := crypter.Verify(hashedPwd, []byte(password))
-			return err == nil
-		}
-		if strings.HasPrefix(hashedPwd, apr1CryptPwdPrefix) {
-			crypter := apr1_crypt.New()
-			err := crypter.Verify(hashedPwd, []byte(password))
-			return err == nil
-		}
-	}
-	return false
+	return httpAuth.ValidateCredentials(username, password)
 }

+ 4 - 4
httpd/httpd.go

@@ -55,7 +55,7 @@ const (
 var (
 	router      *chi.Mux
 	backupsPath string
-	httpAuth    httpAuthProvider
+	httpAuth    common.HTTPAuthProvider
 	certMgr     *common.CertManager
 )
 
@@ -115,7 +115,7 @@ func (c Conf) Initialize(configDir string) error {
 			staticFilesPath, templatesPath)
 	}
 	authUserFile := getConfigPath(c.AuthUserFile, configDir)
-	httpAuth, err = newBasicAuthProvider(authUserFile)
+	httpAuth, err = common.NewBasicAuthProvider(authUserFile)
 	if err != nil {
 		return err
 	}
@@ -135,7 +135,7 @@ func (c Conf) Initialize(configDir string) error {
 		IdleTimeout:    120 * time.Second,
 		MaxHeaderBytes: 1 << 16, // 64KB
 	}
-	if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
+	if certificateFile != "" && certificateKeyFile != "" {
 		certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
 		if err != nil {
 			return err
@@ -162,7 +162,7 @@ func getConfigPath(name, configDir string) string {
 	if !utils.IsFileInputValid(name) {
 		return ""
 	}
-	if len(name) > 0 && !filepath.IsAbs(name) {
+	if name != "" && !filepath.IsAbs(name) {
 		return filepath.Join(configDir, name)
 	}
 	return name

+ 2 - 2
httpd/internal_test.go

@@ -591,7 +591,7 @@ func TestBasicAuth(t *testing.T) {
 	authUserData := []byte("test1:$2y$05$bcHSED7aO1cfLto6ZdDBOOKzlwftslVhtpIkRhAtSa4GuLmk5mola\n")
 	err := ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
 	assert.NoError(t, err)
-	httpAuth, _ = newBasicAuthProvider(authUserFile)
+	httpAuth, _ = common.NewBasicAuthProvider(authUserFile)
 	_, _, err = GetVersion(http.StatusUnauthorized)
 	assert.NoError(t, err)
 	SetBaseURLAndCredentials(httpBaseURL, "test1", "password1")
@@ -652,7 +652,7 @@ func TestBasicAuth(t *testing.T) {
 	err = os.Remove(authUserFile)
 	assert.NoError(t, err)
 	SetBaseURLAndCredentials(httpBaseURL, oldAuthUsername, oldAuthPassword)
-	httpAuth, _ = newBasicAuthProvider("")
+	httpAuth, _ = common.NewBasicAuthProvider("")
 }
 
 func TestCloseConnectionHandler(t *testing.T) {

+ 3 - 4
service/service.go

@@ -39,7 +39,6 @@ type Service struct {
 	PortableUser      dataprovider.User
 	LogCompress       bool
 	LogVerbose        bool
-	Profiler          bool
 	LoadDataClean     bool
 	LoadDataFrom      string
 	LoadDataMode      int
@@ -65,8 +64,8 @@ func (s *Service) Start() error {
 		}
 	}
 	logger.Info(logSender, "", "starting SFTPGo %v, config dir: %v, config file: %v, log max size: %v log max backups: %v "+
-		"log max age: %v log verbose: %v, log compress: %v, profile: %v load data from: %#v", version.GetAsString(), s.ConfigDir, s.ConfigFile,
-		s.LogMaxSize, s.LogMaxBackups, s.LogMaxAge, s.LogVerbose, s.LogCompress, s.Profiler, s.LoadDataFrom)
+		"log max age: %v log verbose: %v, log compress: %v, load data from: %#v", version.GetAsString(), s.ConfigDir, s.ConfigFile,
+		s.LogMaxSize, s.LogMaxBackups, s.LogMaxAge, s.LogVerbose, s.LogCompress, s.LoadDataFrom)
 	// in portable mode we don't read configuration from file
 	if s.PortableMode != 1 {
 		err := config.LoadConfig(s.ConfigDir, s.ConfigFile)
@@ -185,7 +184,7 @@ func (s *Service) startServices() {
 	}
 	if telemetryConf.BindPort > 0 {
 		go func() {
-			if err := telemetryConf.Initialize(s.Profiler); err != nil {
+			if err := telemetryConf.Initialize(s.ConfigDir); err != nil {
 				logger.Error(logSender, "", "could not start telemetry server: %v", err)
 				logger.ErrorToConsole("could not start telemetry server: %v", err)
 				s.Error = err

+ 5 - 1
service/service_windows.go

@@ -101,7 +101,11 @@ loop:
 			}
 			err = webdavd.ReloadTLSCertificate()
 			if err != nil {
-				logger.Warn(logSender, "", "error reloading WebDav TLS certificate: %v", err)
+				logger.Warn(logSender, "", "error reloading WebDAV TLS certificate: %v", err)
+			}
+			err = telemetry.ReloadTLSCertificate()
+			if err != nil {
+				logger.Warn(logSender, "", "error reloading telemetry TLS certificate: %v", err)
 			}
 		case rotateLogCmd:
 			logger.Debug(logSender, "", "Received log file rotation request")

+ 6 - 1
service/sighup_unix.go

@@ -11,6 +11,7 @@ import (
 	"github.com/drakkan/sftpgo/ftpd"
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/telemetry"
 	"github.com/drakkan/sftpgo/webdavd"
 )
 
@@ -34,7 +35,11 @@ func registerSigHup() {
 			}
 			err = webdavd.ReloadTLSCertificate()
 			if err != nil {
-				logger.Warn(logSender, "", "error reloading WebDav TLS certificate: %v", err)
+				logger.Warn(logSender, "", "error reloading WebDAV TLS certificate: %v", err)
+			}
+			err = telemetry.ReloadTLSCertificate()
+			if err != nil {
+				logger.Warn(logSender, "", "error reloading telemetry TLS certificate: %v", err)
 			}
 		}
 	}()

+ 5 - 1
sftpgo.json

@@ -121,7 +121,11 @@
   },
   "telemetry": {
     "bind_port": 10000,
-    "bind_address": "127.0.0.1"
+    "bind_address": "127.0.0.1",
+    "enable_profiler": false,
+    "auth_user_file": "",
+    "certificate_file": "",
+    "certificate_key_file": ""
   },
   "http": {
     "timeout": 20,

+ 31 - 5
telemetry/router.go

@@ -7,6 +7,7 @@ import (
 	"github.com/go-chi/chi/middleware"
 	"github.com/go-chi/render"
 
+	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/metrics"
 )
@@ -22,11 +23,36 @@ func initializeRouter(enableProfiler bool) {
 		})
 	})
 
-	metrics.AddMetricsEndpoint(metricsPath, router)
+	router.Group(func(router chi.Router) {
+		router.Use(checkAuth)
+		metrics.AddMetricsEndpoint(metricsPath, router)
 
-	if enableProfiler {
-		logger.InfoToConsole("enabling the built-in profiler")
-		logger.Info(logSender, "", "enabling the built-in profiler")
-		router.Mount(pprofBasePath, middleware.Profiler())
+		if enableProfiler {
+			logger.InfoToConsole("enabling the built-in profiler")
+			logger.Info(logSender, "", "enabling the built-in profiler")
+			router.Mount(pprofBasePath, middleware.Profiler())
+		}
+	})
+}
+
+func checkAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !validateCredentials(r) {
+			w.Header().Set(common.HTTPAuthenticationHeader, "Basic realm=\"SFTPGo telemetry\"")
+			http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+func validateCredentials(r *http.Request) bool {
+	if !httpAuth.IsEnabled() {
+		return true
+	}
+	username, password, ok := r.BasicAuth()
+	if !ok {
+		return false
 	}
+	return httpAuth.ValidateCredentials(username, password)
 }

+ 62 - 3
telemetry/telemetry.go

@@ -5,13 +5,17 @@
 package telemetry
 
 import (
+	"crypto/tls"
 	"fmt"
 	"net/http"
+	"path/filepath"
 	"time"
 
 	"github.com/go-chi/chi"
 
+	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/utils"
 )
 
 const (
@@ -21,7 +25,9 @@ const (
 )
 
 var (
-	router *chi.Mux
+	router   *chi.Mux
+	httpAuth common.HTTPAuthProvider
+	certMgr  *common.CertManager
 )
 
 // Conf telemetry server configuration.
@@ -30,12 +36,35 @@ type Conf struct {
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
 	// The address to listen on. A blank value means listen on all available network interfaces. Default: "127.0.0.1"
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
+	// Enable the built-in profiler.
+	// The profiler will be accessible via HTTP/HTTPS using the base URL "/debug/pprof/"
+	EnableProfiler bool `json:"enable_profiler" mapstructure:"enable_profiler"`
+	// 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
+	// htpasswd tool. The supported password formats are bcrypt ($2y$ prefix) and md5 crypt ($apr1$ prefix).
+	// 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.
+	// 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"`
 }
 
 // Initialize configures and starts the telemetry server.
-func (c Conf) Initialize(enableProfiler bool) error {
+func (c Conf) Initialize(configDir string) error {
+	var err error
 	logger.Debug(logSender, "", "initializing telemetry server with config %+v", c)
-	initializeRouter(enableProfiler)
+	authUserFile := getConfigPath(c.AuthUserFile, configDir)
+	httpAuth, err = common.NewBasicAuthProvider(authUserFile)
+	if err != nil {
+		return err
+	}
+	certificateFile := getConfigPath(c.CertificateFile, configDir)
+	certificateKeyFile := getConfigPath(c.CertificateKeyFile, configDir)
+	initializeRouter(c.EnableProfiler)
 	httpServer := &http.Server{
 		Addr:           fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort),
 		Handler:        router,
@@ -44,5 +73,35 @@ func (c Conf) Initialize(enableProfiler bool) error {
 		IdleTimeout:    120 * time.Second,
 		MaxHeaderBytes: 1 << 14, // 16KB
 	}
+	if certificateFile != "" && certificateKeyFile != "" {
+		certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
+		if err != nil {
+			return err
+		}
+		config := &tls.Config{
+			GetCertificate: certMgr.GetCertificateFunc(),
+			MinVersion:     tls.VersionTLS12,
+		}
+		httpServer.TLSConfig = config
+		return httpServer.ListenAndServeTLS("", "")
+	}
 	return httpServer.ListenAndServe()
 }
+
+// ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
+func ReloadTLSCertificate() error {
+	if certMgr != nil {
+		return certMgr.LoadCertificate(logSender)
+	}
+	return nil
+}
+
+func getConfigPath(name, configDir string) string {
+	if !utils.IsFileInputValid(name) {
+		return ""
+	}
+	if name != "" && !filepath.IsAbs(name) {
+		return filepath.Join(configDir, name)
+	}
+	return name
+}

+ 140 - 0
telemetry/telemetry_test.go

@@ -0,0 +1,140 @@
+package telemetry
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/drakkan/sftpgo/common"
+)
+
+const (
+	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-----`
+)
+
+func TestInitialization(t *testing.T) {
+	c := Conf{
+		BindPort:       10000,
+		BindAddress:    "invalid",
+		EnableProfiler: false,
+	}
+	err := c.Initialize(".")
+	require.Error(t, err)
+
+	c.AuthUserFile = "missing"
+	err = c.Initialize(".")
+	require.Error(t, err)
+
+	err = ReloadTLSCertificate()
+	require.NoError(t, err)
+
+	c.AuthUserFile = ""
+	c.CertificateFile = "crt"
+	c.CertificateKeyFile = "key"
+
+	err = c.Initialize(".")
+	require.Error(t, err)
+
+	certPath := filepath.Join(os.TempDir(), "test.crt")
+	keyPath := filepath.Join(os.TempDir(), "test.key")
+	err = ioutil.WriteFile(certPath, []byte(httpsCert), os.ModePerm)
+	require.NoError(t, err)
+	err = ioutil.WriteFile(keyPath, []byte(httpsKey), os.ModePerm)
+	require.NoError(t, err)
+
+	c.CertificateFile = certPath
+	c.CertificateKeyFile = keyPath
+
+	err = c.Initialize(".")
+	require.Error(t, err)
+
+	err = ReloadTLSCertificate()
+	require.NoError(t, err)
+
+	err = os.Remove(certPath)
+	require.NoError(t, err)
+	err = os.Remove(keyPath)
+	require.NoError(t, err)
+}
+
+func TestRouter(t *testing.T) {
+	authUserFile := filepath.Join(os.TempDir(), "http_users.txt")
+	authUserData := []byte("test1:$2y$05$bcHSED7aO1cfLto6ZdDBOOKzlwftslVhtpIkRhAtSa4GuLmk5mola\n")
+	err := ioutil.WriteFile(authUserFile, authUserData, os.ModePerm)
+	require.NoError(t, err)
+
+	httpAuth, err = common.NewBasicAuthProvider(authUserFile)
+	require.NoError(t, err)
+
+	initializeRouter(true)
+	testServer := httptest.NewServer(router)
+	defer testServer.Close()
+
+	req, err := http.NewRequest(http.MethodGet, "/healthz", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+	require.Equal(t, "ok", rr.Body.String())
+
+	req, err = http.NewRequest(http.MethodGet, "/metrics", nil)
+	require.NoError(t, err)
+	rr = httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusUnauthorized, rr.Code)
+
+	req.SetBasicAuth("test1", "password1")
+	rr = httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	req, err = http.NewRequest(http.MethodGet, pprofBasePath+"/pprof/", nil)
+	require.NoError(t, err)
+	rr = httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusUnauthorized, rr.Code)
+
+	req.SetBasicAuth("test1", "password1")
+	rr = httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	httpAuth, err = common.NewBasicAuthProvider("")
+	require.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, "/metrics", nil)
+	require.NoError(t, err)
+	rr = httptest.NewRecorder()
+	testServer.Config.Handler.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	err = os.Remove(authUserFile)
+	require.NoError(t, err)
+}