telemetry server: add optional https and authentication

This commit is contained in:
Nicola Murino 2020-12-18 16:04:42 +01:00
parent 140380716d
commit bcf0fa073e
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
21 changed files with 492 additions and 169 deletions

View file

@ -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'

View file

@ -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{

View file

@ -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),

View file

@ -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
common/httpauth.go Normal file
View file

@ -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
common/httpauth_test.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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.

View file

@ -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.

View file

@ -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/`.

View file

@ -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)

View file

@ -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"
"github.com/drakkan/sftpgo/common"
)
const (
authenticationHeader = "WWW-Authenticate"
authenticationRealm = "SFTPGo Web"
unauthResponse = "Unauthorized"
md5CryptPwdPrefix = "$1$"
apr1CryptPwdPrefix = "$apr1$"
)
var (
bcryptPwdPrefixes = []string{"$2a$", "$2$", "$2x$", "$2y$", "$2b$"}
)
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)
}

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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")

View file

@ -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)
}
}
}()

View file

@ -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,

View file

@ -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)
}

View file

@ -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
telemetry/telemetry_test.go Normal file
View file

@ -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)
}