telemetry server: add optional https and authentication
This commit is contained in:
parent
140380716d
commit
bcf0fa073e
21 changed files with 492 additions and 169 deletions
1
.github/workflows/development.yml
vendored
1
.github/workflows/development.yml
vendored
|
@ -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'
|
||||
|
|
|
@ -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{
|
||||
|
|
14
cmd/root.go
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),
|
||||
|
|
|
@ -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
134
common/httpauth.go
Normal 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
72
common/httpauth_test.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,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/`.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
131
httpd/auth.go
131
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"
|
||||
"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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
140
telemetry/telemetry_test.go
Normal 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)
|
||||
}
|
Loading…
Add table
Reference in a new issue