Browse Source

Merge pull request #63 from markgardner/master

Allow config to have multiple remotes.
Bernhard Fröhlich 3 years ago
parent
commit
bf8c222ac1
8 changed files with 275 additions and 112 deletions
  1. 16 49
      config.go
  2. 1 0
      go.mod
  3. 7 0
      go.sum
  4. 26 37
      main.go
  5. 84 0
      remotes.go
  6. 114 0
      remotes_test.go
  7. 17 14
      smtp.go
  8. 10 12
      smtprelay.ini

+ 16 - 49
config.go

@@ -2,8 +2,8 @@ package main
 
 import (
 	"flag"
+	"fmt"
 	"net"
-	"net/smtp"
 	"regexp"
 	"strings"
 	"time"
@@ -45,13 +45,8 @@ var (
 	allowedRecipients *regexp.Regexp
 	allowedUsers      = flag.String("allowed_users", "", "Path to file with valid users/passwords")
 	command           = flag.String("command", "", "Path to pipe command")
-	remoteHost        = flag.String("remote_host", "", "Outgoing SMTP server")
-	remoteSkipVerify  = flag.Bool("remote_skip_verify", false, "Ignore invalid remote certificates")
-	remoteUser        = flag.String("remote_user", "", "Username for authentication on outgoing SMTP server")
-	remotePass        = flag.String("remote_pass", "", "Password for authentication on outgoing SMTP server")
-	remoteAuthStr     = flag.String("remote_auth", "none", "Auth method on outgoing SMTP server (none, plain, login)")
-	remoteAuth        smtp.Auth
-	remoteSender      = flag.String("remote_sender", "", "Sender e-mail address on outgoing SMTP server")
+	remotesStr        = flag.String("remotes", "", "Outgoing SMTP servers")
+	remotes           = []*Remote{}
 	versionInfo       = flag.Bool("version", false, "Show version information")
 )
 
@@ -103,46 +98,18 @@ func setupAllowedPatterns() {
 	}
 }
 
-func setupRemoteAuth() {
-	logger := log.WithField("remote_auth", *remoteAuthStr)
+func setupRemotes() {
+	logger := log.WithField("remotes", *remotesStr)
 
-	// Remote auth disabled?
-	if *remoteAuthStr == "" || *remoteAuthStr == "none" {
-		if *remoteUser != "" {
-			logger.Fatal("remote_user given but not used")
-		}
-		if *remotePass != "" {
-			logger.Fatal("remote_pass given but not used")
-		}
-
-		// No auth; use empty default
-		return
-	}
-
-	// We need a username, password, and remote host
-	if *remoteUser == "" {
-		logger.Fatal("remote_user required but empty")
-	}
-	if *remotePass == "" {
-		logger.Fatal("remote_pass required but empty")
-	}
-	if *remoteHost == "" {
-		logger.Fatal("remote_auth without remote_host is pointless")
-	}
-
-	host, _, err := net.SplitHostPort(*remoteHost)
-	if err != nil {
-		logger.WithField("remote_host", *remoteHost).
-			Fatal("Invalid remote_host")
-	}
+	if *remotesStr != "" {
+		for _, remoteURL := range strings.Split(*remotesStr, " ") {
+			r, err := ParseRemote(remoteURL)
+			if err != nil {
+				logger.Fatal(fmt.Sprintf("error parsing url: '%s': %v", remoteURL, err))
+			}
 
-	switch *remoteAuthStr {
-	case "plain":
-		remoteAuth = smtp.PlainAuth("", *remoteUser, *remotePass, host)
-	case "login":
-		remoteAuth = LoginAuth(*remoteUser, *remotePass)
-	default:
-		logger.Fatal("Invalid remote_auth type")
+			remotes = append(remotes, r)
+		}
 	}
 }
 
@@ -221,13 +188,13 @@ func ConfigLoad() {
 	// Set up logging as soon as possible
 	setupLogger()
 
-	if *remoteHost == "" && *command == "" {
-		log.Warn("no remote_host or command set; mail will not be forwarded!")
+	if *remotesStr == "" && *command == "" {
+		log.Warn("no remotes or command set; mail will not be forwarded!")
 	}
 
 	setupAllowedNetworks()
 	setupAllowedPatterns()
-	setupRemoteAuth()
+	setupRemotes()
 	setupListeners()
 	setupTimeouts()
 }

+ 1 - 0
go.mod

@@ -4,6 +4,7 @@ require (
 	github.com/chrj/smtpd v0.3.1
 	github.com/google/uuid v1.3.0
 	github.com/sirupsen/logrus v1.8.1
+	github.com/stretchr/testify v1.7.0
 	github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de
 	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
 )

+ 7 - 0
go.sum

@@ -1,5 +1,6 @@
 github.com/chrj/smtpd v0.3.1 h1:kogHFkbFdKaoH3bgZkqNC9uVtKYOFfM3uV3rroBdooE=
 github.com/chrj/smtpd v0.3.1/go.mod h1:JtABvV/LzvLmEIzy0NyDnrfMGOMd8wy5frAokwf6J9Q=
+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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -8,8 +9,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
 github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de h1:fkw+7JkxF3U1GzQoX9h69Wvtvxajo5Rbzy6+YMMzPIg=
 github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de/go.mod h1:irMhzlTz8+fVFj6CH2AN2i+WI5S6wWFtK3MBCIxIpyI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -21,3 +25,6 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 26 - 37
main.go

@@ -161,11 +161,10 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
 		"from": env.Sender,
 		"to":   env.Recipients,
 		"peer": peerIP,
-		"host": *remoteHost,
 		"uuid": generateUUID(),
 	})
 
-	if *remoteHost == "" && *command == "" {
+	if *remotesStr == "" && *command == "" {
 		logger.Warning("no remote_host or command set; discarding mail")
 		return nil
 	}
@@ -192,50 +191,40 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
 		cmdLogger.Info("pipe command successful: " + stdout.String())
 	}
 
-	if *remoteHost == "" {
-		return nil
-	}
-
-	logger.Info("delivering mail from peer using smarthost")
+	for _, remote := range remotes {
+		logger = logger.WithField("host", remote.Addr)
+		logger.Info("delivering mail from peer using smarthost")
 
-	var sender string
-
-	if *remoteSender == "" {
-		sender = env.Sender
-	} else {
-		sender = *remoteSender
-	}
+		err := SendMail(
+			remote,
+			env.Sender,
+			env.Recipients,
+			env.Data,
+		)
+		if err != nil {
+			var smtpError smtpd.Error
 
-	err := SendMail(
-		*remoteHost,
-		remoteAuth,
-		sender,
-		env.Recipients,
-		env.Data,
-	)
-	if err != nil {
-		var smtpError smtpd.Error
+			switch err := err.(type) {
+			case *textproto.Error:
+				smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
 
-		switch err.(type) {
-		case *textproto.Error:
-			err := err.(*textproto.Error)
-			smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
+				logger.WithFields(logrus.Fields{
+					"err_code": err.Code,
+					"err_msg":  err.Msg,
+				}).Error("delivery failed")
+			default:
+				smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}
 
-			logger.WithFields(logrus.Fields{
-				"err_code": err.Code,
-				"err_msg":  err.Msg,
-			}).Error("delivery failed")
-		default:
-			smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}
+				logger.WithError(err).
+					Error("delivery failed")
+			}
 
-			logger.WithError(err).
-				Error("delivery failed")
+			return smtpError
 		}
 
-		return smtpError
+		logger.Debug("delivery successful")
 	}
 
-	logger.Debug("delivery successful")
 	return nil
 }
 

+ 84 - 0
remotes.go

@@ -0,0 +1,84 @@
+package main
+
+import (
+	"fmt"
+	"net/smtp"
+	"net/url"
+)
+
+type Remote struct {
+	SkipVerify bool
+	Auth       smtp.Auth
+	Scheme     string
+	Hostname   string
+	Port       string
+	Addr       string
+	Sender     string
+}
+
+// ParseRemote creates a remote from a given url in the following format:
+//
+// smtp://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
+// smtps://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
+// starttls://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
+//
+// Supported Params:
+// - skipVerify: can be "true" or empty to prevent ssl verification of remote server's certificate.
+// - auth: can be "login" to trigger "LOGIN" auth instead of "PLAIN" auth
+//
+func ParseRemote(remoteURL string) (*Remote, error) {
+	u, err := url.Parse(remoteURL)
+	if err != nil {
+		return nil, err
+	}
+
+	if u.Scheme != "smtp" && u.Scheme != "smtps" && u.Scheme != "starttls" {
+		return nil, fmt.Errorf("'%s' is not a supported relay scheme", u.Scheme)
+	}
+
+	hostname, port := u.Hostname(), u.Port()
+
+	if port == "" {
+		switch u.Scheme {
+		case "smtp":
+			port = "25"
+		case "smtps":
+			port = "465"
+		case "starttls":
+			port = "587"
+		}
+	}
+
+	q := u.Query()
+	r := &Remote{
+		Scheme:   u.Scheme,
+		Hostname: hostname,
+		Port:     port,
+		Addr:     fmt.Sprintf("%s:%s", hostname, port),
+	}
+
+	if u.User != nil {
+		pass, _ := u.User.Password()
+		user := u.User.Username()
+
+		if hasAuth, authVal := q.Has("auth"), q.Get("auth"); hasAuth {
+			if authVal != "login" {
+				return nil, fmt.Errorf("Auth must be login or not present, received '%s'", authVal)
+			}
+
+			r.Auth = LoginAuth(user, pass)
+		} else {
+			r.Auth = smtp.PlainAuth("", user, pass, u.Hostname())
+		}
+	}
+
+	if hasVal, skipVerify := q.Has("skipVerify"), q.Get("skipVerify"); hasVal && skipVerify != "false" {
+		r.SkipVerify = true
+	}
+
+	if u.Path != "" {
+		r.Sender = u.Path[1:]
+	}
+
+	return r, nil
+}

+ 114 - 0
remotes_test.go

@@ -0,0 +1,114 @@
+package main
+
+import (
+	"net/smtp"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func AssertRemoteUrlEquals(t *testing.T, expected *Remote, remotUrl string) {
+	actual, err := ParseRemote(remotUrl)
+	assert.Nil(t, err)
+	assert.NotNil(t, actual)
+	assert.Equal(t, expected.Scheme, actual.Scheme, "Scheme %s", remotUrl)
+	assert.Equal(t, expected.Addr, actual.Addr, "Addr %s", remotUrl)
+	assert.Equal(t, expected.Hostname, actual.Hostname, "Hostname %s", remotUrl)
+	assert.Equal(t, expected.Port, actual.Port, "Port %s", remotUrl)
+	assert.Equal(t, expected.Sender, actual.Sender, "Sender %s", remotUrl)
+	assert.Equal(t, expected.SkipVerify, actual.SkipVerify, "SkipVerify %s", remotUrl)
+
+	if expected.Auth != nil || actual.Auth != nil {
+		assert.NotNil(t, expected, "Auth %s", remotUrl)
+		assert.NotNil(t, actual, "Auth %s", remotUrl)
+		assert.IsType(t, expected.Auth, actual.Auth)
+	}
+}
+
+func TestValidRemoteUrls(t *testing.T) {
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtp",
+		SkipVerify: false,
+		Auth:       nil,
+		Hostname:   "email.com",
+		Port:       "25",
+		Addr:       "email.com:25",
+		Sender:     "",
+	}, "smtp://email.com")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtp",
+		SkipVerify: true,
+		Auth:       nil,
+		Hostname:   "email.com",
+		Port:       "25",
+		Addr:       "email.com:25",
+		Sender:     "",
+	}, "smtp://email.com?skipVerify")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtp",
+		SkipVerify: false,
+		Auth:       smtp.PlainAuth("", "user", "pass", ""),
+		Hostname:   "email.com",
+		Port:       "25",
+		Addr:       "email.com:25",
+		Sender:     "",
+	}, "smtp://user:[email protected]")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtp",
+		SkipVerify: false,
+		Auth:       LoginAuth("user", "pass"),
+		Hostname:   "email.com",
+		Port:       "25",
+		Addr:       "email.com:25",
+		Sender:     "",
+	}, "smtp://user:[email protected]?auth=login")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtp",
+		SkipVerify: false,
+		Auth:       LoginAuth("user", "pass"),
+		Hostname:   "email.com",
+		Port:       "25",
+		Addr:       "email.com:25",
+		Sender:     "[email protected]",
+	}, "smtp://user:[email protected]/[email protected]?auth=login")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtps",
+		SkipVerify: false,
+		Auth:       LoginAuth("user", "pass"),
+		Hostname:   "email.com",
+		Port:       "465",
+		Addr:       "email.com:465",
+		Sender:     "[email protected]",
+	}, "smtps://user:[email protected]/[email protected]?auth=login")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "smtps",
+		SkipVerify: true,
+		Auth:       LoginAuth("user", "pass"),
+		Hostname:   "email.com",
+		Port:       "8425",
+		Addr:       "email.com:8425",
+		Sender:     "[email protected]",
+	}, "smtps://user:[email protected]:8425/[email protected]?auth=login&skipVerify")
+
+	AssertRemoteUrlEquals(t, &Remote{
+		Scheme:     "starttls",
+		SkipVerify: true,
+		Auth:       LoginAuth("user", "pass"),
+		Hostname:   "email.com",
+		Port:       "8425",
+		Addr:       "email.com:8425",
+		Sender:     "[email protected]",
+	}, "starttls://user:[email protected]:8425/[email protected]?auth=login&skipVerify")
+}
+
+func TestMissingScheme(t *testing.T) {
+	_, err := ParseRemote("http://user:[email protected]:8425/[email protected]")
+	assert.NotNil(t, err, "Err must be present")
+	assert.Equal(t, err.Error(), "'http' is not a supported relay scheme")
+}

+ 17 - 14
smtp.go

@@ -322,7 +322,11 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
 // attachments (see the mime/multipart package), or other mail
 // functionality. Higher-level packages exist outside of the standard
 // library.
-func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
+func SendMail(r *Remote, from string, to []string, msg []byte) error {
+	if r.Sender != "" {
+		from = r.Sender
+	}
+
 	if err := validateLine(from); err != nil {
 		return err
 	}
@@ -331,22 +335,19 @@ func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
 			return err
 		}
 	}
-	host, port, err := net.SplitHostPort(addr)
-	if err != nil {
-		return err
-	}
 	var c *Client
-	if port == "465" || port == "smtps" {
+	var err error
+	if r.Scheme == "smtps" {
 		config := &tls.Config{
-			ServerName:         host,
-			InsecureSkipVerify: *remoteSkipVerify,
+			ServerName:         r.Hostname,
+			InsecureSkipVerify: r.SkipVerify,
 		}
-		conn, err := tls.Dial("tcp", addr, config)
+		conn, err := tls.Dial("tcp", r.Addr, config)
 		if err != nil {
 			return err
 		}
 		defer conn.Close()
-		c, err = NewClient(conn, host)
+		c, err = NewClient(conn, r.Hostname)
 		if err != nil {
 			return err
 		}
@@ -354,7 +355,7 @@ func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
 			return err
 		}
 	} else {
-		c, err = Dial(addr)
+		c, err = Dial(r.Addr)
 		if err != nil {
 			return err
 		}
@@ -365,7 +366,7 @@ func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
 		if ok, _ := c.Extension("STARTTLS"); ok {
 			config := &tls.Config{
 				ServerName:         c.serverName,
-				InsecureSkipVerify: *remoteSkipVerify,
+				InsecureSkipVerify: r.SkipVerify,
 			}
 			if testHookStartTLS != nil {
 				testHookStartTLS(config)
@@ -373,13 +374,15 @@ func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) er
 			if err = c.StartTLS(config); err != nil {
 				return err
 			}
+		} else if r.Scheme == "starttls" {
+			return errors.New("starttls: server does not support extension, check remote scheme")
 		}
 	}
-	if a != nil && c.ext != nil {
+	if r.Auth != nil && c.ext != nil {
 		if _, ok := c.ext["AUTH"]; !ok {
 			return errors.New("smtp: server doesn't support AUTH")
 		}
-		if err = c.Auth(a); err != nil {
+		if err = c.Auth(r.Auth); err != nil {
 			return err
 		}
 	}

+ 10 - 12
smtprelay.ini

@@ -87,27 +87,25 @@
 ; If not set, mails are discarded.
 
 ; GMail
-;remote_host = smtp.gmail.com:587
+;remotes = starttls://user:pass@smtp.gmail.com:587
 
 ; Mailgun.org
-;remote_host = smtp.mailgun.org:587
+;remotes = starttls://user:pass@smtp.mailgun.org:587
 
 ; Mailjet.com
-;remote_host = in-v3.mailjet.com:587
+;remotes = starttls://user:pass@in-v3.mailjet.com:587
 
 ; Ignore remote host certificates
-;remote_skip_verify = false
+;remotes = starttls://user:pass@server:587?skipVerify
 
-; Authentication credentials on outgoing SMTP server
-;remote_user =
-;remote_pass =
-
-; Authentication method on outgoing SMTP server
-; (none, plain, login)
-;remote_auth = none
+; Login Authentication method on outgoing SMTP server
+;remotes = smtp://user:pass@server:2525?auth=login
 
 ; Sender e-mail address on outgoing SMTP server
-;remote_sender = 
+;remotes = smtp://user:pass@server:2525/[email protected]?auth=login
+
+; Multiple remotes, space delimited
+;remotes = smtp://127.0.0.1:1025 starttls://user:[email protected]:587
 
 ; Pipe messages to external command
 ;command = /usr/local/bin/script