add SMTP support

it will be used in future update to add email sending capabilities
This commit is contained in:
Nicola Murino 2021-09-26 20:25:37 +02:00
parent 0661876e99
commit da0ccc6426
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
11 changed files with 302 additions and 12 deletions

View file

@ -71,6 +71,7 @@ var (
)
func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.Flags().BoolP("version", "v", false, "")
rootCmd.Version = version.GetAsString()
rootCmd.SetVersionTemplate(`{{printf "SFTPGo "}}{{printf "%s" .Version}}

54
cmd/smtptest.go Normal file
View file

@ -0,0 +1,54 @@
package cmd
import (
"os"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/drakkan/sftpgo/v2/config"
"github.com/drakkan/sftpgo/v2/logger"
"github.com/drakkan/sftpgo/v2/smtp"
"github.com/drakkan/sftpgo/v2/util"
)
var (
smtpTestRecipient string
smtpTestCmd = &cobra.Command{
Use: "smtptest",
Short: "Test the SMTP configuration",
Long: `SFTPGo will try to send a test email to the specified recipient.
If the SMTP configuration is correct you should receive this email.`,
Run: func(cmd *cobra.Command, args []string) {
logger.DisableLogger()
logger.EnableConsoleLogger(zerolog.DebugLevel)
configDir = util.CleanDirInput(configDir)
err := config.LoadConfig(configDir, configFile)
if err != nil {
logger.WarnToConsole("Unable to initialize data provider, config load error: %v", err)
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
if err != nil {
logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)
os.Exit(1)
}
err = smtp.SendEmail(smtpTestRecipient, "SFTPGo - Testing Email Settings", "It appears your SFTPGo email is setup correctly!",
smtp.EmailContentTypeTextPlain)
if err != nil {
logger.WarnToConsole("Error sending email: %v", err)
os.Exit(1)
}
logger.InfoToConsole("No errors were reported while sending an email. Please check your inbox to make sure.")
},
}
)
func init() {
addConfigFlags(smtpTestCmd)
smtpTestCmd.Flags().StringVar(&smtpTestRecipient, "recipient", "", `email address to send the test e-mail to`)
smtpTestCmd.MarkFlagRequired("recipient") //nolint:errcheck
rootCmd.AddCommand(smtpTestCmd)
}

View file

@ -86,6 +86,12 @@ Command-line flags should be specified in the Subsystem declaration.
logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
if err != nil {
logger.Error(logSender, connectionID, "unable to initialize SMTP configuration: %v", err)
os.Exit(1)
}
dataProviderConf := config.GetProviderConf()
if dataProviderConf.Driver == dataprovider.SQLiteDataProviderName || dataProviderConf.Driver == dataprovider.BoltDataProviderName {
logger.Debug(logSender, connectionID, "data provider %#v not supported in subsystem mode, using %#v provider",

View file

@ -21,6 +21,7 @@ import (
"github.com/drakkan/sftpgo/v2/mfa"
"github.com/drakkan/sftpgo/v2/sdk/plugin"
"github.com/drakkan/sftpgo/v2/sftpd"
"github.com/drakkan/sftpgo/v2/smtp"
"github.com/drakkan/sftpgo/v2/telemetry"
"github.com/drakkan/sftpgo/v2/util"
"github.com/drakkan/sftpgo/v2/version"
@ -107,6 +108,7 @@ type globalConfig struct {
MFAConfig mfa.Config `json:"mfa" mapstructure:"mfa"`
TelemetryConfig telemetry.Conf `json:"telemetry" mapstructure:"telemetry"`
PluginsConfig []plugin.Config `json:"plugins" mapstructure:"plugins"`
SMTPConfig smtp.Config `json:"smtp" mapstructure:"smtp"`
}
func init() {
@ -305,6 +307,16 @@ func Init() {
TLSCipherSuites: nil,
},
PluginsConfig: nil,
SMTPConfig: smtp.Config{
Host: "",
Port: 25,
From: "",
User: "",
Password: "",
AuthType: 0,
Encryption: 0,
Domain: "",
},
}
viper.SetEnvPrefix(configEnvPrefix)
@ -411,6 +423,11 @@ func GetMFAConfig() mfa.Config {
return globalConf.MFAConfig
}
// GetSMTPConfig returns the SMTP configuration
func GetSMTPConfig() smtp.Config {
return globalConf.SMTPConfig
}
// HasServicesToStart returns true if the config defines at least a service to start.
// Supported services are SFTP, FTP and WebDAV
func HasServicesToStart() bool {
@ -426,19 +443,24 @@ func HasServicesToStart() bool {
return false
}
func getRedactedPassword() string {
return "[redacted]"
}
func getRedactedGlobalConf() globalConfig {
conf := globalConf
conf.Common.Actions.Hook = util.GetRedactedURL(conf.Common.Actions.Hook)
conf.Common.StartupHook = util.GetRedactedURL(conf.Common.StartupHook)
conf.Common.PostConnectHook = util.GetRedactedURL(conf.Common.PostConnectHook)
conf.SFTPD.KeyboardInteractiveHook = util.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook)
conf.HTTPDConfig.SigningPassphrase = "[redacted]"
conf.ProviderConf.Password = "[redacted]"
conf.HTTPDConfig.SigningPassphrase = getRedactedPassword()
conf.ProviderConf.Password = getRedactedPassword()
conf.ProviderConf.Actions.Hook = util.GetRedactedURL(conf.ProviderConf.Actions.Hook)
conf.ProviderConf.ExternalAuthHook = util.GetRedactedURL(conf.ProviderConf.ExternalAuthHook)
conf.ProviderConf.PreLoginHook = util.GetRedactedURL(conf.ProviderConf.PreLoginHook)
conf.ProviderConf.PostLoginHook = util.GetRedactedURL(conf.ProviderConf.PostLoginHook)
conf.ProviderConf.CheckPasswordHook = util.GetRedactedURL(conf.ProviderConf.CheckPasswordHook)
conf.SMTPConfig.Password = getRedactedPassword()
return conf
}
@ -1146,6 +1168,14 @@ func setViperDefaults() {
viper.SetDefault("telemetry.certificate_file", globalConf.TelemetryConfig.CertificateFile)
viper.SetDefault("telemetry.certificate_key_file", globalConf.TelemetryConfig.CertificateKeyFile)
viper.SetDefault("telemetry.tls_cipher_suites", globalConf.TelemetryConfig.TLSCipherSuites)
viper.SetDefault("smtp.host", globalConf.SMTPConfig.Host)
viper.SetDefault("smtp.port", globalConf.SMTPConfig.Port)
viper.SetDefault("smtp.from", globalConf.SMTPConfig.From)
viper.SetDefault("smtp.user", globalConf.SMTPConfig.User)
viper.SetDefault("smtp.password", globalConf.SMTPConfig.Password)
viper.SetDefault("smtp.auth_type", globalConf.SMTPConfig.AuthType)
viper.SetDefault("smtp.encryption", globalConf.SMTPConfig.Encryption)
viper.SetDefault("smtp.domain", globalConf.SMTPConfig.Domain)
}
func lookupBoolFromEnv(envName string) (bool, bool) {

View file

@ -20,6 +20,7 @@ import (
"github.com/drakkan/sftpgo/v2/kms"
"github.com/drakkan/sftpgo/v2/mfa"
"github.com/drakkan/sftpgo/v2/sftpd"
"github.com/drakkan/sftpgo/v2/smtp"
"github.com/drakkan/sftpgo/v2/util"
)
@ -42,6 +43,7 @@ func TestLoadConfigTest(t *testing.T) {
assert.NotEqual(t, dataprovider.Config{}, config.GetProviderConf())
assert.NotEqual(t, sftpd.Configuration{}, config.GetSFTPDConfig())
assert.NotEqual(t, httpclient.Config{}, config.GetHTTPConfig())
assert.NotEqual(t, smtp.Config{}, config.GetSMTPConfig())
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
err = config.LoadConfig(configDir, confName)
@ -340,6 +342,24 @@ func TestSSHCommandsFromEnv(t *testing.T) {
}
}
func TestSMTPFromEnv(t *testing.T) {
reset()
os.Setenv("SFTPGO_SMTP__HOST", "smtp.example.com")
os.Setenv("SFTPGO_SMTP__PORT", "587")
t.Cleanup(func() {
os.Unsetenv("SFTPGO_SMTP__HOST")
os.Unsetenv("SFTPGO_SMTP__PORT")
})
configDir := ".."
err := config.LoadConfig(configDir, "")
assert.NoError(t, err)
smtpConfig := config.GetSMTPConfig()
assert.Equal(t, "smtp.example.com", smtpConfig.Host)
assert.Equal(t, 587, smtpConfig.Port)
}
func TestMFAFromEnv(t *testing.T) {
reset()

View file

@ -9,11 +9,14 @@ Usage:
sftpgo [command]
Available Commands:
gen A collection of useful generators
help Help about any command
initprovider Initializes and/or updates the configured data provider
portable Serve a single directory
serve Start the SFTP Server
gen A collection of useful generators
help Help about any command
initprovider Initializes and/or updates the configured data provider
portable Serve a single directory
revertprovider Revert the configured data provider to a previous version
serve Start the SFTPGo service
smtptest Test the SMTP configuration
startsubsys Use SFTPGo as SFTP file transfer subsystem
Flags:
-h, --help help for sftpgo
@ -257,6 +260,15 @@ The configuration file contains the following sections:
- `name`, string. Unique configuration name. This name should not be changed if there are users or admins using the configuration. The name is not exposed to the authentication apps. Default: `Default`.
- `issuer`, string. Name of the issuing Organization/Company. Default: `SFTPGo`.
- `algo`, string. Algorithm to use for HMAC. The supported algorithms are: `sha1`, `sha256`, `sha512`. Currently Google Authenticator app on iPhone seems to only support `sha1`, please check the compatibility with your target apps/device before setting a different algorithm. You can also define multiple configurations, for example one that uses `sha256` or `sha512` and another one that uses `sha1` and instruct your users to use the appropriate configuration for their devices/apps. The algorithm should not be changed if there are users or admins using the configuration. Default: `sha1`.
- **smtp**, SMTP configuration enables SFTPGo email sending capabilities
- `host`, string. Location of SMTP email server. Leavy empty to disable email sending capabilities. Default: empty.
- `port`, integer. Port of SMTP email server.
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Default: empty
- `user`, string. SMTP username. Default: empty
- `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: empty
- `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`. Default: `0`.
- `encryption`, integer. 0 means no encryption, 1 means `TLS`, 2 means `STARTTLS`. Default: `0`.
- `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: empty.
- **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
- `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`.
- `notifier_options`, struct. Defines the options for notifier plugins.

5
go.mod
View file

@ -29,7 +29,7 @@ require (
github.com/klauspost/compress v1.13.6
github.com/kr/text v0.2.0 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/jwx v1.2.6
github.com/lestrrat-go/jwx v1.2.7
github.com/lib/pq v1.10.3
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mattn/go-isatty v0.0.14 // indirect
@ -43,7 +43,7 @@ require (
github.com/pkg/sftp v1.13.3
github.com/pquerna/otp v1.3.0
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.30.0 // indirect
github.com/prometheus/common v0.31.0 // indirect
github.com/rs/cors v1.8.0
github.com/rs/xid v1.3.0
github.com/rs/zerolog v1.25.0
@ -55,6 +55,7 @@ require (
github.com/stretchr/testify v1.7.0
github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df
github.com/wagslane/go-password-validator v0.3.0
github.com/xhit/go-simple-mail/v2 v2.10.0
github.com/yl2chen/cidranger v1.0.2
go.etcd.io/bbolt v1.3.6
go.uber.org/automaxprocs v1.4.0

10
go.sum
View file

@ -544,12 +544,14 @@ github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBB
github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
github.com/lestrrat-go/codegen v1.0.2/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.2.6 h1:XAgfuHaOB7fDZ/6WhVgl8K89af768dU+3Nx4DlTbLIk=
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
github.com/lestrrat-go/jwx v1.2.7 h1:wO7fEc3PW56wpQBMU5CyRkrk4DVsXxCoJg7oIm5HHE4=
github.com/lestrrat-go/jwx v1.2.7/go.mod h1:bw24IXWbavc0R2RsOtpXL7RtMyP589yZ1+L7kd09ZGA=
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -679,8 +681,8 @@ github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.30.0 h1:JEkYlQnpzrzQFxi6gnukFPdQ+ac82oRhzMcIduJu/Ug=
github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.31.0 h1:FTJdLTjtrh4dXlCjpzdZJXMnejSTL5F/nVQm5sNwD34=
github.com/prometheus/common v0.31.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
@ -762,6 +764,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/xhit/go-simple-mail/v2 v2.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8=
github.com/xhit/go-simple-mail/v2 v2.10.0/go.mod h1:kA1XbQfCI4JxQ9ccSN6VFyIEkkugOm7YiPkA5hKiQn4=
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View file

@ -110,6 +110,13 @@ func (s *Service) Start() error {
logger.ErrorToConsole("unable to initialize plugin system: %v", err)
os.Exit(1)
}
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize()
if err != nil {
logger.Error(logSender, "", "unable to initialize SMTP configuration: %v", err)
logger.ErrorToConsole("unable to initialize SMTP configuration: %v", err)
os.Exit(1)
}
providerConf := config.GetProviderConf()

View file

@ -254,5 +254,15 @@
}
]
},
"smtp": {
"host": "",
"port": 25,
"from": "",
"user": "",
"password": "",
"auth_type": 0,
"encryption": 0,
"domain": ""
},
"plugins": []
}

145
smtp/smtp.go Normal file
View file

@ -0,0 +1,145 @@
// Package smtp provides supports for sending emails
package smtp
import (
"errors"
"fmt"
"time"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/drakkan/sftpgo/v2/logger"
)
const (
logSender = "smtp"
)
// EmailContentType defines the support content types for email body
type EmailContentType int
// Supporte email body content type
const (
EmailContentTypeTextPlain EmailContentType = iota
EmailContentTypeTextHTML
)
var (
smtpServer *mail.SMTPServer
from string
)
// Config defines the SMTP configuration to use to send emails
type Config struct {
// Location of SMTP email server. Leavy empty to disable email sending capabilities
Host string `json:"host" mapstructure:"host"`
// Port of SMTP email server
Port int `json:"port" mapstructure:"port"`
// From address, for example "SFTPGo <sftpgo@example.com>"
From string `json:"from" mapstructure:"from"`
// SMTP username
User string `json:"user" mapstructure:"user"`
// SMTP password. Leaving both username and password empty the SMTP authentication
// will be disabled
Password string `json:"password" mapstructure:"password"`
// 0 Plain
// 1 Login
// 2 CRAM-MD5
AuthType int `json:"auth_type" mapstructure:"auth_type"`
// 0 no encryption
// 1 TLS
// 2 start TLS
Encryption int `json:"encryption" mapstructure:"encryption"`
// Domain to use for HELO command, if empty localhost will be used
Domain string `json:"domain" mapstructure:"domain"`
}
// Initialize initialized and validates the SMTP configuration
func (c *Config) Initialize() error {
smtpServer = nil
if c.Host == "" {
logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
return nil
}
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("smtp: invalid port %v", c.Port)
}
if c.AuthType < 0 || c.AuthType > 2 {
return fmt.Errorf("smtp: invalid auth type %v", c.AuthType)
}
if c.Encryption < 0 || c.Encryption > 2 {
return fmt.Errorf("smtp: invalid encryption %v", c.Encryption)
}
from = c.From
smtpServer = mail.NewSMTPClient()
smtpServer.Host = c.Host
smtpServer.Port = c.Port
smtpServer.Username = c.User
smtpServer.Password = c.Password
smtpServer.Authentication = c.getAuthType()
smtpServer.Encryption = c.getEncryption()
smtpServer.KeepAlive = false
smtpServer.ConnectTimeout = 10 * time.Second
smtpServer.SendTimeout = 30 * time.Second
if c.Domain != "" {
smtpServer.Helo = c.Domain
}
logger.Debug(logSender, "", "configuration successfully initialized, host: %#v, port: %v, username: %#v, auth: %v, encryption: %v, helo: %#v",
smtpServer.Host, smtpServer.Port, smtpServer.Username, smtpServer.Authentication, smtpServer.Encryption, smtpServer.Helo)
return nil
}
func (c *Config) getEncryption() mail.Encryption {
switch c.Encryption {
case 1:
return mail.EncryptionSSLTLS
case 2:
return mail.EncryptionSTARTTLS
default:
return mail.EncryptionNone
}
}
func (c *Config) getAuthType() mail.AuthType {
if c.User == "" && c.Password == "" {
return mail.AuthNone
}
switch c.AuthType {
case 1:
return mail.AuthLogin
case 2:
return mail.AuthCRAMMD5
default:
return mail.AuthPlain
}
}
// SendEmail tries to send an email using the specified parameters.
// If the contentType is 0 text/plain is assumed, otherwise text/html
func SendEmail(to, subject, body string, contentType EmailContentType) error {
if smtpServer == nil {
return errors.New("smtp: not configured")
}
smtpClient, err := smtpServer.Connect()
if err != nil {
return fmt.Errorf("smtp: unable to connect: %w", err)
}
email := mail.NewMSG()
if from != "" {
email.SetFrom(from)
}
email.AddTo(to).SetSubject(subject)
switch contentType {
case EmailContentTypeTextPlain:
email.SetBody(mail.TextPlain, body)
case EmailContentTypeTextHTML:
email.SetBody(mail.TextHTML, body)
default:
return fmt.Errorf("smtp: unsupported body content type %v", contentType)
}
if email.Error != nil {
return fmt.Errorf("smtp: email error: %w", email.Error)
}
return email.Send(smtpClient)
}