diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 29848c417..5f9debcb1 100644 --- a/.github/workflows/development.yml +++ b/.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' diff --git a/cmd/portable.go b/cmd/portable.go index ddb2ebdc0..b85ef9256 100644 --- a/cmd/portable.go +++ b/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{ diff --git a/cmd/root.go b/cmd/root.go index 216fe7dda..e5c0e20cd 100644 --- a/cmd/root.go +++ b/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), diff --git a/cmd/serve.go b/cmd/serve.go index 5091bd9ff..18ac7c349 100644 --- a/cmd/serve.go +++ b/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 { diff --git a/common/httpauth.go b/common/httpauth.go new file mode 100644 index 000000000..60540137a --- /dev/null +++ b/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 +} diff --git a/common/httpauth_test.go b/common/httpauth_test.go new file mode 100644 index 000000000..257942250 --- /dev/null +++ b/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) +} diff --git a/config/config.go b/config/config.go index e2ae65529..95a07eb22 100644 --- a/config/config.go +++ b/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) } diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 6c4c6d81b..2e1a7ab65 100644 --- a/docs/full-configuration.md +++ b/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. diff --git a/docs/metrics.md b/docs/metrics.md index 38f8f3454..cfdcb1664 100644 --- a/docs/metrics.md +++ b/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. diff --git a/docs/profiling.md b/docs/profiling.md index 5d18c0237..083df08c6 100644 --- a/docs/profiling.md +++ b/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/`. diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 1bca03ae9..f513a3c3d 100644 --- a/httpd/api_utils.go +++ b/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) diff --git a/httpd/auth.go b/httpd/auth.go index 8b6987ca7..72b28b808 100644 --- a/httpd/auth.go +++ b/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) } diff --git a/httpd/httpd.go b/httpd/httpd.go index c8476c4a0..8cee39d39 100644 --- a/httpd/httpd.go +++ b/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 diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 85895cc0e..346863d0e 100644 --- a/httpd/internal_test.go +++ b/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) { diff --git a/service/service.go b/service/service.go index ec668d9d5..b56db7c2f 100644 --- a/service/service.go +++ b/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 diff --git a/service/service_windows.go b/service/service_windows.go index 57dd17fef..abeff5437 100644 --- a/service/service_windows.go +++ b/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") diff --git a/service/sighup_unix.go b/service/sighup_unix.go index 2045958f1..a9b71e55a 100644 --- a/service/sighup_unix.go +++ b/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) } } }() diff --git a/sftpgo.json b/sftpgo.json index 5e2b4fb06..f5713e8ef 100644 --- a/sftpgo.json +++ b/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, diff --git a/telemetry/router.go b/telemetry/router.go index 61aa4be85..0781a6e29 100644 --- a/telemetry/router.go +++ b/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) } diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index 134825824..bacfb7763 100644 --- a/telemetry/telemetry.go +++ b/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 +} diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go new file mode 100644 index 000000000..2bdb36d3c --- /dev/null +++ b/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) +}