Просмотр исходного кода

add support for multiple bindings

Fixes #253
Nicola Murino 4 лет назад
Родитель
Сommit
c69d63c1f8

+ 1 - 0
common/common.go

@@ -88,6 +88,7 @@ var (
 	ErrQuotaExceeded        = errors.New("denying write due to space limit")
 	ErrQuotaExceeded        = errors.New("denying write due to space limit")
 	ErrSkipPermissionsCheck = errors.New("permission check skipped")
 	ErrSkipPermissionsCheck = errors.New("permission check skipped")
 	ErrConnectionDenied     = errors.New("You are not allowed to connect")
 	ErrConnectionDenied     = errors.New("You are not allowed to connect")
+	ErrNoBinding            = errors.New("No binding configured")
 	errNoTransfer           = errors.New("requested transfer not found")
 	errNoTransfer           = errors.New("requested transfer not found")
 	errTransferMismatch     = errors.New("transfer mismatch")
 	errTransferMismatch     = errors.New("transfer mismatch")
 )
 )

+ 246 - 22
config/config.go

@@ -3,7 +3,9 @@ package config
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"os"
 	"path/filepath"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"strings"
 
 
 	"github.com/spf13/viper"
 	"github.com/spf13/viper"
@@ -33,9 +35,24 @@ const (
 )
 )
 
 
 var (
 var (
-	globalConf         globalConfig
-	defaultSFTPDBanner = fmt.Sprintf("SFTPGo_%v", version.Get().Version)
-	defaultFTPDBanner  = fmt.Sprintf("SFTPGo %v ready", version.Get().Version)
+	globalConf          globalConfig
+	defaultSFTPDBanner  = fmt.Sprintf("SFTPGo_%v", version.Get().Version)
+	defaultFTPDBanner   = fmt.Sprintf("SFTPGo %v ready", version.Get().Version)
+	defaultSFTPDBinding = sftpd.Binding{
+		Address:          "",
+		Port:             2022,
+		ApplyProxyConfig: true,
+	}
+	defaultFTPDBinding = ftpd.Binding{
+		Address:          "",
+		Port:             0,
+		ApplyProxyConfig: true,
+	}
+	defaultWebDAVDBinding = webdavd.Binding{
+		Address:     "",
+		Port:        0,
+		EnableHTTPS: false,
+	}
 )
 )
 
 
 type globalConfig struct {
 type globalConfig struct {
@@ -75,8 +92,7 @@ func Init() {
 		},
 		},
 		SFTPD: sftpd.Configuration{
 		SFTPD: sftpd.Configuration{
 			Banner:                  defaultSFTPDBanner,
 			Banner:                  defaultSFTPDBanner,
-			BindPort:                2022,
-			BindAddress:             "",
+			Bindings:                []sftpd.Binding{defaultSFTPDBinding},
 			MaxAuthTries:            0,
 			MaxAuthTries:            0,
 			HostKeys:                []string{},
 			HostKeys:                []string{},
 			KexAlgorithms:           []string{},
 			KexAlgorithms:           []string{},
@@ -89,12 +105,10 @@ func Init() {
 			PasswordAuthentication:  true,
 			PasswordAuthentication:  true,
 		},
 		},
 		FTPD: ftpd.Configuration{
 		FTPD: ftpd.Configuration{
-			BindPort:                 0,
-			BindAddress:              "",
+			Bindings:                 []ftpd.Binding{defaultFTPDBinding},
 			Banner:                   defaultFTPDBanner,
 			Banner:                   defaultFTPDBanner,
 			BannerFile:               "",
 			BannerFile:               "",
-			ActiveTransfersPortNon20: false,
-			ForcePassiveIP:           "",
+			ActiveTransfersPortNon20: true,
 			PassivePortRange: ftpd.PortRange{
 			PassivePortRange: ftpd.PortRange{
 				Start: 50000,
 				Start: 50000,
 				End:   50100,
 				End:   50100,
@@ -103,8 +117,7 @@ func Init() {
 			CertificateKeyFile: "",
 			CertificateKeyFile: "",
 		},
 		},
 		WebDAVD: webdavd.Configuration{
 		WebDAVD: webdavd.Configuration{
-			BindPort:           0,
-			BindAddress:        "",
+			Bindings:           []webdavd.Binding{defaultWebDAVDBinding},
 			CertificateFile:    "",
 			CertificateFile:    "",
 			CertificateKeyFile: "",
 			CertificateKeyFile: "",
 			Cors: webdavd.Cors{
 			Cors: webdavd.Cors{
@@ -291,13 +304,13 @@ func SetTelemetryConfig(config telemetry.Conf) {
 // HasServicesToStart returns true if the config defines at least a service to start.
 // HasServicesToStart returns true if the config defines at least a service to start.
 // Supported services are SFTP, FTP and WebDAV
 // Supported services are SFTP, FTP and WebDAV
 func HasServicesToStart() bool {
 func HasServicesToStart() bool {
-	if globalConf.SFTPD.BindPort > 0 {
+	if globalConf.SFTPD.ShouldBind() {
 		return true
 		return true
 	}
 	}
-	if globalConf.FTPD.BindPort > 0 {
+	if globalConf.FTPD.ShouldBind() {
 		return true
 		return true
 	}
 	}
-	if globalConf.WebDAVD.BindPort > 0 {
+	if globalConf.WebDAVD.ShouldBind() {
 		return true
 		return true
 	}
 	}
 	return false
 	return false
@@ -340,6 +353,8 @@ func LoadConfig(configDir, configFile string) error {
 		logger.WarnToConsole("error parsing configuration file: %v", err)
 		logger.WarnToConsole("error parsing configuration file: %v", err)
 		return err
 		return err
 	}
 	}
+	// viper only supports slice of strings from env vars, so we use our custom method
+	loadBindingsFromEnv()
 	checkCommonParamsCompatibility()
 	checkCommonParamsCompatibility()
 	if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
 	if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
 		globalConf.SFTPD.Banner = defaultSFTPDBanner
 		globalConf.SFTPD.Banner = defaultSFTPDBanner
@@ -426,6 +441,199 @@ func checkCommonParamsCompatibility() {
 	}
 	}
 }
 }
 
 
+func checkSFTPDBindingsCompatibility() {
+	if len(globalConf.SFTPD.Bindings) > 0 {
+		return
+	}
+
+	// we copy deprecated fields to new ones to keep backward compatibility so lint is disabled
+	binding := sftpd.Binding{
+		ApplyProxyConfig: true,
+	}
+	if globalConf.SFTPD.BindPort > 0 { //nolint:staticcheck
+		binding.Port = globalConf.SFTPD.BindPort //nolint:staticcheck
+	}
+	if globalConf.SFTPD.BindAddress != "" { //nolint:staticcheck
+		binding.Address = globalConf.SFTPD.BindAddress //nolint:staticcheck
+	}
+
+	globalConf.SFTPD.Bindings = append(globalConf.SFTPD.Bindings, binding)
+}
+
+func checkFTPDBindingCompatibility() {
+	if len(globalConf.FTPD.Bindings) > 0 {
+		return
+	}
+
+	binding := ftpd.Binding{
+		ApplyProxyConfig: true,
+	}
+
+	if globalConf.FTPD.BindPort > 0 { //nolint:staticcheck
+		binding.Port = globalConf.FTPD.BindPort //nolint:staticcheck
+	}
+	if globalConf.FTPD.BindAddress != "" { //nolint:staticcheck
+		binding.Address = globalConf.FTPD.BindAddress //nolint:staticcheck
+	}
+	if globalConf.FTPD.TLSMode > 0 { //nolint:staticcheck
+		binding.TLSMode = globalConf.FTPD.TLSMode //nolint:staticcheck
+	}
+	if globalConf.FTPD.ForcePassiveIP != "" { //nolint:staticcheck
+		binding.ForcePassiveIP = globalConf.FTPD.ForcePassiveIP //nolint:staticcheck
+	}
+
+	globalConf.FTPD.Bindings = append(globalConf.FTPD.Bindings, binding)
+}
+
+func checkWebDAVDBindingCompatibility() {
+	if len(globalConf.WebDAVD.Bindings) > 0 {
+		return
+	}
+
+	binding := webdavd.Binding{
+		EnableHTTPS: globalConf.WebDAVD.CertificateFile != "" && globalConf.WebDAVD.CertificateKeyFile != "",
+	}
+
+	if globalConf.WebDAVD.BindPort > 0 { //nolint:staticcheck
+		binding.Port = globalConf.WebDAVD.BindPort //nolint:staticcheck
+	}
+	if globalConf.WebDAVD.BindAddress != "" { //nolint:staticcheck
+		binding.Address = globalConf.WebDAVD.BindAddress //nolint:staticcheck
+	}
+
+	globalConf.WebDAVD.Bindings = append(globalConf.WebDAVD.Bindings, binding)
+}
+
+func loadBindingsFromEnv() {
+	checkSFTPDBindingsCompatibility()
+	checkFTPDBindingCompatibility()
+	checkWebDAVDBindingCompatibility()
+
+	maxBindings := make([]int, 10)
+	for idx := range maxBindings {
+		getSFTPDBindindFromEnv(idx)
+		getFTPDBindingFromEnv(idx)
+		getWebDAVDBindingFromEnv(idx)
+	}
+}
+
+func getSFTPDBindindFromEnv(idx int) {
+	binding := sftpd.Binding{}
+	if len(globalConf.SFTPD.Bindings) > idx {
+		binding = globalConf.SFTPD.Bindings[idx]
+	}
+
+	isSet := false
+
+	port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_SFTPD__BINDINGS__%v__PORT", idx))
+	if ok {
+		binding.Port = port
+		isSet = true
+	}
+
+	address, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_SFTPD__BINDINGS__%v__ADDRESS", idx))
+	if ok {
+		binding.Address = address
+		isSet = true
+	}
+
+	applyProxyConfig, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_SFTPD__BINDINGS__%v__APPLY_PROXY_CONFIG", idx))
+	if ok {
+		binding.ApplyProxyConfig = applyProxyConfig
+		isSet = true
+	}
+
+	if isSet {
+		if len(globalConf.SFTPD.Bindings) > idx {
+			globalConf.SFTPD.Bindings[idx] = binding
+		} else {
+			globalConf.SFTPD.Bindings = append(globalConf.SFTPD.Bindings, binding)
+		}
+	}
+}
+
+func getFTPDBindingFromEnv(idx int) {
+	binding := ftpd.Binding{}
+	if len(globalConf.FTPD.Bindings) > idx {
+		binding = globalConf.FTPD.Bindings[idx]
+	}
+
+	isSet := false
+
+	port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__PORT", idx))
+	if ok {
+		binding.Port = port
+		isSet = true
+	}
+
+	address, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__ADDRESS", idx))
+	if ok {
+		binding.Address = address
+		isSet = true
+	}
+
+	applyProxyConfig, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__APPLY_PROXY_CONFIG", idx))
+	if ok {
+		binding.ApplyProxyConfig = applyProxyConfig
+		isSet = true
+	}
+
+	tlsMode, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__TLS_MODE", idx))
+	if ok {
+		binding.TLSMode = tlsMode
+		isSet = true
+	}
+
+	passiveIP, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__FORCE_PASSIVE_IP", idx))
+	if ok {
+		binding.ForcePassiveIP = passiveIP
+		isSet = true
+	}
+
+	if isSet {
+		if len(globalConf.FTPD.Bindings) > idx {
+			globalConf.FTPD.Bindings[idx] = binding
+		} else {
+			globalConf.FTPD.Bindings = append(globalConf.FTPD.Bindings, binding)
+		}
+	}
+}
+
+func getWebDAVDBindingFromEnv(idx int) {
+	binding := webdavd.Binding{}
+	if len(globalConf.WebDAVD.Bindings) > idx {
+		binding = globalConf.WebDAVD.Bindings[idx]
+	}
+
+	isSet := false
+
+	port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__PORT", idx))
+	if ok {
+		binding.Port = port
+		isSet = true
+	}
+
+	address, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__ADDRESS", idx))
+	if ok {
+		binding.Address = address
+		isSet = true
+	}
+
+	enableHTTPS, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__ENABLE_HTTPS", idx))
+	if ok {
+		binding.EnableHTTPS = enableHTTPS
+		isSet = true
+	}
+
+	if isSet {
+		if len(globalConf.WebDAVD.Bindings) > idx {
+			globalConf.WebDAVD.Bindings[idx] = binding
+		} else {
+			globalConf.WebDAVD.Bindings = append(globalConf.WebDAVD.Bindings, binding)
+		}
+	}
+}
+
 func setViperDefaults() {
 func setViperDefaults() {
 	viper.SetDefault("common.idle_timeout", globalConf.Common.IdleTimeout)
 	viper.SetDefault("common.idle_timeout", globalConf.Common.IdleTimeout)
 	viper.SetDefault("common.upload_mode", globalConf.Common.UploadMode)
 	viper.SetDefault("common.upload_mode", globalConf.Common.UploadMode)
@@ -436,8 +644,6 @@ func setViperDefaults() {
 	viper.SetDefault("common.proxy_allowed", globalConf.Common.ProxyAllowed)
 	viper.SetDefault("common.proxy_allowed", globalConf.Common.ProxyAllowed)
 	viper.SetDefault("common.post_connect_hook", globalConf.Common.PostConnectHook)
 	viper.SetDefault("common.post_connect_hook", globalConf.Common.PostConnectHook)
 	viper.SetDefault("common.max_total_connections", globalConf.Common.MaxTotalConnections)
 	viper.SetDefault("common.max_total_connections", globalConf.Common.MaxTotalConnections)
-	viper.SetDefault("sftpd.bind_port", globalConf.SFTPD.BindPort)
-	viper.SetDefault("sftpd.bind_address", globalConf.SFTPD.BindAddress)
 	viper.SetDefault("sftpd.max_auth_tries", globalConf.SFTPD.MaxAuthTries)
 	viper.SetDefault("sftpd.max_auth_tries", globalConf.SFTPD.MaxAuthTries)
 	viper.SetDefault("sftpd.banner", globalConf.SFTPD.Banner)
 	viper.SetDefault("sftpd.banner", globalConf.SFTPD.Banner)
 	viper.SetDefault("sftpd.host_keys", globalConf.SFTPD.HostKeys)
 	viper.SetDefault("sftpd.host_keys", globalConf.SFTPD.HostKeys)
@@ -449,19 +655,13 @@ func setViperDefaults() {
 	viper.SetDefault("sftpd.enabled_ssh_commands", globalConf.SFTPD.EnabledSSHCommands)
 	viper.SetDefault("sftpd.enabled_ssh_commands", globalConf.SFTPD.EnabledSSHCommands)
 	viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
 	viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
 	viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
 	viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
-	viper.SetDefault("ftpd.bind_port", globalConf.FTPD.BindPort)
-	viper.SetDefault("ftpd.bind_address", globalConf.FTPD.BindAddress)
 	viper.SetDefault("ftpd.banner", globalConf.FTPD.Banner)
 	viper.SetDefault("ftpd.banner", globalConf.FTPD.Banner)
 	viper.SetDefault("ftpd.banner_file", globalConf.FTPD.BannerFile)
 	viper.SetDefault("ftpd.banner_file", globalConf.FTPD.BannerFile)
 	viper.SetDefault("ftpd.active_transfers_port_non_20", globalConf.FTPD.ActiveTransfersPortNon20)
 	viper.SetDefault("ftpd.active_transfers_port_non_20", globalConf.FTPD.ActiveTransfersPortNon20)
-	viper.SetDefault("ftpd.force_passive_ip", globalConf.FTPD.ForcePassiveIP)
 	viper.SetDefault("ftpd.passive_port_range.start", globalConf.FTPD.PassivePortRange.Start)
 	viper.SetDefault("ftpd.passive_port_range.start", globalConf.FTPD.PassivePortRange.Start)
 	viper.SetDefault("ftpd.passive_port_range.end", globalConf.FTPD.PassivePortRange.End)
 	viper.SetDefault("ftpd.passive_port_range.end", globalConf.FTPD.PassivePortRange.End)
 	viper.SetDefault("ftpd.certificate_file", globalConf.FTPD.CertificateFile)
 	viper.SetDefault("ftpd.certificate_file", globalConf.FTPD.CertificateFile)
 	viper.SetDefault("ftpd.certificate_key_file", globalConf.FTPD.CertificateKeyFile)
 	viper.SetDefault("ftpd.certificate_key_file", globalConf.FTPD.CertificateKeyFile)
-	viper.SetDefault("ftpd.tls_mode", globalConf.FTPD.TLSMode)
-	viper.SetDefault("webdavd.bind_port", globalConf.WebDAVD.BindPort)
-	viper.SetDefault("webdavd.bind_address", globalConf.WebDAVD.BindAddress)
 	viper.SetDefault("webdavd.certificate_file", globalConf.WebDAVD.CertificateFile)
 	viper.SetDefault("webdavd.certificate_file", globalConf.WebDAVD.CertificateFile)
 	viper.SetDefault("webdavd.certificate_key_file", globalConf.WebDAVD.CertificateKeyFile)
 	viper.SetDefault("webdavd.certificate_key_file", globalConf.WebDAVD.CertificateKeyFile)
 	viper.SetDefault("webdavd.cors.enabled", globalConf.WebDAVD.Cors.Enabled)
 	viper.SetDefault("webdavd.cors.enabled", globalConf.WebDAVD.Cors.Enabled)
@@ -523,3 +723,27 @@ func setViperDefaults() {
 	viper.SetDefault("telemetry.certificate_file", globalConf.TelemetryConfig.CertificateFile)
 	viper.SetDefault("telemetry.certificate_file", globalConf.TelemetryConfig.CertificateFile)
 	viper.SetDefault("telemetry.certificate_key_file", globalConf.TelemetryConfig.CertificateKeyFile)
 	viper.SetDefault("telemetry.certificate_key_file", globalConf.TelemetryConfig.CertificateKeyFile)
 }
 }
+
+func lookupBoolFromEnv(envName string) (bool, bool) {
+	value, ok := os.LookupEnv(envName)
+	if ok {
+		converted, err := strconv.ParseBool(value)
+		if err == nil {
+			return converted, ok
+		}
+	}
+
+	return false, false
+}
+
+func lookupIntFromEnv(envName string) (int, bool) {
+	value, ok := os.LookupEnv(envName)
+	if ok {
+		converted, err := strconv.ParseInt(value, 10, 16)
+		if err == nil {
+			return int(converted), ok
+		}
+	}
+
+	return 0, false
+}

+ 274 - 9
config/config_test.go

@@ -10,6 +10,7 @@ import (
 
 
 	"github.com/spf13/viper"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/config"
 	"github.com/drakkan/sftpgo/config"
@@ -19,6 +20,7 @@ import (
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
+	"github.com/drakkan/sftpgo/webdavd"
 )
 )
 
 
 const (
 const (
@@ -331,38 +333,300 @@ func TestServiceToStart(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.True(t, config.HasServicesToStart())
 	assert.True(t, config.HasServicesToStart())
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
-	sftpdConf.BindPort = 0
+	sftpdConf.Bindings[0].Port = 0
 	config.SetSFTPDConfig(sftpdConf)
 	config.SetSFTPDConfig(sftpdConf)
 	assert.False(t, config.HasServicesToStart())
 	assert.False(t, config.HasServicesToStart())
 	ftpdConf := config.GetFTPDConfig()
 	ftpdConf := config.GetFTPDConfig()
-	ftpdConf.BindPort = 2121
+	ftpdConf.Bindings[0].Port = 2121
 	config.SetFTPDConfig(ftpdConf)
 	config.SetFTPDConfig(ftpdConf)
 	assert.True(t, config.HasServicesToStart())
 	assert.True(t, config.HasServicesToStart())
-	ftpdConf.BindPort = 0
+	ftpdConf.Bindings[0].Port = 0
 	config.SetFTPDConfig(ftpdConf)
 	config.SetFTPDConfig(ftpdConf)
 	webdavdConf := config.GetWebDAVDConfig()
 	webdavdConf := config.GetWebDAVDConfig()
-	webdavdConf.BindPort = 9000
+	webdavdConf.Bindings[0].Port = 9000
 	config.SetWebDAVDConfig(webdavdConf)
 	config.SetWebDAVDConfig(webdavdConf)
 	assert.True(t, config.HasServicesToStart())
 	assert.True(t, config.HasServicesToStart())
-	webdavdConf.BindPort = 0
+	webdavdConf.Bindings[0].Port = 0
 	config.SetWebDAVDConfig(webdavdConf)
 	config.SetWebDAVDConfig(webdavdConf)
 	assert.False(t, config.HasServicesToStart())
 	assert.False(t, config.HasServicesToStart())
-	sftpdConf.BindPort = 2022
+	sftpdConf.Bindings[0].Port = 2022
 	config.SetSFTPDConfig(sftpdConf)
 	config.SetSFTPDConfig(sftpdConf)
 	assert.True(t, config.HasServicesToStart())
 	assert.True(t, config.HasServicesToStart())
 }
 }
 
 
+//nolint:dupl
+func TestSFTPDBindingsCompatibility(t *testing.T) {
+	reset()
+
+	configDir := ".."
+	confName := tempConfigName + ".json"
+	configFilePath := filepath.Join(configDir, confName)
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	sftpdConf := config.GetSFTPDConfig()
+	require.Len(t, sftpdConf.Bindings, 1)
+	sftpdConf.Bindings = nil
+	sftpdConf.BindPort = 9022           //nolint:staticcheck
+	sftpdConf.BindAddress = "127.0.0.1" //nolint:staticcheck
+	c := make(map[string]sftpd.Configuration)
+	c["sftpd"] = sftpdConf
+	jsonConf, err := json.Marshal(c)
+	assert.NoError(t, err)
+	err = ioutil.WriteFile(configFilePath, jsonConf, os.ModePerm)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	sftpdConf = config.GetSFTPDConfig()
+	// even if there is no binding configuration in sftpd conf we load the default
+	require.Len(t, sftpdConf.Bindings, 1)
+	require.Equal(t, 2022, sftpdConf.Bindings[0].Port)
+	require.Empty(t, sftpdConf.Bindings[0].Address)
+	require.True(t, sftpdConf.Bindings[0].ApplyProxyConfig)
+	// now set the global value to nil and reload the configuration
+	// this time we should get the values setted using the deprecated configuration
+	sftpdConf.Bindings = nil
+	sftpdConf.BindPort = 2022  //nolint:staticcheck
+	sftpdConf.BindAddress = "" //nolint:staticcheck
+	config.SetSFTPDConfig(sftpdConf)
+	require.Nil(t, config.GetSFTPDConfig().Bindings)
+	require.Equal(t, 2022, config.GetSFTPDConfig().BindPort) //nolint:staticcheck
+	require.Empty(t, config.GetSFTPDConfig().BindAddress)    //nolint:staticcheck
+
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	sftpdConf = config.GetSFTPDConfig()
+	require.Len(t, sftpdConf.Bindings, 1)
+	require.Equal(t, 9022, sftpdConf.Bindings[0].Port)
+	require.Equal(t, "127.0.0.1", sftpdConf.Bindings[0].Address)
+	require.True(t, sftpdConf.Bindings[0].ApplyProxyConfig)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+}
+
+//nolint:dupl
+func TestFTPDBindingsCompatibility(t *testing.T) {
+	reset()
+
+	configDir := ".."
+	confName := tempConfigName + ".json"
+	configFilePath := filepath.Join(configDir, confName)
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	ftpdConf := config.GetFTPDConfig()
+	require.Len(t, ftpdConf.Bindings, 1)
+	ftpdConf.Bindings = nil
+	ftpdConf.BindPort = 9022              //nolint:staticcheck
+	ftpdConf.BindAddress = "127.1.0.1"    //nolint:staticcheck
+	ftpdConf.ForcePassiveIP = "127.1.1.1" //nolint:staticcheck
+	ftpdConf.TLSMode = 2                  //nolint:staticcheck
+	c := make(map[string]ftpd.Configuration)
+	c["ftpd"] = ftpdConf
+	jsonConf, err := json.Marshal(c)
+	assert.NoError(t, err)
+	err = ioutil.WriteFile(configFilePath, jsonConf, os.ModePerm)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	ftpdConf = config.GetFTPDConfig()
+	// even if there is no binding configuration in ftpd conf we load the default
+	require.Len(t, ftpdConf.Bindings, 1)
+	require.Equal(t, 0, ftpdConf.Bindings[0].Port)
+	require.Empty(t, ftpdConf.Bindings[0].Address)
+	require.True(t, ftpdConf.Bindings[0].ApplyProxyConfig)
+	// now set the global value to nil and reload the configuration
+	// this time we should get the values setted using the deprecated configuration
+	ftpdConf.Bindings = nil
+	ftpdConf.BindPort = 0     //nolint:staticcheck
+	ftpdConf.BindAddress = "" //nolint:staticcheck
+	config.SetFTPDConfig(ftpdConf)
+	require.Nil(t, config.GetFTPDConfig().Bindings)
+	require.Equal(t, 0, config.GetFTPDConfig().BindPort) //nolint:staticcheck
+	require.Empty(t, config.GetFTPDConfig().BindAddress) //nolint:staticcheck
+
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	ftpdConf = config.GetFTPDConfig()
+	require.Len(t, ftpdConf.Bindings, 1)
+	require.Equal(t, 9022, ftpdConf.Bindings[0].Port)
+	require.Equal(t, "127.1.0.1", ftpdConf.Bindings[0].Address)
+	require.True(t, ftpdConf.Bindings[0].ApplyProxyConfig)
+	require.Equal(t, 2, ftpdConf.Bindings[0].TLSMode)
+	require.Equal(t, "127.1.1.1", ftpdConf.Bindings[0].ForcePassiveIP)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+}
+
+//nolint:dupl
+func TestWebDAVDBindingsCompatibility(t *testing.T) {
+	reset()
+
+	configDir := ".."
+	confName := tempConfigName + ".json"
+	configFilePath := filepath.Join(configDir, confName)
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	webdavConf := config.GetWebDAVDConfig()
+	require.Len(t, webdavConf.Bindings, 1)
+	webdavConf.Bindings = nil
+	webdavConf.BindPort = 9080           //nolint:staticcheck
+	webdavConf.BindAddress = "127.0.0.1" //nolint:staticcheck
+	c := make(map[string]webdavd.Configuration)
+	c["webdavd"] = webdavConf
+	jsonConf, err := json.Marshal(c)
+	assert.NoError(t, err)
+	err = ioutil.WriteFile(configFilePath, jsonConf, os.ModePerm)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	webdavConf = config.GetWebDAVDConfig()
+	// even if there is no binding configuration in webdav conf we load the default
+	require.Len(t, webdavConf.Bindings, 1)
+	require.Equal(t, 0, webdavConf.Bindings[0].Port)
+	require.Empty(t, webdavConf.Bindings[0].Address)
+	require.False(t, webdavConf.Bindings[0].EnableHTTPS)
+	// now set the global value to nil and reload the configuration
+	// this time we should get the values setted using the deprecated configuration
+	webdavConf.Bindings = nil
+	webdavConf.BindPort = 10080 //nolint:staticcheck
+	webdavConf.BindAddress = "" //nolint:staticcheck
+	config.SetWebDAVDConfig(webdavConf)
+	require.Nil(t, config.GetWebDAVDConfig().Bindings)
+	require.Equal(t, 10080, config.GetWebDAVDConfig().BindPort) //nolint:staticcheck
+	require.Empty(t, config.GetWebDAVDConfig().BindAddress)     //nolint:staticcheck
+
+	err = config.LoadConfig(configDir, confName)
+	assert.NoError(t, err)
+	webdavConf = config.GetWebDAVDConfig()
+	require.Len(t, webdavConf.Bindings, 1)
+	require.Equal(t, 9080, webdavConf.Bindings[0].Port)
+	require.Equal(t, "127.0.0.1", webdavConf.Bindings[0].Address)
+	require.False(t, webdavConf.Bindings[0].EnableHTTPS)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+}
+
+func TestSFTPDBindingsFromEnv(t *testing.T) {
+	reset()
+
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__0__ADDRESS", "127.0.0.1")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__0__PORT", "2200")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__0__APPLY_PROXY_CONFIG", "false")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__3__ADDRESS", "127.0.1.1")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__3__PORT", "2203")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__3__APPLY_PROXY_CONFIG", "1")
+	t.Cleanup(func() {
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__0__ADDRESS")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__0__PORT")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__0__APPLY_PROXY_CONFIG")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__3__ADDRESS")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__3__PORT")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__3__APPLY_PROXY_CONFIG")
+	})
+
+	configDir := ".."
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	bindings := config.GetSFTPDConfig().Bindings
+	require.Len(t, bindings, 2)
+	require.Equal(t, 2200, bindings[0].Port)
+	require.Equal(t, "127.0.0.1", bindings[0].Address)
+	require.False(t, bindings[0].ApplyProxyConfig)
+	require.Equal(t, 2203, bindings[1].Port)
+	require.Equal(t, "127.0.1.1", bindings[1].Address)
+	require.True(t, bindings[1].ApplyProxyConfig)
+}
+
+func TestFTPDBindingsFromEnv(t *testing.T) {
+	reset()
+
+	os.Setenv("SFTPGO_FTPD__BINDINGS__0__ADDRESS", "127.0.0.1")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__0__PORT", "2200")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__0__APPLY_PROXY_CONFIG", "f")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__0__TLS_MODE", "2")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IP", "127.0.1.2")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__9__ADDRESS", "127.0.1.1")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__9__PORT", "2203")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__9__APPLY_PROXY_CONFIG", "t")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__9__TLS_MODE", "1")
+	os.Setenv("SFTPGO_FTPD__BINDINGS__9__FORCE_PASSIVE_IP", "127.0.1.1")
+
+	t.Cleanup(func() {
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__ADDRESS")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__PORT")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__APPLY_PROXY_CONFIG")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__TLS_MODE")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IP")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__9__ADDRESS")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__9__PORT")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__9__APPLY_PROXY_CONFIG")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__9__TLS_MODE")
+		os.Unsetenv("SFTPGO_FTPD__BINDINGS__9__FORCE_PASSIVE_IP")
+	})
+
+	configDir := ".."
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	bindings := config.GetFTPDConfig().Bindings
+	require.Len(t, bindings, 2)
+	require.Equal(t, 2200, bindings[0].Port)
+	require.Equal(t, "127.0.0.1", bindings[0].Address)
+	require.False(t, bindings[0].ApplyProxyConfig)
+	require.Equal(t, bindings[0].TLSMode, 2)
+	require.Equal(t, bindings[0].ForcePassiveIP, "127.0.1.2")
+	require.Equal(t, 2203, bindings[1].Port)
+	require.Equal(t, "127.0.1.1", bindings[1].Address)
+	require.True(t, bindings[1].ApplyProxyConfig)
+	require.Equal(t, bindings[1].TLSMode, 1)
+	require.Equal(t, bindings[1].ForcePassiveIP, "127.0.1.1")
+}
+
+func TestWebDAVBindingsFromEnv(t *testing.T) {
+	reset()
+
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__ADDRESS", "127.0.0.1")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__PORT", "8000")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__ENABLE_HTTPS", "0")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ADDRESS", "127.0.1.1")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT", "9000")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS", "1")
+	t.Cleanup(func() {
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ADDRESS")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__PORT")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ENABLE_HTTPS")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ADDRESS")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__PORT")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__2__ENABLE_HTTPS")
+	})
+
+	configDir := ".."
+	err := config.LoadConfig(configDir, "")
+	assert.NoError(t, err)
+	bindings := config.GetWebDAVDConfig().Bindings
+	require.Len(t, bindings, 3)
+	require.Equal(t, 0, bindings[0].Port)
+	require.Empty(t, bindings[0].Address)
+	require.False(t, bindings[0].EnableHTTPS)
+	require.Equal(t, 8000, bindings[1].Port)
+	require.Equal(t, "127.0.0.1", bindings[1].Address)
+	require.False(t, bindings[1].EnableHTTPS)
+	require.Equal(t, 9000, bindings[2].Port)
+	require.Equal(t, "127.0.1.1", bindings[2].Address)
+	require.True(t, bindings[2].EnableHTTPS)
+}
+
 func TestConfigFromEnv(t *testing.T) {
 func TestConfigFromEnv(t *testing.T) {
 	reset()
 	reset()
 
 
-	os.Setenv("SFTPGO_SFTPD__BIND_ADDRESS", "127.0.0.1")
+	os.Setenv("SFTPGO_SFTPD__BINDINGS__0__ADDRESS", "127.0.0.1")
+	os.Setenv("SFTPGO_WEBDAVD__BINDINGS__0__PORT", "12000")
 	os.Setenv("SFTPGO_DATA_PROVIDER__PASSWORD_HASHING__ARGON2_OPTIONS__ITERATIONS", "41")
 	os.Setenv("SFTPGO_DATA_PROVIDER__PASSWORD_HASHING__ARGON2_OPTIONS__ITERATIONS", "41")
 	os.Setenv("SFTPGO_DATA_PROVIDER__POOL_SIZE", "10")
 	os.Setenv("SFTPGO_DATA_PROVIDER__POOL_SIZE", "10")
 	os.Setenv("SFTPGO_DATA_PROVIDER__ACTIONS__EXECUTE_ON", "add")
 	os.Setenv("SFTPGO_DATA_PROVIDER__ACTIONS__EXECUTE_ON", "add")
 	os.Setenv("SFTPGO_KMS__SECRETS__URL", "local")
 	os.Setenv("SFTPGO_KMS__SECRETS__URL", "local")
 	os.Setenv("SFTPGO_KMS__SECRETS__MASTER_KEY_PATH", "path")
 	os.Setenv("SFTPGO_KMS__SECRETS__MASTER_KEY_PATH", "path")
 	t.Cleanup(func() {
 	t.Cleanup(func() {
-		os.Unsetenv("SFTPGO_SFTPD__BIND_ADDRESS")
+		os.Unsetenv("SFTPGO_SFTPD__BINDINGS__0__ADDRESS")
+		os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__0__PORT")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__PASSWORD_HASHING__ARGON2_OPTIONS__ITERATIONS")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__PASSWORD_HASHING__ARGON2_OPTIONS__ITERATIONS")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__POOL_SIZE")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__POOL_SIZE")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__ACTIONS__EXECUTE_ON")
 		os.Unsetenv("SFTPGO_DATA_PROVIDER__ACTIONS__EXECUTE_ON")
@@ -372,7 +636,8 @@ func TestConfigFromEnv(t *testing.T) {
 	err := config.LoadConfig(".", "invalid config")
 	err := config.LoadConfig(".", "invalid config")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	sftpdConfig := config.GetSFTPDConfig()
 	sftpdConfig := config.GetSFTPDConfig()
-	assert.Equal(t, "127.0.0.1", sftpdConfig.BindAddress)
+	assert.Equal(t, "127.0.0.1", sftpdConfig.Bindings[0].Address)
+	assert.Equal(t, 12000, config.GetWebDAVDConfig().Bindings[0].Port)
 	dataProviderConf := config.GetProviderConf()
 	dataProviderConf := config.GetProviderConf()
 	assert.Equal(t, uint32(41), dataProviderConf.PasswordHashing.Argon2Options.Iterations)
 	assert.Equal(t, uint32(41), dataProviderConf.PasswordHashing.Argon2Options.Iterations)
 	assert.Equal(t, 10, dataProviderConf.PoolSize)
 	assert.Equal(t, 10, dataProviderConf.PoolSize)

+ 25 - 11
docs/full-configuration.md

@@ -65,8 +65,12 @@ The configuration file contains the following sections:
   - `post_connect_hook`, string. Absolute path to the command to execute or HTTP URL to notify. See [Post connect hook](./post-connect-hook.md) for more details. Leave empty to disable
   - `post_connect_hook`, string. Absolute path to the command to execute or HTTP URL to notify. See [Post connect hook](./post-connect-hook.md) for more details. Leave empty to disable
   - `max_total_connections`, integer. Maximum number of concurrent client connections. 0 means unlimited
   - `max_total_connections`, integer. Maximum number of concurrent client connections. 0 means unlimited
 - **"sftpd"**, the configuration for the SFTP server
 - **"sftpd"**, the configuration for the SFTP server
-  - `bind_port`, integer. The port used for serving SFTP requests. 0 means disabled. Default: 2022
-  - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: ""
+  - `bindings`, list of structs. Each struct has the following fields:
+    - `port`, integer. The port used for serving SFTP requests. 0 means disabled. Default: 2022
+    - `address`, string. Leave blank to listen on all available network interfaces. Default: ""
+    - `apply_proxy_config`, boolean. If enabled the common proxy configuration, if any, will be applied. Default `true`
+  - `bind_port`, integer. Deprecated, please use `bindings`
+  - `bind_address`, string. Deprecated, please use `bindings`
   - `idle_timeout`, integer. Deprecated, please use the same key in `common` section.
   - `idle_timeout`, integer. Deprecated, please use the same key in `common` section.
   - `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts is unlimited. If set to zero, the number of attempts is limited to 6.
   - `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts is unlimited. If set to zero, the number of attempts is limited to 6.
   - `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_<version>`, for example `SSH-2.0-SFTPGo_0.9.5`
   - `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_<version>`, for example `SSH-2.0-SFTPGo_0.9.5`
@@ -87,21 +91,31 @@ The configuration file contains the following sections:
   - `proxy_protocol`, integer.  Deprecated, please use the same key in `common` section.
   - `proxy_protocol`, integer.  Deprecated, please use the same key in `common` section.
   - `proxy_allowed`, list of strings. Deprecated, please use the same key in `common` section.
   - `proxy_allowed`, list of strings. Deprecated, please use the same key in `common` section.
 - **"ftpd"**, the configuration for the FTP server
 - **"ftpd"**, the configuration for the FTP server
-  - `bind_port`, integer. The port used for serving FTP requests. 0 means disabled. Default: 0.
-  - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "".
+  - `bindings`, list of structs. Each struct has the following fields:
+    - `port`, integer. The port used for serving FTP requests. 0 means disabled. Default: 0
+    - `address`, string. Leave blank to listen on all available network interfaces. Default: ""
+    - `apply_proxy_config`, boolean. If enabled the common proxy configuration, if any, will be applied. Default `true`
+    - `tls_mode`, integer. 0 means accept both cleartext and encrypted sessions. 1 means TLS is required for both control and data connection. 2 means implicit TLS. Do not enable this blindly, please check that a proper TLS config is in place if you set `tls_mode` is different from 0.
+    - `force_passive_ip`, ip address. External IP address to expose for passive connections. Leavy empty to autodetect. Defaut: "".
+  - `bind_port`, integer. Deprecated, please use `bindings`
+  - `bind_address`, string. Deprecated, please use `bindings`
   - `banner`, string. Greeting banner displayed when a connection first comes in. Leave empty to use the default banner. Default `SFTPGo <version> ready`, for example `SFTPGo 1.0.0-dev ready`.
   - `banner`, string. Greeting banner displayed when a connection first comes in. Leave empty to use the default banner. Default `SFTPGo <version> ready`, for example `SFTPGo 1.0.0-dev ready`.
   - `banner_file`, path to the banner file. The contents of the specified file, if any, are displayed when someone connects to the server. It can be a path relative to the config dir or an absolute one. If set, it overrides the banner string provided by the `banner` option. Leave empty to disable.
   - `banner_file`, path to the banner file. The contents of the specified file, if any, are displayed when someone connects to the server. It can be a path relative to the config dir or an absolute one. If set, it overrides the banner string provided by the `banner` option. Leave empty to disable.
   - `active_transfers_port_non_20`, boolean. Do not impose the port 20 for active data transfers. Enabling this option allows to run SFTPGo with less privilege. Default: false.
   - `active_transfers_port_non_20`, boolean. Do not impose the port 20 for active data transfers. Enabling this option allows to run SFTPGo with less privilege. Default: false.
-  - `force_passive_ip`, ip address. External IP address to expose for passive connections. Leavy empty to autodetect. Defaut: "".
+  - `force_passive_ip`, ip address.  Deprecated, please use `bindings`
   - `passive_port_range`, struct containing the key `start` and `end`. Port Range for data connections. Random if not specified. Default range is 50000-50100.
   - `passive_port_range`, struct containing the key `start` and `end`. Port Range for data connections. Random if not specified. Default range is 50000-50100.
   - `certificate_file`, string. Certificate for FTPS. This can be an absolute path or a path relative to the config dir.
   - `certificate_file`, string. Certificate for FTPS. 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 accept both plain FTP an explicit FTP over TLS. 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.
-  - `tls_mode`, integer. 0 means accept both cleartext and encrypted sessions. 1 means TLS is required for both control and data connection. Do not enable this blindly, please check that a proper TLS config is in place or no login will be allowed if `tls_mode` is 1.
+  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and the private key are required to enable explicit and implicit TLS. 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.
+  - `tls_mode`, integer. Deprecated, please use `bindings`
 - **webdavd**, the configuration for the WebDAV server, more info [here](./webdav.md)
 - **webdavd**, the configuration for the WebDAV server, more info [here](./webdav.md)
-  - `bind_port`, integer. The port used for serving WebDAV requests. 0 means disabled. Default: 0.
-  - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "".
+  - `bindings`, list of structs. Each struct has the following fields:
+    - `port`, integer. The port used for serving WebDAV requests. 0 means disabled. Default: 0.
+    - `address`, string. Leave blank to listen on all available network interfaces. Default: "".
+    - `enable_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false`
+  - `bind_port`, integer. Deprecated, please use `bindings`
+  - `bind_address`, string. Deprecated, please use `bindings`
   - `certificate_file`, string. Certificate for WebDAV over HTTPS. This can be an absolute path or a path relative to the config dir.
   - `certificate_file`, string. Certificate for WebDAV over 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.
+  - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. A certificate and a private key are required to enable 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.
   - `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values.
   - `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values.
     - `enabled`, boolean, set to true to enable CORS.
     - `enabled`, boolean, set to true to enable CORS.
     - `allowed_origins`, list of strings.
     - `allowed_origins`, list of strings.
@@ -210,7 +224,7 @@ You can also override all the available configuration options using environment
 
 
 Let's see some examples:
 Let's see some examples:
 
 
-- To set sftpd `bind_port`, you need to define the env var `SFTPGO_SFTPD__BIND_PORT`
+- To set the `port` for the first sftpd binding, you need to define the env var `SFTPGO_SFTPD__BINDINGS__0__PORT`
 - To set the `execute_on` actions, you need to define the env var `SFTPGO_COMMON__ACTIONS__EXECUTE_ON`. For example `SFTPGO_COMMON__ACTIONS__EXECUTE_ON=upload,download`
 - To set the `execute_on` actions, you need to define the env var `SFTPGO_COMMON__ACTIONS__EXECUTE_ON`. For example `SFTPGO_COMMON__ACTIONS__EXECUTE_ON=upload,download`
 
 
 ## Telemetry Server
 ## Telemetry Server

+ 102 - 29
ftpd/ftpd.go

@@ -7,6 +7,7 @@ import (
 
 
 	ftpserver "github.com/fclairamb/ftpserverlib"
 	ftpserver "github.com/fclairamb/ftpserverlib"
 
 
+	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
 )
 )
@@ -16,9 +17,54 @@ const (
 )
 )
 
 
 var (
 var (
-	server *Server
+	certMgr       *common.CertManager
+	serviceStatus ServiceStatus
 )
 )
 
 
+// Binding defines the configuration for a network listener
+type Binding struct {
+	// The address to listen on. A blank value means listen on all available network interfaces.
+	Address string `json:"address" mapstructure:"address"`
+	// The port used for serving requests
+	Port int `json:"port" mapstructure:"port"`
+	// apply the proxy configuration, if any, for this binding
+	ApplyProxyConfig bool `json:"apply_proxy_config" mapstructure:"apply_proxy_config"`
+	// set to 1 to require TLS for both data and control connection
+	TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
+	// External IP address to expose for passive connections.
+	ForcePassiveIP string `json:"force_passive_ip" mapstructure:"force_passive_ip"`
+}
+
+// GetAddress returns the binding address
+func (b *Binding) GetAddress() string {
+	return fmt.Sprintf("%s:%d", b.Address, b.Port)
+}
+
+// IsValid returns true if the binding port is > 0
+func (b *Binding) IsValid() bool {
+	return b.Port > 0
+}
+
+// HasProxy returns true if the proxy protocol is active for this binding
+func (b *Binding) HasProxy() bool {
+	return b.ApplyProxyConfig && common.Config.ProxyProtocol > 0
+}
+
+// GetTLSDescription returns the TLS mode as string
+func (b *Binding) GetTLSDescription() string {
+	if certMgr == nil {
+		return "Disabled"
+	}
+	switch b.TLSMode {
+	case 1:
+		return "Explicit required"
+	case 2:
+		return "Implicit"
+	}
+
+	return "Plain and explicit"
+}
+
 // PortRange defines a port range
 // PortRange defines a port range
 type PortRange struct {
 type PortRange struct {
 	// Range start
 	// Range start
@@ -30,18 +76,19 @@ type PortRange struct {
 // ServiceStatus defines the service status
 // ServiceStatus defines the service status
 type ServiceStatus struct {
 type ServiceStatus struct {
 	IsActive         bool      `json:"is_active"`
 	IsActive         bool      `json:"is_active"`
-	Address          string    `json:"address"`
+	Bindings         []Binding `json:"bindings"`
 	PassivePortRange PortRange `json:"passive_port_range"`
 	PassivePortRange PortRange `json:"passive_port_range"`
-	FTPES            string    `json:"ftpes"`
 }
 }
 
 
 // Configuration defines the configuration for the ftp server
 // Configuration defines the configuration for the ftp server
 type Configuration struct {
 type Configuration struct {
-	// The port used for serving FTP requests
+	// Addresses and ports to bind to
+	Bindings []Binding `json:"bindings" mapstructure:"bindings"`
+	// Deprecated: please use Bindings
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
-	// The address to listen on. A blank value means listen on all available network interfaces.
+	// Deprecated: please use Bindings
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
-	// External IP address to expose for passive connections.
+	// Deprecated: please use Bindings
 	ForcePassiveIP string `json:"force_passive_ip" mapstructure:"force_passive_ip"`
 	ForcePassiveIP string `json:"force_passive_ip" mapstructure:"force_passive_ip"`
 	// Greeting banner displayed when a connection first comes in
 	// Greeting banner displayed when a connection first comes in
 	Banner string `json:"banner" mapstructure:"banner"`
 	Banner string `json:"banner" mapstructure:"banner"`
@@ -58,56 +105,82 @@ type Configuration struct {
 	ActiveTransfersPortNon20 bool `json:"active_transfers_port_non_20" mapstructure:"active_transfers_port_non_20"`
 	ActiveTransfersPortNon20 bool `json:"active_transfers_port_non_20" mapstructure:"active_transfers_port_non_20"`
 	// Port Range for data connections. Random if not specified
 	// Port Range for data connections. Random if not specified
 	PassivePortRange PortRange `json:"passive_port_range" mapstructure:"passive_port_range"`
 	PassivePortRange PortRange `json:"passive_port_range" mapstructure:"passive_port_range"`
-	// set to 1 to require TLS for both data and control connection
+	// Deprecated: please use Bindings
 	TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
 	TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
 }
 }
 
 
+// ShouldBind returns true if there is at least a valid binding
+func (c *Configuration) ShouldBind() bool {
+	for _, binding := range c.Bindings {
+		if binding.IsValid() {
+			return true
+		}
+	}
+
+	return false
+}
+
 // Initialize configures and starts the FTP server
 // Initialize configures and starts the FTP server
 func (c *Configuration) Initialize(configDir string) error {
 func (c *Configuration) Initialize(configDir string) error {
-	var err error
 	logger.Debug(logSender, "", "initializing FTP server with config %+v", *c)
 	logger.Debug(logSender, "", "initializing FTP server with config %+v", *c)
-	server, err = NewServer(c, configDir)
-	if err != nil {
-		return err
+	if !c.ShouldBind() {
+		return common.ErrNoBinding
+	}
+
+	certificateFile := getConfigPath(c.CertificateFile, configDir)
+	certificateKeyFile := getConfigPath(c.CertificateKeyFile, configDir)
+	if certificateFile != "" && certificateKeyFile != "" {
+		mgr, err := common.NewCertManager(certificateFile, certificateKeyFile, logSender)
+		if err != nil {
+			return err
+		}
+		certMgr = mgr
 	}
 	}
-	server.status = ServiceStatus{
-		IsActive:         true,
-		Address:          fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort),
+	serviceStatus = ServiceStatus{
+		Bindings:         nil,
 		PassivePortRange: c.PassivePortRange,
 		PassivePortRange: c.PassivePortRange,
-		FTPES:            "Disabled",
 	}
 	}
-	if c.CertificateFile != "" && c.CertificateKeyFile != "" {
-		if c.TLSMode == 1 {
-			server.status.FTPES = "Required"
-		} else {
-			server.status.FTPES = "Enabled"
+
+	exitChannel := make(chan error)
+
+	for idx, binding := range c.Bindings {
+		if !binding.IsValid() {
+			continue
 		}
 		}
+
+		server := NewServer(c, configDir, binding, idx)
+
+		go func(s *Server) {
+			ftpServer := ftpserver.NewFtpServer(s)
+			exitChannel <- ftpServer.ListenAndServe()
+		}(server)
+
+		serviceStatus.Bindings = append(serviceStatus.Bindings, binding)
 	}
 	}
-	ftpServer := ftpserver.NewFtpServer(server)
-	return ftpServer.ListenAndServe()
+
+	serviceStatus.IsActive = true
+
+	return <-exitChannel
 }
 }
 
 
 // ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
 // ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
 func ReloadTLSCertificate() error {
 func ReloadTLSCertificate() error {
-	if server != nil && server.certMgr != nil {
-		return server.certMgr.LoadCertificate(logSender)
+	if certMgr != nil {
+		return certMgr.LoadCertificate(logSender)
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
 // GetStatus returns the server status
 // GetStatus returns the server status
 func GetStatus() ServiceStatus {
 func GetStatus() ServiceStatus {
-	if server == nil {
-		return ServiceStatus{}
-	}
-	return server.status
+	return serviceStatus
 }
 }
 
 
 func getConfigPath(name, configDir string) string {
 func getConfigPath(name, configDir string) string {
 	if !utils.IsFileInputValid(name) {
 	if !utils.IsFileInputValid(name) {
 		return ""
 		return ""
 	}
 	}
-	if len(name) > 0 && !filepath.IsAbs(name) {
+	if name != "" && !filepath.IsAbs(name) {
 		return filepath.Join(configDir, name)
 		return filepath.Join(configDir, name)
 	}
 	}
 	return name
 	return name

+ 39 - 10
ftpd/ftpd_test.go

@@ -20,6 +20,7 @@ import (
 	"github.com/jlaffaye/ftp"
 	"github.com/jlaffaye/ftp"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/config"
 	"github.com/drakkan/sftpgo/config"
@@ -28,6 +29,7 @@ import (
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/vfs"
 	"github.com/drakkan/sftpgo/vfs"
 )
 )
 
 
@@ -145,7 +147,11 @@ func TestMain(m *testing.M) {
 	httpd.SetBaseURLAndCredentials("http://127.0.0.1:8079", "", "")
 	httpd.SetBaseURLAndCredentials("http://127.0.0.1:8079", "", "")
 
 
 	ftpdConf := config.GetFTPDConfig()
 	ftpdConf := config.GetFTPDConfig()
-	ftpdConf.BindPort = 2121
+	ftpdConf.Bindings = []ftpd.Binding{
+		{
+			Port: 2121,
+		},
+	}
 	ftpdConf.PassivePortRange.Start = 0
 	ftpdConf.PassivePortRange.Start = 0
 	ftpdConf.PassivePortRange.End = 0
 	ftpdConf.PassivePortRange.End = 0
 	ftpdConf.BannerFile = bannerFileName
 	ftpdConf.BannerFile = bannerFileName
@@ -154,7 +160,11 @@ func TestMain(m *testing.M) {
 
 
 	// required to test sftpfs
 	// required to test sftpfs
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
-	sftpdConf.BindPort = 2122
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port: 2122,
+		},
+	}
 	sftpdConf.HostKeys = []string{filepath.Join(os.TempDir(), "id_ed25519")}
 	sftpdConf.HostKeys = []string{filepath.Join(os.TempDir(), "id_ed25519")}
 
 
 	extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
 	extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
@@ -190,9 +200,9 @@ func TestMain(m *testing.M) {
 		}
 		}
 	}()
 	}()
 
 
-	waitTCPListening(fmt.Sprintf("%s:%d", ftpdConf.BindAddress, ftpdConf.BindPort))
+	waitTCPListening(ftpdConf.Bindings[0].GetAddress())
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
-	waitTCPListening(fmt.Sprintf("%s:%d", sftpdConf.BindAddress, sftpdConf.BindPort))
+	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	ftpd.ReloadTLSCertificate() //nolint:errcheck
 	ftpd.ReloadTLSCertificate() //nolint:errcheck
 
 
 	exitCode := m.Run()
 	exitCode := m.Run()
@@ -206,16 +216,35 @@ func TestMain(m *testing.M) {
 	os.Exit(exitCode)
 	os.Exit(exitCode)
 }
 }
 
 
-func TestInitialization(t *testing.T) {
+func TestInitializationFailure(t *testing.T) {
 	ftpdConf := config.GetFTPDConfig()
 	ftpdConf := config.GetFTPDConfig()
-	ftpdConf.BindPort = 2121
+	ftpdConf.Bindings = []ftpd.Binding{}
 	ftpdConf.CertificateFile = filepath.Join(os.TempDir(), "test_ftpd.crt")
 	ftpdConf.CertificateFile = filepath.Join(os.TempDir(), "test_ftpd.crt")
 	ftpdConf.CertificateKeyFile = filepath.Join(os.TempDir(), "test_ftpd.key")
 	ftpdConf.CertificateKeyFile = filepath.Join(os.TempDir(), "test_ftpd.key")
-	ftpdConf.TLSMode = 1
 	err := ftpdConf.Initialize(configDir)
 	err := ftpdConf.Initialize(configDir)
-	assert.Error(t, err)
-	status := ftpd.GetStatus()
-	assert.True(t, status.IsActive)
+	require.EqualError(t, err, common.ErrNoBinding.Error())
+	ftpdConf.Bindings = []ftpd.Binding{
+		{
+			Port: 0,
+		},
+		{
+			Port: 2121,
+		},
+	}
+	ftpdConf.BannerFile = "a-missing-file"
+	err = ftpdConf.Initialize(configDir)
+	require.Error(t, err)
+
+	ftpdConf.BannerFile = ""
+	ftpdConf.Bindings[1].TLSMode = 10
+	err = ftpdConf.Initialize(configDir)
+	require.Error(t, err)
+
+	ftpdConf.CertificateFile = ""
+	ftpdConf.CertificateKeyFile = ""
+	ftpdConf.Bindings[1].TLSMode = 1
+	err = ftpdConf.Initialize(configDir)
+	require.Error(t, err)
 }
 }
 
 
 func TestBasicFTPHandling(t *testing.T) {
 func TestBasicFTPHandling(t *testing.T) {

+ 1 - 1
ftpd/handler.go

@@ -45,7 +45,7 @@ func (c *Connection) GetRemoteAddress() string {
 
 
 // Disconnect disconnects the client
 // Disconnect disconnects the client
 func (c *Connection) Disconnect() error {
 func (c *Connection) Disconnect() error {
-	return c.clientContext.Close(ftpserver.StatusServiceNotAvailable, "connection closed")
+	return c.clientContext.Close(0, "")
 }
 }
 
 
 // GetCommand returns an empty string
 // GetCommand returns an empty string

+ 45 - 15
ftpd/internal_test.go

@@ -120,37 +120,56 @@ func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir st
 }
 }
 
 
 func TestInitialization(t *testing.T) {
 func TestInitialization(t *testing.T) {
+	oldMgr := certMgr
+	certMgr = nil
+
+	binding := Binding{
+		Port: 2121,
+	}
 	c := &Configuration{
 	c := &Configuration{
-		BindPort:           2121,
+		Bindings:           []Binding{binding},
 		CertificateFile:    "acert",
 		CertificateFile:    "acert",
 		CertificateKeyFile: "akey",
 		CertificateKeyFile: "akey",
 	}
 	}
+	assert.False(t, binding.HasProxy())
+	assert.Equal(t, "Disabled", binding.GetTLSDescription())
 	err := c.Initialize(configDir)
 	err := c.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
 	c.CertificateFile = ""
 	c.CertificateFile = ""
 	c.CertificateKeyFile = ""
 	c.CertificateKeyFile = ""
 	c.BannerFile = "afile"
 	c.BannerFile = "afile"
-	server, err := NewServer(c, configDir)
-	if assert.NoError(t, err) {
-		assert.Equal(t, "", server.initialMsg)
-		_, err = server.GetTLSConfig()
-		assert.Error(t, err)
-	}
+	server := NewServer(c, configDir, binding, 0)
+	assert.Equal(t, "", server.initialMsg)
+	_, err = server.GetTLSConfig()
+	assert.Error(t, err)
+
+	binding.TLSMode = 1
+	server = NewServer(c, configDir, binding, 0)
+	_, err = server.GetSettings()
+	assert.Error(t, err)
+
 	err = ReloadTLSCertificate()
 	err = ReloadTLSCertificate()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+
+	certMgr = oldMgr
 }
 }
 
 
 func TestServerGetSettings(t *testing.T) {
 func TestServerGetSettings(t *testing.T) {
 	oldConfig := common.Config
 	oldConfig := common.Config
+
+	binding := Binding{
+		Port:             2121,
+		ApplyProxyConfig: true,
+	}
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 2121,
+		Bindings: []Binding{binding},
 		PassivePortRange: PortRange{
 		PassivePortRange: PortRange{
 			Start: 10000,
 			Start: 10000,
 			End:   11000,
 			End:   11000,
 		},
 		},
 	}
 	}
-	server, err := NewServer(c, configDir)
-	assert.NoError(t, err)
+	assert.False(t, binding.HasProxy())
+	server := NewServer(c, configDir, binding, 0)
 	settings, err := server.GetSettings()
 	settings, err := server.GetSettings()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, 10000, settings.PassiveTransferPortRange.Start)
 	assert.Equal(t, 10000, settings.PassiveTransferPortRange.Start)
@@ -158,12 +177,21 @@ func TestServerGetSettings(t *testing.T) {
 
 
 	common.Config.ProxyProtocol = 1
 	common.Config.ProxyProtocol = 1
 	common.Config.ProxyAllowed = []string{"invalid"}
 	common.Config.ProxyAllowed = []string{"invalid"}
+	assert.True(t, binding.HasProxy())
 	_, err = server.GetSettings()
 	_, err = server.GetSettings()
 	assert.Error(t, err)
 	assert.Error(t, err)
-	server.config.BindPort = 8021
+	server.binding.Port = 8021
 	_, err = server.GetSettings()
 	_, err = server.GetSettings()
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
+	assert.Equal(t, "Plain and explicit", binding.GetTLSDescription())
+
+	binding.TLSMode = 1
+	assert.Equal(t, "Explicit required", binding.GetTLSDescription())
+
+	binding.TLSMode = 2
+	assert.Equal(t, "Implicit", binding.GetTLSDescription())
+
 	common.Config = oldConfig
 	common.Config = oldConfig
 }
 }
 
 
@@ -171,16 +199,18 @@ func TestUserInvalidParams(t *testing.T) {
 	u := dataprovider.User{
 	u := dataprovider.User{
 		HomeDir: "invalid",
 		HomeDir: "invalid",
 	}
 	}
+	binding := Binding{
+		Port: 2121,
+	}
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 2121,
+		Bindings: []Binding{binding},
 		PassivePortRange: PortRange{
 		PassivePortRange: PortRange{
 			Start: 10000,
 			Start: 10000,
 			End:   11000,
 			End:   11000,
 		},
 		},
 	}
 	}
-	server, err := NewServer(c, configDir)
-	assert.NoError(t, err)
-	_, err = server.validateUser(u, mockFTPClientContext{})
+	server := NewServer(c, configDir, binding, 0)
+	_, err := server.validateUser(u, mockFTPClientContext{})
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	u.Username = "a"
 	u.Username = "a"

+ 26 - 26
ftpd/server.go

@@ -20,31 +20,23 @@ import (
 
 
 // Server implements the ftpserverlib MainDriver interface
 // Server implements the ftpserverlib MainDriver interface
 type Server struct {
 type Server struct {
+	ID           int
 	config       *Configuration
 	config       *Configuration
-	certMgr      *common.CertManager
 	initialMsg   string
 	initialMsg   string
 	statusBanner string
 	statusBanner string
-	status       ServiceStatus
+	binding      Binding
 }
 }
 
 
 // NewServer returns a new FTP server driver
 // NewServer returns a new FTP server driver
-func NewServer(config *Configuration, configDir string) (*Server, error) {
-	var err error
+func NewServer(config *Configuration, configDir string, binding Binding, id int) *Server {
 	server := &Server{
 	server := &Server{
 		config:       config,
 		config:       config,
-		certMgr:      nil,
 		initialMsg:   config.Banner,
 		initialMsg:   config.Banner,
 		statusBanner: fmt.Sprintf("SFTPGo %v FTP Server", version.Get().Version),
 		statusBanner: fmt.Sprintf("SFTPGo %v FTP Server", version.Get().Version),
+		binding:      binding,
+		ID:           id,
 	}
 	}
-	certificateFile := getConfigPath(config.CertificateFile, configDir)
-	certificateKeyFile := getConfigPath(config.CertificateKeyFile, configDir)
-	if certificateFile != "" && certificateKeyFile != "" {
-		server.certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
-		if err != nil {
-			return server, err
-		}
-	}
-	if len(config.BannerFile) > 0 {
+	if config.BannerFile != "" {
 		bannerFilePath := config.BannerFile
 		bannerFilePath := config.BannerFile
 		if !filepath.IsAbs(bannerFilePath) {
 		if !filepath.IsAbs(bannerFilePath) {
 			bannerFilePath = filepath.Join(configDir, bannerFilePath)
 			bannerFilePath = filepath.Join(configDir, bannerFilePath)
@@ -57,7 +49,7 @@ func NewServer(config *Configuration, configDir string) (*Server, error) {
 			logger.Warn(logSender, "", "unable to read banner file: %v", err)
 			logger.Warn(logSender, "", "unable to read banner file: %v", err)
 		}
 		}
 	}
 	}
-	return server, err
+	return server
 }
 }
 
 
 // GetSettings returns FTP server settings
 // GetSettings returns FTP server settings
@@ -70,10 +62,10 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
 		}
 		}
 	}
 	}
 	var ftpListener net.Listener
 	var ftpListener net.Listener
-	if common.Config.ProxyProtocol > 0 {
-		listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.config.BindAddress, s.config.BindPort))
+	if common.Config.ProxyProtocol > 0 && s.binding.ApplyProxyConfig {
+		listener, err := net.Listen("tcp", s.binding.GetAddress())
 		if err != nil {
 		if err != nil {
-			logger.Warn(logSender, "", "error starting listener on address %s:%d: %v", s.config.BindAddress, s.config.BindPort, err)
+			logger.Warn(logSender, "", "error starting listener on address %v: %v", s.binding.GetAddress(), err)
 			return nil, err
 			return nil, err
 		}
 		}
 		ftpListener, err = common.Config.GetProxyListener(listener)
 		ftpListener, err = common.Config.GetProxyListener(listener)
@@ -83,16 +75,24 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
 		}
 		}
 	}
 	}
 
 
+	if s.binding.TLSMode < 0 || s.binding.TLSMode > 2 {
+		return nil, errors.New("unsupported TLS mode")
+	}
+
+	if s.binding.TLSMode > 0 && certMgr == nil {
+		return nil, errors.New("to enable TLS you need to provide a certificate")
+	}
+
 	return &ftpserver.Settings{
 	return &ftpserver.Settings{
 		Listener:                 ftpListener,
 		Listener:                 ftpListener,
-		ListenAddr:               fmt.Sprintf("%s:%d", s.config.BindAddress, s.config.BindPort),
-		PublicHost:               s.config.ForcePassiveIP,
+		ListenAddr:               s.binding.GetAddress(),
+		PublicHost:               s.binding.ForcePassiveIP,
 		PassiveTransferPortRange: portRange,
 		PassiveTransferPortRange: portRange,
 		ActiveTransferPortNon20:  s.config.ActiveTransfersPortNon20,
 		ActiveTransferPortNon20:  s.config.ActiveTransfersPortNon20,
 		IdleTimeout:              -1,
 		IdleTimeout:              -1,
 		ConnectionTimeout:        20,
 		ConnectionTimeout:        20,
 		Banner:                   s.statusBanner,
 		Banner:                   s.statusBanner,
-		TLSRequired:              ftpserver.TLSRequirement(s.config.TLSMode),
+		TLSRequired:              ftpserver.TLSRequirement(s.binding.TLSMode),
 	}, nil
 	}, nil
 }
 }
 
 
@@ -105,7 +105,7 @@ func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) {
 	if err := common.Config.ExecutePostConnectHook(cc.RemoteAddr().String(), common.ProtocolFTP); err != nil {
 	if err := common.Config.ExecutePostConnectHook(cc.RemoteAddr().String(), common.ProtocolFTP); err != nil {
 		return "", err
 		return "", err
 	}
 	}
-	connID := fmt.Sprintf("%v", cc.ID())
+	connID := fmt.Sprintf("%v_%v", s.ID, cc.ID())
 	user := dataprovider.User{}
 	user := dataprovider.User{}
 	connection := &Connection{
 	connection := &Connection{
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolFTP, user, nil),
 		BaseConnection: common.NewBaseConnection(connID, common.ProtocolFTP, user, nil),
@@ -117,7 +117,7 @@ func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) {
 
 
 // ClientDisconnected is called when the user disconnects, even if he never authenticated
 // ClientDisconnected is called when the user disconnects, even if he never authenticated
 func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
 func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
-	connID := fmt.Sprintf("%v_%v", common.ProtocolFTP, cc.ID())
+	connID := fmt.Sprintf("%v_%v_%v", common.ProtocolFTP, s.ID, cc.ID())
 	common.Connections.Remove(connID)
 	common.Connections.Remove(connID)
 }
 }
 
 
@@ -146,9 +146,9 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
 
 
 // GetTLSConfig returns a TLS Certificate to use
 // GetTLSConfig returns a TLS Certificate to use
 func (s *Server) GetTLSConfig() (*tls.Config, error) {
 func (s *Server) GetTLSConfig() (*tls.Config, error) {
-	if s.certMgr != nil {
+	if certMgr != nil {
 		return &tls.Config{
 		return &tls.Config{
-			GetCertificate: s.certMgr.GetCertificateFunc(),
+			GetCertificate: certMgr.GetCertificateFunc(),
 			MinVersion:     tls.VersionTLS12,
 			MinVersion:     tls.VersionTLS12,
 		}, nil
 		}, nil
 	}
 	}
@@ -193,7 +193,7 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
 		return nil, err
 		return nil, err
 	}
 	}
 	connection := &Connection{
 	connection := &Connection{
-		BaseConnection: common.NewBaseConnection(fmt.Sprintf("%v", cc.ID()), common.ProtocolFTP, user, fs),
+		BaseConnection: common.NewBaseConnection(fmt.Sprintf("%v_%v", s.ID, cc.ID()), common.ProtocolFTP, user, fs),
 		clientContext:  cc,
 		clientContext:  cc,
 	}
 	}
 	err = common.Connections.Swap(connection)
 	err = common.Connections.Swap(connection)

+ 5 - 5
go.mod

@@ -8,10 +8,10 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.12.0
 	github.com/Azure/azure-storage-blob-go v0.12.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b
 	github.com/alexedwards/argon2id v0.0.0-20200802152012-2464efd3196b
-	github.com/aws/aws-sdk-go v1.36.10
+	github.com/aws/aws-sdk-go v1.36.13
 	github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
 	github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
 	github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
 	github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
-	github.com/fclairamb/ftpserverlib v0.10.1-0.20201218011054-66782562d4fd
+	github.com/fclairamb/ftpserverlib v0.11.0
 	github.com/frankban/quicktest v1.11.2 // indirect
 	github.com/frankban/quicktest v1.11.2 // indirect
 	github.com/go-chi/chi v1.5.1
 	github.com/go-chi/chi v1.5.1
 	github.com/go-chi/render v1.0.1
 	github.com/go-chi/render v1.0.1
@@ -45,11 +45,11 @@ require (
 	go.uber.org/automaxprocs v1.3.0
 	go.uber.org/automaxprocs v1.3.0
 	gocloud.dev v0.21.0
 	gocloud.dev v0.21.0
 	gocloud.dev/secrets/hashivault v0.21.0
 	gocloud.dev/secrets/hashivault v0.21.0
-	golang.org/x/crypto v0.0.0-20201217014255-9d1352758620
+	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
 	golang.org/x/net v0.0.0-20201216054612-986b41b23924
 	golang.org/x/net v0.0.0-20201216054612-986b41b23924
-	golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e
+	golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0
 	golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
 	golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
-	golang.org/x/tools v0.0.0-20201215192005-fa10ef0b8743 // indirect
+	golang.org/x/tools v0.0.0-20201221201019-196535612888 // indirect
 	google.golang.org/api v0.36.0
 	google.golang.org/api v0.36.0
 	google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d // indirect
 	google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d // indirect
 	gopkg.in/ini.v1 v1.62.0 // indirect
 	gopkg.in/ini.v1 v1.62.0 // indirect

+ 7 - 7
go.sum

@@ -105,8 +105,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
-github.com/aws/aws-sdk-go v1.36.10 h1:JCBOoVIEJkNcio01MNbmelgOc6+uxANTC3TYYPlcsEE=
-github.com/aws/aws-sdk-go v1.36.10/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
+github.com/aws/aws-sdk-go v1.36.13 h1:RAyssUwg/yM7q874D2PQuIST6uhhyYFFPJtgVG/OujI=
+github.com/aws/aws-sdk-go v1.36.13/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -178,9 +178,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/fclairamb/ftpserverlib v0.10.1-0.20201217134934-fdb96115baf4/go.mod h1:lCDfM4WqDDh/wlVMZP185tEH93I0t5gsY2/lKfrLl3o=
-github.com/fclairamb/ftpserverlib v0.10.1-0.20201218011054-66782562d4fd h1:HDZpKSw2Iztmj/fPI7vlCYvAYaB5W8OT0RglegRxOEE=
-github.com/fclairamb/ftpserverlib v0.10.1-0.20201218011054-66782562d4fd/go.mod h1:lCDfM4WqDDh/wlVMZP185tEH93I0t5gsY2/lKfrLl3o=
+github.com/fclairamb/ftpserverlib v0.11.0 h1:leyKdtf3Xk9POY/akxicXrrHF5804PVqnXlUBUYN4Tk=
+github.com/fclairamb/ftpserverlib v0.11.0/go.mod h1:lCDfM4WqDDh/wlVMZP185tEH93I0t5gsY2/lKfrLl3o=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
 github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
@@ -746,8 +745,9 @@ golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
 golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0 h1:n+DPcgTwkgWzIFpLmoimYR2K2b0Ga5+Os4kayIN0vGo=
+golang.org/x/sys v0.0.0-20201221093633-bc327ba9c2f0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -825,7 +825,7 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.0.0-20201202200335-bef1c476418a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201202200335-bef1c476418a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201203202102-a1a1cbeaa516/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201203202102-a1a1cbeaa516/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201215192005-fa10ef0b8743/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201221201019-196535612888/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 1 - 1
httpd/httpd.go

@@ -2,7 +2,7 @@
 // REST API allows to manage users and quota and to get real time reports for the active connections
 // REST API allows to manage users and quota and to get real time reports for the active connections
 // with possibility of forcibly closing a connection.
 // with possibility of forcibly closing a connection.
 // The OpenAPI 3 schema for the exposed API can be found inside the source tree:
 // The OpenAPI 3 schema for the exposed API can be found inside the source tree:
-// https://github.com/drakkan/sftpgo/tree/master/api/schema/openapi.yaml
+// https://github.com/drakkan/sftpgo/blob/master/httpd/schema/openapi.yaml
 // A basic Web interface to manage users and connections is provided too
 // A basic Web interface to manage users and connections is provided too
 package httpd
 package httpd
 
 

+ 77 - 36
httpd/schema/openapi.yaml

@@ -2,7 +2,7 @@ openapi: 3.0.3
 info:
 info:
   title: SFTPGo
   title: SFTPGo
   description: SFTPGo REST API
   description: SFTPGo REST API
-  version: 2.2.2
+  version: 2.2.3
 
 
 servers:
 servers:
   - url: /api/v1
   - url: /api/v1
@@ -1371,26 +1371,70 @@ components:
           type: string
           type: string
         fingerprint:
         fingerprint:
           type: string
           type: string
-    BaseServiceStatus:
+    SSHBinding:
       type: object
       type: object
       properties:
       properties:
-        is_active:
+        address:
+          type: string
+          description: TCP address the server listen on
+        port:
+          type: integer
+          description: the port used for serving requests
+        apply_proxy_config:
+          type: boolean
+          description: apply the proxy configuration, if any
+    WebDAVBinding:
+      type: object
+      properties:
+        address:
+          type: string
+          description: TCP address the server listen on
+        port:
+          type: integer
+          description: the port used for serving requests
+        enable_https:
           type: boolean
           type: boolean
+    FTPDBinding:
+      type: object
+      properties:
         address:
         address:
           type: string
           type: string
-          description: TCP address the server listen on in the form "host:port"
+          description: TCP address the server listen on
+        port:
+          type: integer
+          description: the port used for serving requests
+        apply_proxy_config:
+          type: boolean
+          description: apply the proxy configuration, if any
+        tls_mode:
+          type: integer
+          enum:
+            - 0
+            - 1
+            - 2
+          description: >
+            * `0` - clear or explicit TLS
+            * `1` - explicit TLS required
+            * `2` - implicit TLS
+        force_passive_ip:
+          type: string
+          description: External IP address to expose for passive connections
     SSHServiceStatus:
     SSHServiceStatus:
-      allOf:
-        - $ref: '#/components/schemas/BaseServiceStatus'
-        - type: object
-          properties:
-            host_keys:
-              type: array
-              items:
-                $ref: '#/components/schemas/SSHHostKey'
-            ssh_commands:
-              type: string
-              description: accepted SSH commands comma separated
+      type: object
+      properties:
+        is_active:
+          type: boolean
+        bindings:
+          type: array
+          items:
+            $ref: '#/components/schemas/SSHBinding'
+        host_keys:
+          type: array
+          items:
+            $ref: '#/components/schemas/SSHHostKey'
+        ssh_commands:
+          type: string
+          description: accepted SSH commands comma separated
     FTPPassivePortRange:
     FTPPassivePortRange:
       type: object
       type: object
       properties:
       properties:
@@ -1399,28 +1443,25 @@ components:
         end:
         end:
           type: integer
           type: integer
     FTPServiceStatus:
     FTPServiceStatus:
-      allOf:
-        - $ref: '#/components/schemas/BaseServiceStatus'
-        - type: object
-          properties:
-            passive_port_range:
-              $ref: '#/components/schemas/FTPPassivePortRange'
-            ftpes:
-              type: string
-              enum:
-                - Disabled
-                - Enabled
-                - Required
+      type: object
+      properties:
+        is_active:
+          type: boolean
+        bindings:
+          type: array
+          items:
+            $ref: '#/components/schemas/FTPDBinding'
+        passive_port_range:
+          $ref: '#/components/schemas/FTPPassivePortRange'
     WebDAVServiceStatus:
     WebDAVServiceStatus:
-      allOf:
-        - $ref: '#/components/schemas/BaseServiceStatus'
-        - type: object
-          properties:
-            protocol:
-              type: string
-              enum:
-                - HTTP
-                - HTTPS
+      type: object
+      properties:
+        is_active:
+          type: boolean
+        bindings:
+          type: array
+          items:
+            $ref: '#/components/schemas/WebDAVBinding'
     DataProviderStatus:
     DataProviderStatus:
       type: object
       type: object
       properties:
       properties:

+ 3 - 3
service/service.go

@@ -129,7 +129,7 @@ func (s *Service) startServices() {
 	webDavDConf := config.GetWebDAVDConfig()
 	webDavDConf := config.GetWebDAVDConfig()
 	telemetryConf := config.GetTelemetryConfig()
 	telemetryConf := config.GetTelemetryConfig()
 
 
-	if sftpdConf.BindPort > 0 {
+	if sftpdConf.ShouldBind() {
 		go func() {
 		go func() {
 			logger.Debug(logSender, "", "initializing SFTP server with config %+v", sftpdConf)
 			logger.Debug(logSender, "", "initializing SFTP server with config %+v", sftpdConf)
 			if err := sftpdConf.Initialize(s.ConfigDir); err != nil {
 			if err := sftpdConf.Initialize(s.ConfigDir); err != nil {
@@ -158,7 +158,7 @@ func (s *Service) startServices() {
 			logger.DebugToConsole("HTTP server not started, disabled in config file")
 			logger.DebugToConsole("HTTP server not started, disabled in config file")
 		}
 		}
 	}
 	}
-	if ftpdConf.BindPort > 0 {
+	if ftpdConf.ShouldBind() {
 		go func() {
 		go func() {
 			if err := ftpdConf.Initialize(s.ConfigDir); err != nil {
 			if err := ftpdConf.Initialize(s.ConfigDir); err != nil {
 				logger.Error(logSender, "", "could not start FTP server: %v", err)
 				logger.Error(logSender, "", "could not start FTP server: %v", err)
@@ -170,7 +170,7 @@ func (s *Service) startServices() {
 	} else {
 	} else {
 		logger.Debug(logSender, "", "FTP server not started, disabled in config file")
 		logger.Debug(logSender, "", "FTP server not started, disabled in config file")
 	}
 	}
-	if webDavDConf.BindPort > 0 {
+	if webDavDConf.ShouldBind() {
 		go func() {
 		go func() {
 			if err := webDavDConf.Initialize(s.ConfigDir); err != nil {
 			if err := webDavDConf.Initialize(s.ConfigDir); err != nil {
 				logger.Error(logSender, "", "could not start WebDAV server: %v", err)
 				logger.Error(logSender, "", "could not start WebDAV server: %v", err)

+ 36 - 26
service/service_portable.go

@@ -15,11 +15,13 @@ import (
 
 
 	"github.com/drakkan/sftpgo/config"
 	"github.com/drakkan/sftpgo/config"
 	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/ftpd"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/version"
 	"github.com/drakkan/sftpgo/version"
+	"github.com/drakkan/sftpgo/webdavd"
 )
 )
 
 
 // StartPortableMode starts the service in portable mode
 // StartPortableMode starts the service in portable mode
@@ -49,13 +51,17 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
 	config.SetHTTPDConfig(httpdConf)
 	config.SetHTTPDConfig(httpdConf)
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf.MaxAuthTries = 12
 	sftpdConf.MaxAuthTries = 12
-	sftpdConf.BindPort = sftpdPort
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port: sftpdPort,
+		},
+	}
 	if sftpdPort >= 0 {
 	if sftpdPort >= 0 {
 		if sftpdPort > 0 {
 		if sftpdPort > 0 {
-			sftpdConf.BindPort = sftpdPort
+			sftpdConf.Bindings[0].Port = sftpdPort
 		} else {
 		} else {
 			// dynamic ports starts from 49152
 			// dynamic ports starts from 49152
-			sftpdConf.BindPort = 49152 + rand.Intn(15000)
+			sftpdConf.Bindings[0].Port = 49152 + rand.Intn(15000)
 		}
 		}
 		if utils.IsStringInSlice("*", enabledSSHCommands) {
 		if utils.IsStringInSlice("*", enabledSSHCommands) {
 			sftpdConf.EnabledSSHCommands = sftpd.GetSupportedSSHCommands()
 			sftpdConf.EnabledSSHCommands = sftpd.GetSupportedSSHCommands()
@@ -67,11 +73,13 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
 
 
 	if ftpPort >= 0 {
 	if ftpPort >= 0 {
 		ftpConf := config.GetFTPDConfig()
 		ftpConf := config.GetFTPDConfig()
+		binding := ftpd.Binding{}
 		if ftpPort > 0 {
 		if ftpPort > 0 {
-			ftpConf.BindPort = ftpPort
+			binding.Port = ftpPort
 		} else {
 		} else {
-			ftpConf.BindPort = 49152 + rand.Intn(15000)
+			binding.Port = 49152 + rand.Intn(15000)
 		}
 		}
+		ftpConf.Bindings = []ftpd.Binding{binding}
 		ftpConf.Banner = fmt.Sprintf("SFTPGo portable %v ready", version.Get().Version)
 		ftpConf.Banner = fmt.Sprintf("SFTPGo portable %v ready", version.Get().Version)
 		ftpConf.CertificateFile = ftpsCert
 		ftpConf.CertificateFile = ftpsCert
 		ftpConf.CertificateKeyFile = ftpsKey
 		ftpConf.CertificateKeyFile = ftpsKey
@@ -80,10 +88,11 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
 
 
 	if webdavPort >= 0 {
 	if webdavPort >= 0 {
 		webDavConf := config.GetWebDAVDConfig()
 		webDavConf := config.GetWebDAVDConfig()
+		binding := webdavd.Binding{}
 		if webdavPort > 0 {
 		if webdavPort > 0 {
-			webDavConf.BindPort = webdavPort
+			binding.Port = webdavPort
 		} else {
 		} else {
-			webDavConf.BindPort = 49152 + rand.Intn(15000)
+			binding.Port = 49152 + rand.Intn(15000)
 		}
 		}
 		webDavConf.CertificateFile = webDavCert
 		webDavConf.CertificateFile = webDavCert
 		webDavConf.CertificateKeyFile = webDavKey
 		webDavConf.CertificateKeyFile = webDavKey
@@ -106,19 +115,19 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort int, enabledS
 
 
 func (s *Service) getServiceOptionalInfoString() string {
 func (s *Service) getServiceOptionalInfoString() string {
 	var info strings.Builder
 	var info strings.Builder
-	if config.GetSFTPDConfig().BindPort > 0 {
-		info.WriteString(fmt.Sprintf("SFTP port: %v ", config.GetSFTPDConfig().BindPort))
+	if config.GetSFTPDConfig().Bindings[0].IsValid() {
+		info.WriteString(fmt.Sprintf("SFTP port: %v ", config.GetSFTPDConfig().Bindings[0].Port))
 	}
 	}
-	if config.GetFTPDConfig().BindPort > 0 {
-		info.WriteString(fmt.Sprintf("FTP port: %v ", config.GetFTPDConfig().BindPort))
+	if config.GetFTPDConfig().Bindings[0].IsValid() {
+		info.WriteString(fmt.Sprintf("FTP port: %v ", config.GetFTPDConfig().Bindings[0].Port))
 	}
 	}
-	if config.GetWebDAVDConfig().BindPort > 0 {
+	if config.GetWebDAVDConfig().Bindings[0].IsValid() {
 		scheme := "http"
 		scheme := "http"
 		if config.GetWebDAVDConfig().CertificateFile != "" && config.GetWebDAVDConfig().CertificateKeyFile != "" {
 		if config.GetWebDAVDConfig().CertificateFile != "" && config.GetWebDAVDConfig().CertificateKeyFile != "" {
 			scheme = "https"
 			scheme = "https"
 		}
 		}
 		info.WriteString(fmt.Sprintf("WebDAV URL: %v://<your IP>:%v/%v",
 		info.WriteString(fmt.Sprintf("WebDAV URL: %v://<your IP>:%v/%v",
-			scheme, config.GetWebDAVDConfig().BindPort, s.PortableUser.Username))
+			scheme, config.GetWebDAVDConfig().Bindings[0].Port, s.PortableUser.Username))
 	}
 	}
 	return info.String()
 	return info.String()
 }
 }
@@ -143,14 +152,14 @@ func (s *Service) advertiseServices(advertiseService, advertiseCredentials bool)
 			}
 			}
 		}
 		}
 		sftpdConf := config.GetSFTPDConfig()
 		sftpdConf := config.GetSFTPDConfig()
-		if sftpdConf.BindPort > 0 {
+		if sftpdConf.Bindings[0].IsValid() {
 			mDNSServiceSFTP, err = zeroconf.Register(
 			mDNSServiceSFTP, err = zeroconf.Register(
-				fmt.Sprintf("SFTPGo portable %v", sftpdConf.BindPort), // service instance name
-				"_sftp-ssh._tcp",   // service type and protocol
-				"local.",           // service domain
-				sftpdConf.BindPort, // service port
-				meta,               // service metadata
-				nil,                // register on all network interfaces
+				fmt.Sprintf("SFTPGo portable %v", sftpdConf.Bindings[0].Port), // service instance name
+				"_sftp-ssh._tcp",           // service type and protocol
+				"local.",                   // service domain
+				sftpdConf.Bindings[0].Port, // service port
+				meta,                       // service metadata
+				nil,                        // register on all network interfaces
 			)
 			)
 			if err != nil {
 			if err != nil {
 				mDNSServiceSFTP = nil
 				mDNSServiceSFTP = nil
@@ -160,12 +169,13 @@ func (s *Service) advertiseServices(advertiseService, advertiseCredentials bool)
 			}
 			}
 		}
 		}
 		ftpdConf := config.GetFTPDConfig()
 		ftpdConf := config.GetFTPDConfig()
-		if ftpdConf.BindPort > 0 {
+		if ftpdConf.Bindings[0].IsValid() {
+			port := ftpdConf.Bindings[0].Port
 			mDNSServiceFTP, err = zeroconf.Register(
 			mDNSServiceFTP, err = zeroconf.Register(
-				fmt.Sprintf("SFTPGo portable %v", ftpdConf.BindPort),
+				fmt.Sprintf("SFTPGo portable %v", port),
 				"_ftp._tcp",
 				"_ftp._tcp",
 				"local.",
 				"local.",
-				ftpdConf.BindPort,
+				port,
 				meta,
 				meta,
 				nil,
 				nil,
 			)
 			)
@@ -177,12 +187,12 @@ func (s *Service) advertiseServices(advertiseService, advertiseCredentials bool)
 			}
 			}
 		}
 		}
 		webdavConf := config.GetWebDAVDConfig()
 		webdavConf := config.GetWebDAVDConfig()
-		if webdavConf.BindPort > 0 {
+		if webdavConf.Bindings[0].IsValid() {
 			mDNSServiceDAV, err = zeroconf.Register(
 			mDNSServiceDAV, err = zeroconf.Register(
-				fmt.Sprintf("SFTPGo portable %v", webdavConf.BindPort),
+				fmt.Sprintf("SFTPGo portable %v", webdavConf.Bindings[0].Port),
 				"_http._tcp",
 				"_http._tcp",
 				"local.",
 				"local.",
-				webdavConf.BindPort,
+				webdavConf.Bindings[0].Port,
 				meta,
 				meta,
 				nil,
 				nil,
 			)
 			)

+ 77 - 13
sftpd/server.go

@@ -15,6 +15,7 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/pires/go-proxyproto"
 	"github.com/pkg/sftp"
 	"github.com/pkg/sftp"
 	"golang.org/x/crypto/ssh"
 	"golang.org/x/crypto/ssh"
 
 
@@ -36,13 +37,40 @@ var (
 	sftpExtensions = []string{"[email protected]"}
 	sftpExtensions = []string{"[email protected]"}
 )
 )
 
 
+// Binding defines the configuration for a network listener
+type Binding struct {
+	// The address to listen on. A blank value means listen on all available network interfaces.
+	Address string `json:"address" mapstructure:"address"`
+	// The port used for serving requests
+	Port int `json:"port" mapstructure:"port"`
+	// apply the proxy configuration, if any, for this binding
+	ApplyProxyConfig bool `json:"apply_proxy_config" mapstructure:"apply_proxy_config"`
+}
+
+// GetAddress returns the binding address
+func (b *Binding) GetAddress() string {
+	return fmt.Sprintf("%s:%d", b.Address, b.Port)
+}
+
+// IsValid returns true if the binding port is > 0
+func (b *Binding) IsValid() bool {
+	return b.Port > 0
+}
+
+// HasProxy returns true if the proxy protocol is active for this binding
+func (b *Binding) HasProxy() bool {
+	return b.ApplyProxyConfig && common.Config.ProxyProtocol > 0
+}
+
 // Configuration for the SFTP server
 // Configuration for the SFTP server
 type Configuration struct {
 type Configuration struct {
 	// Identification string used by the server
 	// Identification string used by the server
 	Banner string `json:"banner" mapstructure:"banner"`
 	Banner string `json:"banner" mapstructure:"banner"`
-	// The port used for serving SFTP requests
+	// Addresses and ports to bind to
+	Bindings []Binding `json:"bindings" mapstructure:"bindings"`
+	// Deprecated: please use Bindings
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
-	// The address to listen on. A blank value means listen on all available network interfaces.
+	// Deprecated: please use Bindings
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
 	// Deprecated: please use the same key in common configuration
 	// Deprecated: please use the same key in common configuration
 	IdleTimeout int `json:"idle_timeout" mapstructure:"idle_timeout"`
 	IdleTimeout int `json:"idle_timeout" mapstructure:"idle_timeout"`
@@ -127,6 +155,17 @@ func (e *authenticationError) Error() string {
 	return fmt.Sprintf("Authentication error: %s", e.err)
 	return fmt.Sprintf("Authentication error: %s", e.err)
 }
 }
 
 
+// ShouldBind returns true if there is at least a valid binding
+func (c *Configuration) ShouldBind() bool {
+	for _, binding := range c.Bindings {
+		if binding.IsValid() {
+			return true
+		}
+	}
+
+	return false
+}
+
 // Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections.
 // Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections.
 func (c *Configuration) Initialize(configDir string) error {
 func (c *Configuration) Initialize(configDir string) error {
 	serverConfig := &ssh.ServerConfig{
 	serverConfig := &ssh.ServerConfig{
@@ -165,6 +204,10 @@ func (c *Configuration) Initialize(configDir string) error {
 		}
 		}
 	}
 	}
 
 
+	if !c.ShouldBind() {
+		return common.ErrNoBinding
+	}
+
 	if err := c.checkAndLoadHostKeys(configDir, serverConfig); err != nil {
 	if err := c.checkAndLoadHostKeys(configDir, serverConfig); err != nil {
 		serviceStatus.HostKeys = nil
 		serviceStatus.HostKeys = nil
 		return err
 		return err
@@ -181,22 +224,43 @@ func (c *Configuration) Initialize(configDir string) error {
 	c.configureLoginBanner(serverConfig, configDir)
 	c.configureLoginBanner(serverConfig, configDir)
 	c.checkSSHCommands()
 	c.checkSSHCommands()
 
 
-	addr := fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort)
-	listener, err := net.Listen("tcp", addr)
-	if err != nil {
-		logger.Warn(logSender, "", "error starting listener on address %s:%d: %v", c.BindAddress, c.BindPort, err)
-		return err
+	exitChannel := make(chan error)
+	serviceStatus.Bindings = nil
+
+	for _, binding := range c.Bindings {
+		if !binding.IsValid() {
+			continue
+		}
+		serviceStatus.Bindings = append(serviceStatus.Bindings, binding)
+
+		go func(binding Binding) {
+			exitChannel <- c.listenAndServe(binding, serverConfig)
+		}(binding)
 	}
 	}
-	proxyListener, err := common.Config.GetProxyListener(listener)
+
+	serviceStatus.IsActive = true
+	serviceStatus.SSHCommands = strings.Join(c.EnabledSSHCommands, ", ")
+
+	return <-exitChannel
+}
+
+func (c *Configuration) listenAndServe(binding Binding, serverConfig *ssh.ServerConfig) error {
+	addr := binding.GetAddress()
+	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 	if err != nil {
-		logger.Warn(logSender, "", "error enabling proxy listener: %v", err)
+		logger.Warn(logSender, "", "error starting listener on address %v: %v", addr, err)
 		return err
 		return err
 	}
 	}
-	logger.Info(logSender, "", "server listener registered address: %v", listener.Addr().String())
-	serviceStatus.Address = addr
-	serviceStatus.IsActive = true
-	serviceStatus.SSHCommands = strings.Join(c.EnabledSSHCommands, ", ")
+	var proxyListener *proxyproto.Listener
 
 
+	if binding.ApplyProxyConfig {
+		proxyListener, err = common.Config.GetProxyListener(listener)
+		if err != nil {
+			logger.Warn(logSender, "", "error enabling proxy listener: %v", err)
+			return err
+		}
+	}
+	logger.Info(logSender, "", "server listener registered, address: %v", listener.Addr().String())
 	for {
 	for {
 		var conn net.Conn
 		var conn net.Conn
 		if proxyListener != nil {
 		if proxyListener != nil {

+ 1 - 1
sftpd/sftpd.go

@@ -38,7 +38,7 @@ type HostKey struct {
 // ServiceStatus defines the service status
 // ServiceStatus defines the service status
 type ServiceStatus struct {
 type ServiceStatus struct {
 	IsActive    bool      `json:"is_active"`
 	IsActive    bool      `json:"is_active"`
-	Address     string    `json:"address"`
+	Bindings    []Binding `json:"bindings"`
 	SSHCommands string    `json:"ssh_commands"`
 	SSHCommands string    `json:"ssh_commands"`
 	HostKeys    []HostKey `json:"host_keys"`
 	HostKeys    []HostKey `json:"host_keys"`
 }
 }

+ 40 - 8
sftpd/sftpd_test.go

@@ -190,7 +190,12 @@ func TestMain(m *testing.M) {
 
 
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
 	httpdConf := config.GetHTTPDConfig()
 	httpdConf := config.GetHTTPDConfig()
-	sftpdConf.BindPort = 2022
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port:             2022,
+			ApplyProxyConfig: true,
+		},
+	}
 	sftpdConf.KexAlgorithms = []string{"[email protected]", "ecdh-sha2-nistp256",
 	sftpdConf.KexAlgorithms = []string{"[email protected]", "ecdh-sha2-nistp256",
 		"ecdh-sha2-nistp384"}
 		"ecdh-sha2-nistp384"}
 	sftpdConf.Ciphers = []string{"[email protected]", "[email protected]",
 	sftpdConf.Ciphers = []string{"[email protected]", "[email protected]",
@@ -250,10 +255,15 @@ func TestMain(m *testing.M) {
 		}
 		}
 	}()
 	}()
 
 
-	waitTCPListening(fmt.Sprintf("%s:%d", sftpdConf.BindAddress, sftpdConf.BindPort))
+	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
 
 
-	sftpdConf.BindPort = 2222
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port:             2222,
+			ApplyProxyConfig: true,
+		},
+	}
 	sftpdConf.PasswordAuthentication = false
 	sftpdConf.PasswordAuthentication = false
 	common.Config.ProxyProtocol = 1
 	common.Config.ProxyProtocol = 1
 	go func() {
 	go func() {
@@ -265,9 +275,14 @@ func TestMain(m *testing.M) {
 		}
 		}
 	}()
 	}()
 
 
-	waitTCPListening(fmt.Sprintf("%s:%d", sftpdConf.BindAddress, sftpdConf.BindPort))
+	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 
 
-	sftpdConf.BindPort = 2224
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port:             2224,
+			ApplyProxyConfig: true,
+		},
+	}
 	sftpdConf.PasswordAuthentication = true
 	sftpdConf.PasswordAuthentication = true
 	common.Config.ProxyProtocol = 2
 	common.Config.ProxyProtocol = 2
 	go func() {
 	go func() {
@@ -279,7 +294,7 @@ func TestMain(m *testing.M) {
 		}
 		}
 	}()
 	}()
 
 
-	waitTCPListening(fmt.Sprintf("%s:%d", sftpdConf.BindAddress, sftpdConf.BindPort))
+	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	getHostKeysFingerprints(sftpdConf.HostKeys)
 	getHostKeysFingerprints(sftpdConf.HostKeys)
 
 
 	exitCode := m.Run()
 	exitCode := m.Run()
@@ -301,7 +316,15 @@ func TestInitialization(t *testing.T) {
 	err := config.LoadConfig(configDir, "")
 	err := config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
-	sftpdConf.BindPort = 2022
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port:             2022,
+			ApplyProxyConfig: true,
+		},
+		{
+			Port: 0,
+		},
+	}
 	sftpdConf.LoginBannerFile = "invalid_file"
 	sftpdConf.LoginBannerFile = "invalid_file"
 	sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls")
 	sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls")
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
@@ -312,9 +335,15 @@ func TestInitialization(t *testing.T) {
 	sftpdConf.KeyboardInteractiveHook = filepath.Join(homeBasePath, "invalid_file")
 	sftpdConf.KeyboardInteractiveHook = filepath.Join(homeBasePath, "invalid_file")
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
-	sftpdConf.BindPort = 4444
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port:             4444,
+			ApplyProxyConfig: true,
+		},
+	}
 	common.Config.ProxyProtocol = 1
 	common.Config.ProxyProtocol = 1
 	common.Config.ProxyAllowed = []string{"1270.0.0.1"}
 	common.Config.ProxyAllowed = []string{"1270.0.0.1"}
+	assert.True(t, sftpdConf.Bindings[0].HasProxy())
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
 	sftpdConf.HostKeys = []string{"missing key"}
 	sftpdConf.HostKeys = []string{"missing key"}
@@ -324,6 +353,9 @@ func TestInitialization(t *testing.T) {
 	sftpdConf.TrustedUserCAKeys = []string{"missing ca key"}
 	sftpdConf.TrustedUserCAKeys = []string{"missing ca key"}
 	err = sftpdConf.Initialize(configDir)
 	err = sftpdConf.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
+	sftpdConf.Bindings = nil
+	err = sftpdConf.Initialize(configDir)
+	assert.EqualError(t, err, common.ErrNoBinding.Error())
 }
 }
 
 
 func TestBasicSFTPHandling(t *testing.T) {
 func TestBasicSFTPHandling(t *testing.T) {

+ 25 - 10
sftpgo.json

@@ -13,8 +13,13 @@
     "max_total_connections": 0
     "max_total_connections": 0
   },
   },
   "sftpd": {
   "sftpd": {
-    "bind_port": 2022,
-    "bind_address": "",
+    "bindings": [
+      {
+        "port": 2022,
+        "address": "",
+        "apply_proxy_config": true
+      }
+    ],
     "max_auth_tries": 0,
     "max_auth_tries": 0,
     "banner": "",
     "banner": "",
     "host_keys": [],
     "host_keys": [],
@@ -34,23 +39,33 @@
     "password_authentication": true
     "password_authentication": true
   },
   },
   "ftpd": {
   "ftpd": {
-    "bind_port": 0,
-    "bind_address": "",
+    "bindings": [
+      {
+        "address": "",
+        "port": 0,
+        "apply_proxy_config": true,
+        "tls_mode": 0,
+        "force_passive_ip": ""
+      }
+    ],
     "banner": "",
     "banner": "",
     "banner_file": "",
     "banner_file": "",
-    "active_transfers_port_non_20": false,
-    "force_passive_ip": "",
+    "active_transfers_port_non_20": true,
     "passive_port_range": {
     "passive_port_range": {
       "start": 50000,
       "start": 50000,
       "end": 50100
       "end": 50100
     },
     },
     "certificate_file": "",
     "certificate_file": "",
-    "certificate_key_file": "",
-    "tls_mode": 0
+    "certificate_key_file": ""
   },
   },
   "webdavd": {
   "webdavd": {
-    "bind_port": 0,
-    "bind_address": "",
+    "bindings": [
+      {
+        "address": "",
+        "port": 0,
+        "enable_https": false
+      }
+    ],
     "certificate_file": "",
     "certificate_file": "",
     "certificate_key_file": "",
     "certificate_key_file": "",
     "cors": {
     "cors": {

+ 21 - 6
templates/status.html

@@ -11,8 +11,11 @@
             Status: {{ if .Status.SSH.IsActive}}"Started"{{else}}"Stopped"{{end}}
             Status: {{ if .Status.SSH.IsActive}}"Started"{{else}}"Stopped"{{end}}
             {{if .Status.SSH.IsActive}}
             {{if .Status.SSH.IsActive}}
             <br>
             <br>
-            Address: "{{.Status.SSH.Address}}"
+            {{range .Status.SSH.Bindings}}
             <br>
             <br>
+            Address: "{{.GetAddress}}" {{if .HasProxy}}Proxy: ON{{end}}
+            <br>
+            {{end}}
             Accepted commands: "{{.Status.SSH.SSHCommands}}"
             Accepted commands: "{{.Status.SSH.SSHCommands}}"
             <br>
             <br>
             {{range .Status.SSH.HostKeys}}
             {{range .Status.SSH.HostKeys}}
@@ -34,11 +37,19 @@
             Status: {{ if .Status.FTP.IsActive}}"Started"{{else}}"Stopped"{{end}}
             Status: {{ if .Status.FTP.IsActive}}"Started"{{else}}"Stopped"{{end}}
             {{if .Status.FTP.IsActive}}
             {{if .Status.FTP.IsActive}}
             <br>
             <br>
-            Address: "{{.Status.FTP.Address}}"
+            {{range .Status.FTP.Bindings}}
             <br>
             <br>
-            Passive port range: "{{.Status.FTP.PassivePortRange.Start}}-{{.Status.FTP.PassivePortRange.End}}"
+            Address: "{{.GetAddress}}" {{if .HasProxy}}Proxy: ON{{end}}
+            <br>
+            TLS: "{{.GetTLSDescription}}"
+            {{if .ForcePassiveIP}}
+            <br>
+            PassiveIP: {{.ForcePassiveIP}}
+            {{end}}
             <br>
             <br>
-            TLS: "{{.Status.FTP.FTPES}}"
+            {{end}}
+            <br>
+            Passive port range: "{{.Status.FTP.PassivePortRange.Start}}-{{.Status.FTP.PassivePortRange.End}}"
             {{end}}
             {{end}}
         </p>
         </p>
     </div>
     </div>
@@ -51,9 +62,13 @@
             Status: {{ if .Status.WebDAV.IsActive}}"Started"{{else}}"Stopped"{{end}}
             Status: {{ if .Status.WebDAV.IsActive}}"Started"{{else}}"Stopped"{{end}}
             {{if .Status.WebDAV.IsActive}}
             {{if .Status.WebDAV.IsActive}}
             <br>
             <br>
-            Address: "{{.Status.WebDAV.Address}}"
+            {{range .Status.WebDAV.Bindings}}
             <br>
             <br>
-            Protocol: "{{.Status.WebDAV.Protocol}}"
+            Address: "{{.GetAddress}}"
+            <br>
+            Protocol: {{if .EnableHTTPS}} HTTPS {{else}} HTTP {{end}}
+            <br>
+            {{end}}
             {{end}}
             {{end}}
         </p>
         </p>
     </div>
     </div>

+ 20 - 4
webdavd/internal_test.go

@@ -159,7 +159,11 @@ func TestUserInvalidParams(t *testing.T) {
 		HomeDir:  "invalid",
 		HomeDir:  "invalid",
 	}
 	}
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 9000,
+		Bindings: []Binding{
+			{
+				Port: 9000,
+			},
+		},
 	}
 	}
 	server, err := newServer(c, configDir)
 	server, err := newServer(c, configDir)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -670,7 +674,11 @@ func TestBasicUsersCache(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 9000,
+		Bindings: []Binding{
+			{
+				Port: 9000,
+			},
+		},
 		Cache: Cache{
 		Cache: Cache{
 			Users: UsersCacheConfig{
 			Users: UsersCacheConfig{
 				MaxSize:        50,
 				MaxSize:        50,
@@ -782,7 +790,11 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 
 
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 9000,
+		Bindings: []Binding{
+			{
+				Port: 9000,
+			},
+		},
 		Cache: Cache{
 		Cache: Cache{
 			Users: UsersCacheConfig{
 			Users: UsersCacheConfig{
 				MaxSize:        3,
 				MaxSize:        3,
@@ -926,7 +938,11 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
 
 
 func TestRecoverer(t *testing.T) {
 func TestRecoverer(t *testing.T) {
 	c := &Configuration{
 	c := &Configuration{
-		BindPort: 9000,
+		Bindings: []Binding{
+			{
+				Port: 9000,
+			},
+		},
 	}
 	}
 	server, err := newServer(c, configDir)
 	server, err := newServer(c, configDir)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

+ 6 - 10
webdavd/server.go

@@ -53,13 +53,9 @@ func newServer(config *Configuration, configDir string) (*webDavServer, error) {
 	return server, nil
 	return server, nil
 }
 }
 
 
-func (s *webDavServer) listenAndServe() error {
-	addr := fmt.Sprintf("%s:%d", s.config.BindAddress, s.config.BindPort)
-	s.status.IsActive = true
-	s.status.Address = addr
-	s.status.Protocol = "HTTP"
+func (s *webDavServer) listenAndServe(binding Binding) error {
 	httpServer := &http.Server{
 	httpServer := &http.Server{
-		Addr:              addr,
+		Addr:              binding.GetAddress(),
 		Handler:           server,
 		Handler:           server,
 		ReadHeaderTimeout: 30 * time.Second,
 		ReadHeaderTimeout: 30 * time.Second,
 		IdleTimeout:       120 * time.Second,
 		IdleTimeout:       120 * time.Second,
@@ -76,17 +72,17 @@ func (s *webDavServer) listenAndServe() error {
 			OptionsPassthrough: true,
 			OptionsPassthrough: true,
 		})
 		})
 		httpServer.Handler = c.Handler(server)
 		httpServer.Handler = c.Handler(server)
-	} else {
-		httpServer.Handler = server
 	}
 	}
-	if s.certMgr != nil {
-		s.status.Protocol = "HTTPS"
+	if s.certMgr != nil && binding.EnableHTTPS {
+		server.status.Bindings = append(server.status.Bindings, binding)
 		httpServer.TLSConfig = &tls.Config{
 		httpServer.TLSConfig = &tls.Config{
 			GetCertificate: s.certMgr.GetCertificateFunc(),
 			GetCertificate: s.certMgr.GetCertificateFunc(),
 			MinVersion:     tls.VersionTLS12,
 			MinVersion:     tls.VersionTLS12,
 		}
 		}
 		return httpServer.ListenAndServeTLS("", "")
 		return httpServer.ListenAndServeTLS("", "")
 	}
 	}
+	binding.EnableHTTPS = false
+	server.status.Bindings = append(server.status.Bindings, binding)
 	return httpServer.ListenAndServe()
 	return httpServer.ListenAndServe()
 }
 }
 
 

+ 60 - 6
webdavd/webdavd.go

@@ -2,8 +2,10 @@
 package webdavd
 package webdavd
 
 
 import (
 import (
+	"fmt"
 	"path/filepath"
 	"path/filepath"
 
 
+	"github.com/drakkan/sftpgo/common"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
 )
 )
@@ -25,9 +27,8 @@ var (
 
 
 // ServiceStatus defines the service status
 // ServiceStatus defines the service status
 type ServiceStatus struct {
 type ServiceStatus struct {
-	IsActive bool   `json:"is_active"`
-	Address  string `json:"address"`
-	Protocol string `json:"protocol"`
+	IsActive bool      `json:"is_active"`
+	Bindings []Binding `json:"bindings"`
 }
 }
 
 
 // Cors configuration
 // Cors configuration
@@ -59,11 +60,33 @@ type Cache struct {
 	MimeTypes MimeCacheConfig  `json:"mime_types" mapstructure:"mime_types"`
 	MimeTypes MimeCacheConfig  `json:"mime_types" mapstructure:"mime_types"`
 }
 }
 
 
+// Binding defines the configuration for a network listener
+type Binding struct {
+	// The address to listen on. A blank value means listen on all available network interfaces.
+	Address string `json:"address" mapstructure:"address"`
+	// The port used for serving requests
+	Port int `json:"port" mapstructure:"port"`
+	// you also need to provide a certificate for enabling HTTPS
+	EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"`
+}
+
+// GetAddress returns the binding address
+func (b *Binding) GetAddress() string {
+	return fmt.Sprintf("%s:%d", b.Address, b.Port)
+}
+
+// IsValid returns true if the binding port is > 0
+func (b *Binding) IsValid() bool {
+	return b.Port > 0
+}
+
 // Configuration defines the configuration for the WevDAV server
 // Configuration defines the configuration for the WevDAV server
 type Configuration struct {
 type Configuration struct {
-	// The port used for serving FTP requests
+	// Addresses and ports to bind to
+	Bindings []Binding `json:"bindings" mapstructure:"bindings"`
+	// Deprecated: please use Bindings
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
 	BindPort int `json:"bind_port" mapstructure:"bind_port"`
-	// The address to listen on. A blank value means listen on all available network interfaces.
+	// Deprecated: please use Bindings
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
 	BindAddress string `json:"bind_address" mapstructure:"bind_address"`
 	// If files containing a certificate and matching private key for the server are provided the server will expect
 	// If files containing a certificate and matching private key for the server are provided the server will expect
 	// HTTPS connections.
 	// HTTPS connections.
@@ -85,6 +108,17 @@ func GetStatus() ServiceStatus {
 	return server.status
 	return server.status
 }
 }
 
 
+// ShouldBind returns true if there is at least a valid binding
+func (c *Configuration) ShouldBind() bool {
+	for _, binding := range c.Bindings {
+		if binding.IsValid() {
+			return true
+		}
+	}
+
+	return false
+}
+
 // Initialize configures and starts the WebDAV server
 // Initialize configures and starts the WebDAV server
 func (c *Configuration) Initialize(configDir string) error {
 func (c *Configuration) Initialize(configDir string) error {
 	var err error
 	var err error
@@ -96,11 +130,31 @@ func (c *Configuration) Initialize(configDir string) error {
 	if !c.Cache.MimeTypes.Enabled {
 	if !c.Cache.MimeTypes.Enabled {
 		mimeTypeCache.maxSize = 0
 		mimeTypeCache.maxSize = 0
 	}
 	}
+	if !c.ShouldBind() {
+		return common.ErrNoBinding
+	}
 	server, err = newServer(c, configDir)
 	server, err = newServer(c, configDir)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	return server.listenAndServe()
+
+	server.status.Bindings = nil
+
+	exitChannel := make(chan error)
+
+	for _, binding := range c.Bindings {
+		if !binding.IsValid() {
+			continue
+		}
+
+		go func(binding Binding) {
+			exitChannel <- server.listenAndServe(binding)
+		}(binding)
+	}
+
+	server.status.IsActive = true
+
+	return <-exitChannel
 }
 }
 
 
 // ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
 // ReloadTLSCertificate reloads the TLS certificate and key from the configured paths

+ 31 - 6
webdavd/webdavd_test.go

@@ -32,6 +32,7 @@ import (
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/httpd"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/kms"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/sftpd"
 	"github.com/drakkan/sftpgo/vfs"
 	"github.com/drakkan/sftpgo/vfs"
 	"github.com/drakkan/sftpgo/webdavd"
 	"github.com/drakkan/sftpgo/webdavd"
 )
 )
@@ -143,11 +144,19 @@ func TestMain(m *testing.M) {
 
 
 	// required to test sftpfs
 	// required to test sftpfs
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
-	sftpdConf.BindPort = 9022
+	sftpdConf.Bindings = []sftpd.Binding{
+		{
+			Port: 9022,
+		},
+	}
 	sftpdConf.HostKeys = []string{filepath.Join(os.TempDir(), "id_ecdsa")}
 	sftpdConf.HostKeys = []string{filepath.Join(os.TempDir(), "id_ecdsa")}
 
 
 	webDavConf := config.GetWebDAVDConfig()
 	webDavConf := config.GetWebDAVDConfig()
-	webDavConf.BindPort = webDavServerPort
+	webDavConf.Bindings = []webdavd.Binding{
+		{
+			Port: webDavServerPort,
+		},
+	}
 	webDavConf.Cors = webdavd.Cors{
 	webDavConf.Cors = webdavd.Cors{
 		Enabled:        true,
 		Enabled:        true,
 		AllowedOrigins: []string{"*"},
 		AllowedOrigins: []string{"*"},
@@ -196,9 +205,9 @@ func TestMain(m *testing.M) {
 		}
 		}
 	}()
 	}()
 
 
-	waitTCPListening(fmt.Sprintf("%s:%d", webDavConf.BindAddress, webDavConf.BindPort))
+	waitTCPListening(webDavConf.Bindings[0].GetAddress())
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
 	waitTCPListening(fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort))
-	waitTCPListening(fmt.Sprintf("%s:%d", sftpdConf.BindAddress, sftpdConf.BindPort))
+	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	webdavd.ReloadTLSCertificate() //nolint:errcheck
 	webdavd.ReloadTLSCertificate() //nolint:errcheck
 
 
 	exitCode := m.Run()
 	exitCode := m.Run()
@@ -213,7 +222,15 @@ func TestMain(m *testing.M) {
 
 
 func TestInitialization(t *testing.T) {
 func TestInitialization(t *testing.T) {
 	cfg := webdavd.Configuration{
 	cfg := webdavd.Configuration{
-		BindPort:           1234,
+		Bindings: []webdavd.Binding{
+			{
+				Port:        1234,
+				EnableHTTPS: true,
+			},
+			{
+				Port: 0,
+			},
+		},
 		CertificateFile:    "missing path",
 		CertificateFile:    "missing path",
 		CertificateKeyFile: "bad path",
 		CertificateKeyFile: "bad path",
 	}
 	}
@@ -221,13 +238,21 @@ func TestInitialization(t *testing.T) {
 	assert.Error(t, err)
 	assert.Error(t, err)
 
 
 	cfg.Cache = config.GetWebDAVDConfig().Cache
 	cfg.Cache = config.GetWebDAVDConfig().Cache
-	cfg.BindPort = webDavServerPort
+	cfg.Bindings[0].Port = webDavServerPort
 	cfg.CertificateFile = certPath
 	cfg.CertificateFile = certPath
 	cfg.CertificateKeyFile = keyPath
 	cfg.CertificateKeyFile = keyPath
 	err = cfg.Initialize(configDir)
 	err = cfg.Initialize(configDir)
 	assert.Error(t, err)
 	assert.Error(t, err)
 	err = webdavd.ReloadTLSCertificate()
 	err = webdavd.ReloadTLSCertificate()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+
+	cfg.Bindings = []webdavd.Binding{
+		{
+			Port: 0,
+		},
+	}
+	err = cfg.Initialize(configDir)
+	assert.EqualError(t, err, common.ErrNoBinding.Error())
 }
 }
 
 
 func TestBasicHandling(t *testing.T) {
 func TestBasicHandling(t *testing.T) {