diff --git a/docs/external-auth.md b/docs/external-auth.md index 8ae5dadf..5171f3dc 100644 --- a/docs/external-auth.md +++ b/docs/external-auth.md @@ -47,6 +47,8 @@ else fi ``` -An example authentication program that allow SFTPGo to authenticate against LDAP can be found inside the source tree [ldapauth](../examples/ldapauth) directory. +An example authentication program allowing to authenticate against an LDAP server can be found inside the source tree [ldapauth](../examples/ldapauth) directory. + +An example server, to use as HTTP authentication hook, allowing to authenticate against an LDAP server can be found inside the source tree [ldapauthserver](../examples/ldapauthserver) directory. If you have an external authentication hook that could be useful to others too, please let us know and/or please send a pull request. diff --git a/examples/ldapauth/README.md b/examples/ldapauth/README.md index f7ce1355..201fa50e 100644 --- a/examples/ldapauth/README.md +++ b/examples/ldapauth/README.md @@ -1,6 +1,6 @@ ## LDAPAuth -This is an example for an external authentication program that performs authentication against an LDAP server. +This is an example for an external authentication program. It performs authentication against an LDAP server. It is tested against [389ds](https://directory.fedoraproject.org/) and can be used as starting point to authenticate using any LDAP server including Active Directory. You need to change the LDAP connection parameters and the user search query to match your environment. diff --git a/examples/ldapauthserver/README.md b/examples/ldapauthserver/README.md new file mode 100644 index 00000000..f188f378 --- /dev/null +++ b/examples/ldapauthserver/README.md @@ -0,0 +1,11 @@ +## LDAPAuthServer + +This is an example for an HTTP server to use as external authentication HTTP hook. It performs authentication against an LDAP server. +It is tested against [389ds](https://directory.fedoraproject.org/) and can be used as starting point to authenticate using any LDAP server including Active Directory. + +You can configure the server using the [ldapauth.toml](./ldapauth.toml) configuration file. +You can build this example using the following command: + +``` +go build -i -ldflags "-s -w" -o ldapauthserver +``` \ No newline at end of file diff --git a/examples/ldapauthserver/cmd/root.go b/examples/ldapauthserver/cmd/root.go new file mode 100644 index 00000000..81bc748e --- /dev/null +++ b/examples/ldapauthserver/cmd/root.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/drakkan/sftpgo/ldapauthserver/config" + "github.com/drakkan/sftpgo/ldapauthserver/utils" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + logSender = "cmd" + configDirFlag = "config-dir" + configDirKey = "config_dir" + configFileFlag = "config-file" + configFileKey = "config_file" + logFilePathFlag = "log-file-path" + logFilePathKey = "log_file_path" + logMaxSizeFlag = "log-max-size" + logMaxSizeKey = "log_max_size" + logMaxBackupFlag = "log-max-backups" + logMaxBackupKey = "log_max_backups" + logMaxAgeFlag = "log-max-age" + logMaxAgeKey = "log_max_age" + logCompressFlag = "log-compress" + logCompressKey = "log_compress" + logVerboseFlag = "log-verbose" + logVerboseKey = "log_verbose" + profilerFlag = "profiler" + profilerKey = "profiler" + defaultConfigDir = "." + defaultConfigName = config.DefaultConfigName + defaultLogFile = "ldapauth.log" + defaultLogMaxSize = 10 + defaultLogMaxBackup = 5 + defaultLogMaxAge = 28 + defaultLogCompress = false + defaultLogVerbose = true +) + +var ( + configDir string + configFile string + logFilePath string + logMaxSize int + logMaxBackups int + logMaxAge int + logCompress bool + logVerbose bool + + rootCmd = &cobra.Command{ + Use: "ldapauthserver", + Short: "LDAP Authentication Server for SFTPGo", + } +) + +func init() { + version := utils.GetAppVersion() + rootCmd.Flags().BoolP("version", "v", false, "") + rootCmd.Version = version.GetVersionAsString() + rootCmd.SetVersionTemplate(`{{printf "LDAP Authentication Server version: "}}{{printf "%s" .Version}} +`) +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func addConfigFlags(cmd *cobra.Command) { + viper.SetDefault(configDirKey, defaultConfigDir) + viper.BindEnv(configDirKey, "LDAPAUTH_CONFIG_DIR") + cmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey), + "Location for the config dir. This directory should contain the \"ldapauth\" configuration file or the configured "+ + "config-file. This flag can be set using LDAPAUTH_CONFIG_DIR env var too.") + viper.BindPFlag(configDirKey, cmd.Flags().Lookup(configDirFlag)) + + viper.SetDefault(configFileKey, defaultConfigName) + viper.BindEnv(configFileKey, "LDAPAUTH_CONFIG_FILE") + cmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey), + "Name for the configuration file. It must be the name of a file stored in config-dir not the absolute path to the "+ + "configuration file. The specified file name must have no extension we automatically load JSON, YAML, TOML, HCL and "+ + "Java properties. Therefore if you set \"ldapauth\" then \"ldapauth.toml\", \"ldapauth.yaml\" and so on are searched. "+ + "This flag can be set using LDAPAUTH_CONFIG_FILE env var too.") + viper.BindPFlag(configFileKey, cmd.Flags().Lookup(configFileFlag)) +} + +func addServeFlags(cmd *cobra.Command) { + addConfigFlags(cmd) + + viper.SetDefault(logFilePathKey, defaultLogFile) + viper.BindEnv(logFilePathKey, "LDAPAUTH_LOG_FILE_PATH") + cmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey), + "Location for the log file. Leave empty to write logs to the standard output. This flag can be set using LDAPAUTH_LOG_FILE_PATH "+ + "env var too.") + viper.BindPFlag(logFilePathKey, cmd.Flags().Lookup(logFilePathFlag)) + + viper.SetDefault(logMaxSizeKey, defaultLogMaxSize) + viper.BindEnv(logMaxSizeKey, "LDAPAUTH_LOG_MAX_SIZE") + cmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey), + "Maximum size in megabytes of the log file before it gets rotated. This flag can be set using LDAPAUTH_LOG_MAX_SIZE "+ + "env var too. It is unused if log-file-path is empty.") + viper.BindPFlag(logMaxSizeKey, cmd.Flags().Lookup(logMaxSizeFlag)) + + viper.SetDefault(logMaxBackupKey, defaultLogMaxBackup) + viper.BindEnv(logMaxBackupKey, "LDAPAUTH_LOG_MAX_BACKUPS") + cmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey), + "Maximum number of old log files to retain. This flag can be set using LDAPAUTH_LOG_MAX_BACKUPS env var too. "+ + "It is unused if log-file-path is empty.") + viper.BindPFlag(logMaxBackupKey, cmd.Flags().Lookup(logMaxBackupFlag)) + + viper.SetDefault(logMaxAgeKey, defaultLogMaxAge) + viper.BindEnv(logMaxAgeKey, "LDAPAUTH_LOG_MAX_AGE") + cmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey), + "Maximum number of days to retain old log files. This flag can be set using LDAPAUTH_LOG_MAX_AGE env var too. "+ + "It is unused if log-file-path is empty.") + viper.BindPFlag(logMaxAgeKey, cmd.Flags().Lookup(logMaxAgeFlag)) + + viper.SetDefault(logCompressKey, defaultLogCompress) + viper.BindEnv(logCompressKey, "LDAPAUTH_LOG_COMPRESS") + cmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey), "Determine if the rotated "+ + "log files should be compressed using gzip. This flag can be set using LDAPAUTH_LOG_COMPRESS env var too. "+ + "It is unused if log-file-path is empty.") + viper.BindPFlag(logCompressKey, cmd.Flags().Lookup(logCompressFlag)) + + viper.SetDefault(logVerboseKey, defaultLogVerbose) + viper.BindEnv(logVerboseKey, "LDAPAUTH_LOG_VERBOSE") + cmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey), "Enable verbose logs. "+ + "This flag can be set using LDAPAUTH_LOG_VERBOSE env var too.") + viper.BindPFlag(logVerboseKey, cmd.Flags().Lookup(logVerboseFlag)) +} diff --git a/examples/ldapauthserver/cmd/serve.go b/examples/ldapauthserver/cmd/serve.go new file mode 100644 index 00000000..b9faf202 --- /dev/null +++ b/examples/ldapauthserver/cmd/serve.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "path/filepath" + + "github.com/drakkan/sftpgo/ldapauthserver/config" + "github.com/drakkan/sftpgo/ldapauthserver/httpd" + "github.com/drakkan/sftpgo/ldapauthserver/logger" + "github.com/drakkan/sftpgo/ldapauthserver/utils" + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +var ( + serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the LDAP Authentication Server", + Long: `To start the server with the default values for the command line flags simply use: + +ldapauthserver serve + +Please take a look at the usage below to customize the startup options`, + Run: func(cmd *cobra.Command, args []string) { + startServer() + }, + } +) + +func init() { + rootCmd.AddCommand(serveCmd) + addServeFlags(serveCmd) +} + +func startServer() error { + logLevel := zerolog.DebugLevel + if !logVerbose { + logLevel = zerolog.InfoLevel + } + if !filepath.IsAbs(logFilePath) && utils.IsFileInputValid(logFilePath) { + logFilePath = filepath.Join(configDir, logFilePath) + } + logger.InitLogger(logFilePath, logMaxSize, logMaxBackups, logMaxAge, logCompress, logLevel) + version := utils.GetAppVersion() + logger.Info(logSender, "", "starting LDAP Auth Server %v, config dir: %v, config file: %v, log max size: %v log max backups: %v "+ + "log max age: %v log verbose: %v, log compress: %v", version.GetVersionAsString(), configDir, configFile, logMaxSize, + logMaxBackups, logMaxAge, logVerbose, logCompress) + config.LoadConfig(configDir, configFile) + return httpd.StartHTTPServer(configDir, config.GetHTTPDConfig()) +} diff --git a/examples/ldapauthserver/config/config.go b/examples/ldapauthserver/config/config.go new file mode 100644 index 00000000..cf353630 --- /dev/null +++ b/examples/ldapauthserver/config/config.go @@ -0,0 +1,158 @@ +package config + +import ( + "strings" + + "github.com/drakkan/sftpgo/ldapauthserver/logger" + "github.com/spf13/viper" +) + +const ( + logSender = "config" + // DefaultConfigName defines the name for the default config file. + // This is the file name without extension, we use viper and so we + // support all the config files format supported by viper + DefaultConfigName = "ldapauth" + // ConfigEnvPrefix defines a prefix that ENVIRONMENT variables will use + configEnvPrefix = "ldapauth" +) + +// HTTPDConfig defines configuration for the HTTPD server +type HTTPDConfig struct { + BindAddress string `mapstructure:"bind_address"` + BindPort int `mapstructure:"bind_port"` + AuthUserFile string `mapstructure:"auth_user_file"` + CertificateFile string `mapstructure:"certificate_file"` + CertificateKeyFile string `mapstructure:"certificate_key_file"` +} + +// LDAPConfig defines the configuration parameters for LDAP connections and searchs +type LDAPConfig struct { + BaseDN string `mapstructure:"basedn"` + BindURL string `mapstructure:"bind_url"` + BindUsername string `mapstructure:"bind_username"` + BindPassword string `mapstructure:"bind_password"` + SearchFilter string `mapstructure:"search_filter"` + SearchBaseAttrs []string `mapstructure:"search_base_attrs"` + DefaultUID int `mapstructure:"default_uid"` + DefaultGID int `mapstructure:"default_gid"` + ForceDefaultUID bool `mapstructure:"force_default_uid"` + ForceDefaultGID bool `mapstructure:"force_default_gid"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` + CACertificates []string `mapstructure:"ca_certificates"` +} + +type appConfig struct { + HTTPD HTTPDConfig `mapstructure:"httpd"` + LDAP LDAPConfig `mapstructure:"ldap"` +} + +var conf appConfig + +func init() { + conf = appConfig{ + HTTPD: HTTPDConfig{ + BindAddress: "", + BindPort: 9000, + AuthUserFile: "", + CertificateFile: "", + CertificateKeyFile: "", + }, + LDAP: LDAPConfig{ + BaseDN: "dc=example,dc=com", + BindURL: "ldap://192.168.1.103:389", + BindUsername: "cn=Directory Manager", + BindPassword: "YOUR_ADMIN_PASSWORD_HERE", + SearchFilter: "(&(objectClass=nsPerson)(uid=%s))", + SearchBaseAttrs: []string{ + "dn", + "homeDirectory", + "uidNumber", + "gidNumber", + "nsSshPublicKey", + }, + DefaultUID: 0, + DefaultGID: 0, + ForceDefaultUID: true, + ForceDefaultGID: true, + InsecureSkipVerify: false, + CACertificates: nil, + }, + } + viper.SetEnvPrefix(configEnvPrefix) + replacer := strings.NewReplacer(".", "__") + viper.SetEnvKeyReplacer(replacer) + viper.SetConfigName(DefaultConfigName) + viper.AutomaticEnv() + viper.AllowEmptyEnv(true) +} + +// GetHomeDirectory returns the configured name for the LDAP field to use as home directory +func (l *LDAPConfig) GetHomeDirectory() string { + if len(l.SearchBaseAttrs) > 1 { + return l.SearchBaseAttrs[1] + } + return "homeDirectory" +} + +// GetUIDNumber returns the configured name for the LDAP field to use as UID +func (l *LDAPConfig) GetUIDNumber() string { + if len(l.SearchBaseAttrs) > 2 { + return l.SearchBaseAttrs[2] + } + return "uidNumber" +} + +// GetGIDNumber returns the configured name for the LDAP field to use as GID +func (l *LDAPConfig) GetGIDNumber() string { + if len(l.SearchBaseAttrs) > 3 { + return l.SearchBaseAttrs[3] + } + return "gidNumber" +} + +// GetPublicKey returns the configured name for the LDAP field to use as public keys +func (l *LDAPConfig) GetPublicKey() string { + if len(l.SearchBaseAttrs) > 4 { + return l.SearchBaseAttrs[4] + } + return "nsSshPublicKey" +} + +// GetHTTPDConfig returns the configuration for the HTTP server +func GetHTTPDConfig() HTTPDConfig { + return conf.HTTPD +} + +// GetLDAPConfig returns LDAP related settings +func GetLDAPConfig() LDAPConfig { + return conf.LDAP +} + +func getRedactedConf() appConfig { + c := conf + return c +} + +// LoadConfig loads the configuration +func LoadConfig(configDir, configName string) error { + var err error + viper.AddConfigPath(configDir) + viper.AddConfigPath(".") + viper.SetConfigName(configName) + if err = viper.ReadInConfig(); err != nil { + logger.Warn(logSender, "", "error loading configuration file: %v. Default configuration will be used: %+v", + err, getRedactedConf()) + logger.WarnToConsole("error loading configuration file: %v. Default configuration will be used.", err) + return err + } + err = viper.Unmarshal(&conf) + if err != nil { + logger.Warn(logSender, "", "error parsing configuration file: %v. Default configuration will be used: %+v", + err, getRedactedConf()) + logger.WarnToConsole("error parsing configuration file: %v. Default configuration will be used.", err) + return err + } + logger.Debug(logSender, "", "config file used: '%#v', config loaded: %+v", viper.ConfigFileUsed(), getRedactedConf()) + return err +} diff --git a/examples/ldapauthserver/go.mod b/examples/ldapauthserver/go.mod new file mode 100644 index 00000000..bfaaa6a7 --- /dev/null +++ b/examples/ldapauthserver/go.mod @@ -0,0 +1,26 @@ +module github.com/drakkan/sftpgo/ldapauthserver + +go 1.14 + +require ( + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-asn1-ber/asn1-ber v1.4.1 // indirect + github.com/go-chi/chi v4.1.1+incompatible + github.com/go-chi/render v1.0.1 + github.com/go-ldap/ldap/v3 v3.1.8 + github.com/mitchellh/mapstructure v1.2.2 // indirect + github.com/nathanaelle/password/v2 v2.0.1 + github.com/pelletier/go-toml v1.7.0 // indirect + github.com/rs/zerolog v1.18.0 + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/cobra v1.0.0 + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.6.3 + golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a + golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect + golang.org/x/text v0.3.2 // indirect + gopkg.in/ini.v1 v1.55.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 +) diff --git a/examples/ldapauthserver/go.sum b/examples/ldapauthserver/go.sum new file mode 100644 index 00000000..e998d4f4 --- /dev/null +++ b/examples/ldapauthserver/go.sum @@ -0,0 +1,228 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +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/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck= +github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.4.1 h1:qP/QDxOtmMoJVgXHCXNzDpA0+wkgYB2x5QoLMVOciyw= +github.com/go-asn1-ber/asn1-ber v1.4.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4= +github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= +github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-ldap/ldap/v3 v3.1.8 h1:5vU/2jOh9HqprwXp8aF915s9p6Z8wmbSEVF7/gdTFhM= +github.com/go-ldap/ldap/v3 v3.1.8/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= +github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nathanaelle/password/v2 v2.0.1 h1:ItoCTdsuIWzilYmllQPa3DR3YoCXcpfxScWLqr8Ii2s= +github.com/nathanaelle/password/v2 v2.0.1/go.mod h1:eaoT+ICQEPNtikBRIAatN8ThWwMhVG+r1jTw60BvPJk= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= +github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs= +github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a h1:y6sBfNd1b9Wy08a6K1Z1DZc4aXABUN5TKjkYhz7UKmo= +golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= +gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/examples/ldapauthserver/httpd/auth.go b/examples/ldapauthserver/httpd/auth.go new file mode 100644 index 00000000..aad3a023 --- /dev/null +++ b/examples/ldapauthserver/httpd/auth.go @@ -0,0 +1,146 @@ +package httpd + +import ( + "encoding/csv" + "errors" + "fmt" + "net/http" + "os" + "sync" + + unixcrypt "github.com/nathanaelle/password/v2" + + "github.com/drakkan/sftpgo/ldapauthserver/logger" + "github.com/drakkan/sftpgo/ldapauthserver/utils" + "golang.org/x/crypto/bcrypt" +) + +const ( + authenticationHeader = "WWW-Authenticate" + authenticationRealm = "LDAP Auth Server" + unauthResponse = "Unauthorized" +) + +var ( + md5CryptPwdPrefixes = []string{"$1$", "$apr1$"} + bcryptPwdPrefixes = []string{"$2a$", "$2$", "$2x$", "$2y$", "$2b$"} +) + +type httpAuthProvider interface { + getHashedPassword(username string) (string, bool) + isEnabled() bool +} + +type basicAuthProvider struct { + Path string + Info os.FileInfo + Users map[string]string + lock *sync.RWMutex +} + +func newBasicAuthProvider(authUserFile string) (httpAuthProvider, error) { + basicAuthProvider := basicAuthProvider{ + Path: authUserFile, + Info: nil, + Users: make(map[string]string), + lock: new(sync.RWMutex), + } + return &basicAuthProvider, basicAuthProvider.loadUsers() +} + +func (p *basicAuthProvider) isEnabled() bool { + return len(p.Path) > 0 +} + +func (p *basicAuthProvider) isReloadNeeded(info os.FileInfo) bool { + p.lock.RLock() + defer p.lock.RUnlock() + return p.Info == nil || p.Info.ModTime() != info.ModTime() || p.Info.Size() != info.Size() +} + +func (p *basicAuthProvider) loadUsers() error { + if !p.isEnabled() { + return nil + } + info, err := os.Stat(p.Path) + if err != nil { + logger.Debug(logSender, "", "unable to stat basic auth users file: %v", err) + return err + } + if p.isReloadNeeded(info) { + r, err := os.Open(p.Path) + if err != nil { + logger.Debug(logSender, "", "unable to open basic auth users file: %v", err) + return err + } + defer r.Close() + reader := csv.NewReader(r) + reader.Comma = ':' + reader.Comment = '#' + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + logger.Debug(logSender, "", "unable to parse basic auth users file: %v", err) + return err + } + p.lock.Lock() + defer p.lock.Unlock() + p.Users = make(map[string]string) + for _, record := range records { + if len(record) == 2 { + p.Users[record[0]] = record[1] + } + } + logger.Debug(logSender, "", "number of users loaded for httpd basic auth: %v", len(p.Users)) + p.Info = info + } + return nil +} + +func (p *basicAuthProvider) getHashedPassword(username string) (string, bool) { + err := p.loadUsers() + if err != nil { + return "", false + } + p.lock.RLock() + defer p.lock.RUnlock() + pwd, ok := p.Users[username] + return pwd, ok +} + +func checkAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !validateCredentials(r) { + w.Header().Set(authenticationHeader, fmt.Sprintf("Basic realm=\"%v\"", authenticationRealm)) + sendAPIResponse(w, r, errors.New(unauthResponse), "", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func validateCredentials(r *http.Request) bool { + if !httpAuth.isEnabled() { + return true + } + username, password, ok := r.BasicAuth() + if !ok { + return false + } + if hashedPwd, ok := httpAuth.getHashedPassword(username); ok { + if utils.IsStringPrefixInSlice(hashedPwd, bcryptPwdPrefixes) { + err := bcrypt.CompareHashAndPassword([]byte(hashedPwd), []byte(password)) + return err == nil + } + if utils.IsStringPrefixInSlice(hashedPwd, md5CryptPwdPrefixes) { + crypter, ok := unixcrypt.MD5.CrypterFound(hashedPwd) + if !ok { + err := errors.New("cannot found matching MD5 crypter") + logger.Debug(logSender, "", "error comparing password with MD5 crypt hash: %v", err) + return false + } + return crypter.Verify([]byte(password)) + } + } + return false +} diff --git a/examples/ldapauthserver/httpd/httpd.go b/examples/ldapauthserver/httpd/httpd.go new file mode 100644 index 00000000..02ecafbc --- /dev/null +++ b/examples/ldapauthserver/httpd/httpd.go @@ -0,0 +1,148 @@ +package httpd + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + "time" + + "github.com/drakkan/sftpgo/ldapauthserver/config" + "github.com/drakkan/sftpgo/ldapauthserver/logger" + "github.com/drakkan/sftpgo/ldapauthserver/utils" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/render" +) + +const ( + logSender = "httpd" + versionPath = "/api/v1/version" + checkAuthPath = "/api/v1/check_auth" + maxRequestSize = 1 << 18 // 256KB +) + +var ( + ldapConfig config.LDAPConfig + httpAuth httpAuthProvider + certMgr *certManager + rootCAs *x509.CertPool +) + +// StartHTTPServer initializes and starts the HTTP Server +func StartHTTPServer(configDir string, httpConfig config.HTTPDConfig) error { + var err error + authUserFile := getConfigPath(httpConfig.AuthUserFile, configDir) + httpAuth, err = newBasicAuthProvider(authUserFile) + if err != nil { + return err + } + + router := chi.NewRouter() + router.Use(middleware.RequestID) + router.Use(middleware.RealIP) + router.Use(logger.NewStructuredLogger(logger.GetLogger())) + router.Use(middleware.Recoverer) + + router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendAPIResponse(w, r, nil, "Not Found", http.StatusNotFound) + })) + + router.MethodNotAllowed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendAPIResponse(w, r, nil, "Method not allowed", http.StatusMethodNotAllowed) + })) + + router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, utils.GetAppVersion()) + }) + + router.Group(func(router chi.Router) { + router.Use(checkAuth) + + router.Post(checkAuthPath, checkSFTPGoUserAuth) + }) + + ldapConfig = config.GetLDAPConfig() + loadCACerts(configDir) + + certificateFile := getConfigPath(httpConfig.CertificateFile, configDir) + certificateKeyFile := getConfigPath(httpConfig.CertificateKeyFile, configDir) + + httpServer := &http.Server{ + Addr: fmt.Sprintf("%s:%d", httpConfig.BindAddress, httpConfig.BindPort), + Handler: router, + ReadTimeout: 70 * time.Second, + WriteTimeout: 70 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 16, // 64KB + } + if len(certificateFile) > 0 && len(certificateKeyFile) > 0 { + certMgr, err = newCertManager(certificateFile, certificateKeyFile) + if err != nil { + return err + } + config := &tls.Config{ + GetCertificate: certMgr.GetCertificateFunc(), + } + httpServer.TLSConfig = config + return httpServer.ListenAndServeTLS("", "") + } + return httpServer.ListenAndServe() +} + +func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) { + var errorString string + if err != nil { + errorString = err.Error() + } + resp := apiResponse{ + Error: errorString, + Message: message, + HTTPStatus: code, + } + ctx := context.WithValue(r.Context(), render.StatusCtxKey, code) + render.JSON(w, r.WithContext(ctx), resp) +} + +func loadCACerts(configDir string) error { + var err error + rootCAs, err = x509.SystemCertPool() + if err != nil { + rootCAs = x509.NewCertPool() + } + for _, ca := range ldapConfig.CACertificates { + caPath := getConfigPath(ca, configDir) + certs, err := ioutil.ReadFile(caPath) + if err != nil { + logger.Warn(logSender, "", "error loading ca cert %#v: %v", caPath, err) + return err + } + if !rootCAs.AppendCertsFromPEM(certs) { + logger.Warn(logSender, "", "unable to add ca cert %#v", caPath) + } else { + logger.Debug(logSender, "", "ca cert %#v added to the trusted certificates", caPath) + } + } + + return nil +} + +// ReloadTLSCertificate reloads the TLS certificate and key from the configured paths +func ReloadTLSCertificate() { + if certMgr != nil { + certMgr.loadCertificate() + } +} + +func getConfigPath(name, configDir string) string { + if !utils.IsFileInputValid(name) { + return "" + } + if len(name) > 0 && !filepath.IsAbs(name) { + return filepath.Join(configDir, name) + } + return name +} diff --git a/examples/ldapauthserver/httpd/ldapauth.go b/examples/ldapauthserver/httpd/ldapauth.go new file mode 100644 index 00000000..90f20a0a --- /dev/null +++ b/examples/ldapauthserver/httpd/ldapauth.go @@ -0,0 +1,143 @@ +package httpd + +import ( + "bytes" + "crypto/tls" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/drakkan/sftpgo/ldapauthserver/logger" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/render" + "github.com/go-ldap/ldap/v3" + "golang.org/x/crypto/ssh" +) + +func getSFTPGoUser(entry *ldap.Entry, username string) (SFTPGoUser, error) { + var err error + var user SFTPGoUser + uid := ldapConfig.DefaultUID + gid := ldapConfig.DefaultGID + status := 1 + + if !ldapConfig.ForceDefaultUID { + uid, err = strconv.Atoi(entry.GetAttributeValue(ldapConfig.GetUIDNumber())) + if err != nil { + return user, err + } + } + + if !ldapConfig.ForceDefaultGID { + uid, err = strconv.Atoi(entry.GetAttributeValue(ldapConfig.GetGIDNumber())) + if err != nil { + return user, err + } + } + + sftpgoUser := SFTPGoUser{ + Username: username, + HomeDir: entry.GetAttributeValue(ldapConfig.GetHomeDirectory()), + UID: uid, + GID: gid, + Status: status, + } + sftpgoUser.Permissions = make(map[string][]string) + sftpgoUser.Permissions["/"] = []string{"*"} + return sftpgoUser, nil +} + +func checkSFTPGoUserAuth(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + var authReq externalAuthRequest + err := render.DecodeJSON(r.Body, &authReq) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "error decoding auth request: %v", err) + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + l, err := ldap.DialURL(ldapConfig.BindURL, ldap.DialWithTLSConfig(&tls.Config{ + InsecureSkipVerify: ldapConfig.InsecureSkipVerify, + RootCAs: rootCAs, + })) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "error connecting to the LDAP server: %v", err) + sendAPIResponse(w, r, err, "Error connecting to the LDAP server", http.StatusInternalServerError) + return + } + defer l.Close() + + err = l.Bind(ldapConfig.BindUsername, ldapConfig.BindPassword) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "error binding to the LDAP server: %v", err) + sendAPIResponse(w, r, err, "Error binding to the LDAP server", http.StatusInternalServerError) + return + } + + searchRequest := ldap.NewSearchRequest( + ldapConfig.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + strings.Replace(ldapConfig.SearchFilter, "%s", authReq.Username, 1), + ldapConfig.SearchBaseAttrs, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "error searching LDAP user %#v: %v", authReq.Username, err) + sendAPIResponse(w, r, err, "Error searching LDAP user", http.StatusInternalServerError) + return + } + + if len(sr.Entries) != 1 { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "expected one user, found: %v", len(sr.Entries)) + sendAPIResponse(w, r, nil, fmt.Sprintf("Expected one user, found: %v", len(sr.Entries)), http.StatusNotFound) + return + } + + if len(authReq.PublicKey) > 0 { + userKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authReq.PublicKey)) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "invalid public key for user %#v: %v", authReq.Username, err) + sendAPIResponse(w, r, err, "Invalid public key", http.StatusBadRequest) + return + } + authOk := false + for _, k := range sr.Entries[0].GetAttributeValues(ldapConfig.GetPublicKey()) { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k)) + // we skip an invalid public key stored inside the LDAP server + if err != nil { + continue + } + if bytes.Equal(key.Marshal(), userKey.Marshal()) { + authOk = true + break + } + } + if !authOk { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "public key authentication failed for user: %#v", authReq.Username) + sendAPIResponse(w, r, nil, "public key authentication failed", http.StatusForbidden) + return + } + } else { + // bind to the LDAP server with the user dn and the given password to check the password + userdn := sr.Entries[0].DN + err = l.Bind(userdn, authReq.Password) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "password authentication failed for user: %#v", authReq.Username) + sendAPIResponse(w, r, nil, "password authentication failed", http.StatusForbidden) + return + } + } + + user, err := getSFTPGoUser(sr.Entries[0], authReq.Username) + if err != nil { + logger.Warn(logSender, middleware.GetReqID(r.Context()), "get user from LDAP entry failed for username %#v: %v", + authReq.Username, err) + sendAPIResponse(w, r, err, "mapping LDAP user failed", http.StatusInternalServerError) + return + } + + render.JSON(w, r, user) +} diff --git a/examples/ldapauthserver/httpd/models.go b/examples/ldapauthserver/httpd/models.go new file mode 100644 index 00000000..40b33367 --- /dev/null +++ b/examples/ldapauthserver/httpd/models.go @@ -0,0 +1,109 @@ +package httpd + +type apiResponse struct { + Error string `json:"error"` + Message string `json:"message"` + HTTPStatus int `json:"status"` +} + +type externalAuthRequest struct { + Username string `json:"username"` + Password string `json:"password"` + PublicKey string `json:"public_key"` +} + +// SFTPGoExtensionsFilter defines filters based on file extensions +type SFTPGoExtensionsFilter struct { + Path string `json:"path"` + AllowedExtensions []string `json:"allowed_extensions,omitempty"` + DeniedExtensions []string `json:"denied_extensions,omitempty"` +} + +// SFTPGoUserFilters defines additional restrictions for an SFTPGo user +type SFTPGoUserFilters struct { + AllowedIP []string `json:"allowed_ip,omitempty"` + DeniedIP []string `json:"denied_ip,omitempty"` + DeniedLoginMethods []string `json:"denied_login_methods,omitempty"` + FileExtensions []SFTPGoExtensionsFilter `json:"file_extensions,omitempty"` +} + +// S3FsConfig defines the configuration for S3 based filesystem +type S3FsConfig struct { + Bucket string `json:"bucket,omitempty"` + KeyPrefix string `json:"key_prefix,omitempty"` + Region string `json:"region,omitempty"` + AccessKey string `json:"access_key,omitempty"` + AccessSecret string `json:"access_secret,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + StorageClass string `json:"storage_class,omitempty"` + UploadPartSize int64 `json:"upload_part_size,omitempty"` + UploadConcurrency int `json:"upload_concurrency,omitempty"` +} + +// GCSFsConfig defines the configuration for Google Cloud Storage based filesystem +type GCSFsConfig struct { + Bucket string `json:"bucket,omitempty"` + KeyPrefix string `json:"key_prefix,omitempty"` + Credentials string `json:"credentials,omitempty"` + AutomaticCredentials int `json:"automatic_credentials,omitempty"` + StorageClass string `json:"storage_class,omitempty"` +} + +// SFTPGoFilesystem defines cloud storage filesystem details +type SFTPGoFilesystem struct { + // 0 local filesystem, 1 Amazon S3 compatible, 2 Google Cloud Storage + Provider int `json:"provider"` + S3Config S3FsConfig `json:"s3config,omitempty"` + GCSConfig GCSFsConfig `json:"gcsconfig,omitempty"` +} + +type virtualFolder struct { + VirtualPath string `json:"virtual_path"` + MappedPath string `json:"mapped_path"` +} + +// SFTPGoUser defines an SFTPGo user +type SFTPGoUser struct { + // Database unique identifier + ID int64 `json:"id"` + // 1 enabled, 0 disabled (login is not allowed) + Status int `json:"status"` + // Username + Username string `json:"username"` + // Account expiration date as unix timestamp in milliseconds. An expired account cannot login. + // 0 means no expiration + ExpirationDate int64 `json:"expiration_date"` + Password string `json:"password,omitempty"` + PublicKeys []string `json:"public_keys,omitempty"` + HomeDir string `json:"home_dir"` + // Mapping between virtual paths and filesystem paths outside the home directory. Supported for local filesystem only + VirtualFolders []virtualFolder `json:"virtual_folders,omitempty"` + // If sftpgo runs as root system user then the created files and directories will be assigned to this system UID + UID int `json:"uid"` + // If sftpgo runs as root system user then the created files and directories will be assigned to this system GID + GID int `json:"gid"` + // Maximum concurrent sessions. 0 means unlimited + MaxSessions int `json:"max_sessions"` + // Maximum size allowed as bytes. 0 means unlimited + QuotaSize int64 `json:"quota_size"` + // Maximum number of files allowed. 0 means unlimited + QuotaFiles int `json:"quota_files"` + // List of the granted permissions + Permissions map[string][]string `json:"permissions"` + // Used quota as bytes + UsedQuotaSize int64 `json:"used_quota_size"` + // Used quota as number of files + UsedQuotaFiles int `json:"used_quota_files"` + // Last quota update as unix timestamp in milliseconds + LastQuotaUpdate int64 `json:"last_quota_update"` + // Maximum upload bandwidth as KB/s, 0 means unlimited + UploadBandwidth int64 `json:"upload_bandwidth"` + // Maximum download bandwidth as KB/s, 0 means unlimited + DownloadBandwidth int64 `json:"download_bandwidth"` + // Last login as unix timestamp in milliseconds + LastLogin int64 `json:"last_login"` + // Additional restrictions + Filters SFTPGoUserFilters `json:"filters"` + // Filesystem configuration details + FsConfig SFTPGoFilesystem `json:"filesystem"` +} diff --git a/examples/ldapauthserver/httpd/tlsutils.go b/examples/ldapauthserver/httpd/tlsutils.go new file mode 100644 index 00000000..a267f99a --- /dev/null +++ b/examples/ldapauthserver/httpd/tlsutils.go @@ -0,0 +1,50 @@ +package httpd + +import ( + "crypto/tls" + "sync" + + "github.com/drakkan/sftpgo/ldapauthserver/logger" +) + +type certManager struct { + cert *tls.Certificate + certPath string + keyPath string + lock *sync.RWMutex +} + +func (m *certManager) loadCertificate() error { + newCert, err := tls.LoadX509KeyPair(m.certPath, m.keyPath) + if err != nil { + logger.Warn(logSender, "", "unable to load https certificate: %v", err) + return err + } + logger.Debug(logSender, "", "https certificate successfully loaded") + m.lock.Lock() + defer m.lock.Unlock() + m.cert = &newCert + return nil +} + +func (m *certManager) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + m.lock.RLock() + defer m.lock.RUnlock() + return m.cert, nil + } +} + +func newCertManager(certificateFile, certificateKeyFile string) (*certManager, error) { + manager := &certManager{ + cert: nil, + certPath: certificateFile, + keyPath: certificateKeyFile, + lock: new(sync.RWMutex), + } + err := manager.loadCertificate() + if err != nil { + return nil, err + } + return manager, nil +} diff --git a/examples/ldapauthserver/ldapauth.toml b/examples/ldapauthserver/ldapauth.toml new file mode 100644 index 00000000..4f3e160c --- /dev/null +++ b/examples/ldapauthserver/ldapauth.toml @@ -0,0 +1,33 @@ +[httpd] +bind_address = "" +bind_port = 9000 +# Path to a file used to store usernames and passwords for basic authentication. It can be generated using the Apache htpasswd tool +auth_user_file = "" +# If both the certificate and the private key are provided, the server will expect HTTPS connections +certificate_file = "" +certificate_key_file = "" + +[ldap] +basedn = "dc=example,dc=com" +bind_url = "ldap://127.0.0.1:389" +bind_username = "cn=Directory Manager" +bind_password = "YOUR_ADMIN_PASSWORD_HERE" +search_filter = "(&(objectClass=nsPerson)(uid=%s))" +# you can change the name of the search base attributes to adapt them to your schema but the order must remain the same +search_base_attrs = [ + "dn", + "homeDirectory", + "uidNumber", + "gidNumber", + "nsSshPublicKey" +] +default_uid = 0 +default_gid = 0 +force_default_uid = true +force_default_gid = true +# if true, ldaps accepts any certificate presented by the LDAP server and any host name in that certificate. +# This should be used only for testing +insecure_skip_verify = false +# list of root CA to use for ldaps connections +# If you use a self signed certificate is better to add the root CA to this list than set insecure_skip_verify to true +ca_certificates = [] diff --git a/examples/ldapauthserver/logger/logger.go b/examples/ldapauthserver/logger/logger.go new file mode 100644 index 00000000..3e46747b --- /dev/null +++ b/examples/ldapauthserver/logger/logger.go @@ -0,0 +1,127 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + + "github.com/rs/zerolog" + lumberjack "gopkg.in/natefinch/lumberjack.v2" +) + +const ( + dateFormat = "2006-01-02T15:04:05.000" // YYYY-MM-DDTHH:MM:SS.ZZZ +) + +var ( + logger zerolog.Logger + consoleLogger zerolog.Logger +) + +// GetLogger get the configured logger instance +func GetLogger() *zerolog.Logger { + return &logger +} + +// InitLogger initialize loggers +func InitLogger(logFilePath string, logMaxSize, logMaxBackups, logMaxAge int, logCompress bool, level zerolog.Level) { + zerolog.TimeFieldFormat = dateFormat + if isLogFilePathValid(logFilePath) { + logger = zerolog.New(&lumberjack.Logger{ + Filename: logFilePath, + MaxSize: logMaxSize, + MaxBackups: logMaxBackups, + MaxAge: logMaxAge, + Compress: logCompress, + }) + EnableConsoleLogger(level) + } else { + logger = zerolog.New(logSyncWrapper{ + output: os.Stdout, + lock: new(sync.Mutex)}) + consoleLogger = zerolog.Nop() + } + logger.Level(level) +} + +// DisableLogger disable the main logger. +// ConsoleLogger will not be affected +func DisableLogger() { + logger = zerolog.Nop() +} + +// EnableConsoleLogger enables the console logger +func EnableConsoleLogger(level zerolog.Level) { + consoleOutput := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: dateFormat, + NoColor: runtime.GOOS == "windows", + } + consoleLogger = zerolog.New(consoleOutput).With().Timestamp().Logger().Level(level) +} + +// Debug logs at debug level for the specified sender +func Debug(prefix, requestID string, format string, v ...interface{}) { + logger.Debug(). + Timestamp(). + Str("sender", prefix). + Str("request_id", requestID). + Msg(fmt.Sprintf(format, v...)) +} + +// Info logs at info level for the specified sender +func Info(prefix, requestID string, format string, v ...interface{}) { + logger.Info(). + Timestamp(). + Str("sender", prefix). + Str("request_id", requestID). + Msg(fmt.Sprintf(format, v...)) +} + +// Warn logs at warn level for the specified sender +func Warn(prefix, requestID string, format string, v ...interface{}) { + logger.Warn(). + Timestamp(). + Str("sender", prefix). + Str("request_id", requestID). + Msg(fmt.Sprintf(format, v...)) +} + +// Error logs at error level for the specified sender +func Error(prefix, requestID string, format string, v ...interface{}) { + logger.Error(). + Timestamp(). + Str("sender", prefix). + Str("request_id", requestID). + Msg(fmt.Sprintf(format, v...)) +} + +// DebugToConsole logs at debug level to stdout +func DebugToConsole(format string, v ...interface{}) { + consoleLogger.Debug().Msg(fmt.Sprintf(format, v...)) +} + +// InfoToConsole logs at info level to stdout +func InfoToConsole(format string, v ...interface{}) { + consoleLogger.Info().Msg(fmt.Sprintf(format, v...)) +} + +// WarnToConsole logs at info level to stdout +func WarnToConsole(format string, v ...interface{}) { + consoleLogger.Warn().Msg(fmt.Sprintf(format, v...)) +} + +// ErrorToConsole logs at error level to stdout +func ErrorToConsole(format string, v ...interface{}) { + consoleLogger.Error().Msg(fmt.Sprintf(format, v...)) +} + +func isLogFilePathValid(logFilePath string) bool { + cleanInput := filepath.Clean(logFilePath) + if cleanInput == "." || cleanInput == ".." { + return false + } + return true +} diff --git a/examples/ldapauthserver/logger/request_logger.go b/examples/ldapauthserver/logger/request_logger.go new file mode 100644 index 00000000..9a7d806f --- /dev/null +++ b/examples/ldapauthserver/logger/request_logger.go @@ -0,0 +1,73 @@ +package logger + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog" +) + +// StructuredLogger defines a simple wrapper around zerolog logger. +// It implements chi.middleware.LogFormatter interface +type StructuredLogger struct { + Logger *zerolog.Logger +} + +// StructuredLoggerEntry ... +type StructuredLoggerEntry struct { + Logger *zerolog.Logger + fields map[string]interface{} +} + +// NewStructuredLogger returns a chi.middleware.RequestLogger using our StructuredLogger. +// This structured logger is called by the chi.middleware.Logger handler to log each HTTP request +func NewStructuredLogger(logger *zerolog.Logger) func(next http.Handler) http.Handler { + return middleware.RequestLogger(&StructuredLogger{logger}) +} + +// NewLogEntry creates a new log entry for an HTTP request +func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + fields := map[string]interface{}{ + "remote_addr": r.RemoteAddr, + "proto": r.Proto, + "method": r.Method, + "user_agent": r.UserAgent(), + "uri": fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)} + + reqID := middleware.GetReqID(r.Context()) + if reqID != "" { + fields["request_id"] = reqID + } + + return &StructuredLoggerEntry{Logger: l.Logger, fields: fields} +} + +// Write logs a new entry at the end of the HTTP request +func (l *StructuredLoggerEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { + l.Logger.Info(). + Timestamp(). + Str("sender", "httpd"). + Fields(l.fields). + Int("resp_status", status). + Int("resp_size", bytes). + Int64("elapsed_ms", elapsed.Nanoseconds()/1000000). + Msg("") +} + +// Panic logs panics +func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) { + l.Logger.Error(). + Timestamp(). + Str("sender", "httpd"). + Fields(l.fields). + Str("stack", string(stack)). + Str("panic", fmt.Sprintf("%+v", v)). + Msg("") +} diff --git a/examples/ldapauthserver/logger/sync_wrapper.go b/examples/ldapauthserver/logger/sync_wrapper.go new file mode 100644 index 00000000..f4baf66c --- /dev/null +++ b/examples/ldapauthserver/logger/sync_wrapper.go @@ -0,0 +1,17 @@ +package logger + +import ( + "os" + "sync" +) + +type logSyncWrapper struct { + lock *sync.Mutex + output *os.File +} + +func (l logSyncWrapper) Write(b []byte) (n int, err error) { + l.lock.Lock() + defer l.lock.Unlock() + return l.output.Write(b) +} diff --git a/examples/ldapauthserver/main.go b/examples/ldapauthserver/main.go new file mode 100644 index 00000000..25521625 --- /dev/null +++ b/examples/ldapauthserver/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/drakkan/sftpgo/ldapauthserver/cmd" + +func main() { + cmd.Execute() +} diff --git a/examples/ldapauthserver/utils/utils.go b/examples/ldapauthserver/utils/utils.go new file mode 100644 index 00000000..62eb340b --- /dev/null +++ b/examples/ldapauthserver/utils/utils.go @@ -0,0 +1,28 @@ +package utils + +import ( + "path/filepath" + "strings" +) + +// IsFileInputValid returns true this is a valid file name. +// This method must be used before joining a file name, generally provided as +// user input, with a directory +func IsFileInputValid(fileInput string) bool { + cleanInput := filepath.Clean(fileInput) + if cleanInput == "." || cleanInput == ".." { + return false + } + return true +} + +// IsStringPrefixInSlice searches a string prefix in a slice and returns true +// if a matching prefix is found +func IsStringPrefixInSlice(obj string, list []string) bool { + for _, v := range list { + if strings.HasPrefix(obj, v) { + return true + } + } + return false +} diff --git a/examples/ldapauthserver/utils/version.go b/examples/ldapauthserver/utils/version.go new file mode 100644 index 00000000..11de6f4f --- /dev/null +++ b/examples/ldapauthserver/utils/version.go @@ -0,0 +1,41 @@ +package utils + +const version = "0.1.0-dev" + +var ( + commit = "" + date = "" + versionInfo VersionInfo +) + +// VersionInfo defines version details +type VersionInfo struct { + Version string `json:"version"` + BuildDate string `json:"build_date"` + CommitHash string `json:"commit_hash"` +} + +func init() { + versionInfo = VersionInfo{ + Version: version, + CommitHash: commit, + BuildDate: date, + } +} + +// GetVersionAsString returns the string representation of the VersionInfo struct +func (v *VersionInfo) GetVersionAsString() string { + versionString := v.Version + if len(v.CommitHash) > 0 { + versionString += "-" + v.CommitHash + } + if len(v.BuildDate) > 0 { + versionString += "-" + v.BuildDate + } + return versionString +} + +// GetAppVersion returns VersionInfo struct +func GetAppVersion() VersionInfo { + return versionInfo +}