diff --git a/config.go b/config.go index bcbf2c0..9623c89 100644 --- a/config.go +++ b/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 *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)) + } + + remotes = append(remotes, r) } - 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") - } - - switch *remoteAuthStr { - case "plain": - remoteAuth = smtp.PlainAuth("", *remoteUser, *remotePass, host) - case "login": - remoteAuth = LoginAuth(*remoteUser, *remotePass) - default: - logger.Fatal("Invalid remote_auth type") } } @@ -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() } diff --git a/go.mod b/go.mod index 8716c35..c48c1df 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 0530500..8709160 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 4547432..1a815e2 100644 --- a/main.go +++ b/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 - } + for _, remote := range remotes { + logger = logger.WithField("host", remote.Addr) + logger.Info("delivering mail from peer using smarthost") - logger.Info("delivering mail from peer using smarthost") + err := SendMail( + remote, + env.Sender, + env.Recipients, + env.Data, + ) + if err != nil { + var smtpError smtpd.Error - var sender string + switch err := err.(type) { + case *textproto.Error: + smtpError = smtpd.Error{Code: err.Code, Message: err.Msg} - if *remoteSender == "" { - sender = env.Sender - } else { - sender = *remoteSender - } + logger.WithFields(logrus.Fields{ + "err_code": err.Code, + "err_msg": err.Msg, + }).Error("delivery failed") + default: + smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"} - err := SendMail( - *remoteHost, - remoteAuth, - sender, - env.Recipients, - env.Data, - ) - if err != nil { - var smtpError smtpd.Error + logger.WithError(err). + Error("delivery failed") + } - 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.WithError(err). - Error("delivery failed") + return smtpError } - return smtpError + logger.Debug("delivery successful") } - logger.Debug("delivery successful") return nil } diff --git a/remotes.go b/remotes.go new file mode 100644 index 0000000..b05d825 --- /dev/null +++ b/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 +} diff --git a/remotes_test.go b/remotes_test.go new file mode 100644 index 0000000..74ca960 --- /dev/null +++ b/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:pass@email.com") + + AssertRemoteUrlEquals(t, &Remote{ + Scheme: "smtp", + SkipVerify: false, + Auth: LoginAuth("user", "pass"), + Hostname: "email.com", + Port: "25", + Addr: "email.com:25", + Sender: "", + }, "smtp://user:pass@email.com?auth=login") + + AssertRemoteUrlEquals(t, &Remote{ + Scheme: "smtp", + SkipVerify: false, + Auth: LoginAuth("user", "pass"), + Hostname: "email.com", + Port: "25", + Addr: "email.com:25", + Sender: "sender@website.com", + }, "smtp://user:pass@email.com/sender@website.com?auth=login") + + AssertRemoteUrlEquals(t, &Remote{ + Scheme: "smtps", + SkipVerify: false, + Auth: LoginAuth("user", "pass"), + Hostname: "email.com", + Port: "465", + Addr: "email.com:465", + Sender: "sender@website.com", + }, "smtps://user:pass@email.com/sender@website.com?auth=login") + + AssertRemoteUrlEquals(t, &Remote{ + Scheme: "smtps", + SkipVerify: true, + Auth: LoginAuth("user", "pass"), + Hostname: "email.com", + Port: "8425", + Addr: "email.com:8425", + Sender: "sender@website.com", + }, "smtps://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify") + + AssertRemoteUrlEquals(t, &Remote{ + Scheme: "starttls", + SkipVerify: true, + Auth: LoginAuth("user", "pass"), + Hostname: "email.com", + Port: "8425", + Addr: "email.com:8425", + Sender: "sender@website.com", + }, "starttls://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify") +} + +func TestMissingScheme(t *testing.T) { + _, err := ParseRemote("http://user:pass@email.com:8425/sender@website.com") + assert.NotNil(t, err, "Err must be present") + assert.Equal(t, err.Error(), "'http' is not a supported relay scheme") +} diff --git a/smtp.go b/smtp.go index a32f286..14ab8c1 100644 --- a/smtp.go +++ b/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 } } diff --git a/smtprelay.ini b/smtprelay.ini index be83633..8546560 100644 --- a/smtprelay.ini +++ b/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/overridden@email.com?auth=login + +; Multiple remotes, space delimited +;remotes = smtp://127.0.0.1:1025 starttls://user:pass@smtp.mailgun.org:587 ; Pipe messages to external command ;command = /usr/local/bin/script