├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── backend.go ├── build.sh ├── client.go ├── cmd └── proxy │ └── proxy.go ├── conn.go ├── data.go ├── linesplitter.go ├── linesplitter_test.go ├── parse.go ├── proxy_app.go ├── proxy_test.go └── server.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | .idea/* 3 | debug*.txt 4 | .vscode/* 5 | .DS_Store 6 | *.code-workspace 7 | __debug_bin 8 | # executables 9 | cmd/proxy/proxy 10 | proxy 11 | 12 | # local debug outputs 13 | debug_*.log 14 | debug.test 15 | proy 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - tip 5 | before_install: 6 | - go get github.com/mattn/goveralls 7 | services: 8 | - 9 | script: 10 | - $GOPATH/bin/goveralls -service=travis-ci 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-smtpproxy 2 | 3 | [![Build Status](https://travis-ci.org/tuck1s/go-smtpproxy.svg?branch=master)](https://travis-ci.org/tuck1s/go-smtpproxy) 4 | [![Coverage Status](https://coveralls.io/repos/github/tuck1s/go-smtpproxy/badge.svg?branch=master)](https://coveralls.io/github/tuck1s/go-smtpproxy?branch=master) 5 | 6 | Go package, based heavily on [emersion's go-gmtp](https://github.com/emersion/go-smtp), with increased transparency of response codes and no sasl dependency. 7 | The purpose of this is to provide functions that act as a server to receive SMTP messages from your downstream client. These SMTP messages are relayed through to 8 | an upstream server. 9 | 10 | The command / response exchanges are passed on transparently. 11 | 12 | STARTTLS can be offered to the downstream client if you configure a valid certificate/key pair. 13 | 14 | STARTTLS can be requested from the upstream server. 15 | 16 | [Line splitting](linesplitter.go) functions are included for base64 encoded email handling by your app. 17 | 18 | Get this project with `go get github.com/tuck1s/go-smtpproxy`. 19 | 20 | `cmd/proxy` contains an example command-line app using this library: 21 | 22 | ```bash 23 | cd cmd/proxy 24 | go build 25 | ./proxy -h 26 | 27 | SMTP proxy that accepts incoming messages from your downstream client, and relays on to an upstream server. 28 | Usage of ./proxy: 29 | -certfile string 30 | Certificate file for this server 31 | -downstream_debug string 32 | File to write downstream server SMTP conversation for debugging 33 | -in_hostport string 34 | Port number to serve incoming SMTP requests (default "localhost:587") 35 | -insecure_skip_verify 36 | Skip check of peer cert on upstream side 37 | -logfile string 38 | File written with message logs (also to stdout) 39 | -out_hostport string 40 | host:port for onward routing of SMTP requests (default "smtp.sparkpostmail.com:587") 41 | -privkeyfile string 42 | Private key file for this server 43 | -verbose 44 | print out lots of messages 45 | ``` -------------------------------------------------------------------------------- /backend.go: -------------------------------------------------------------------------------- 1 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "io" 6 | ) 7 | 8 | // Backend for a SMTP server 9 | type Backend interface { 10 | // Create a session 11 | Init() (Session, error) 12 | } 13 | 14 | // SessionFunc Session backend functions 15 | type SessionFunc func(expectcode int, cmd, arg string) (int, string, error) 16 | 17 | // Session backend functions 18 | type Session interface { 19 | // Greet a session. Returns capabilities of the upstream host 20 | Greet(ehlotype string) ([]string, int, string, error) 21 | 22 | // StartTLS requests the backend to upgrade its connection 23 | StartTLS() (int, string, error) 24 | 25 | // These backend functions follow a regular pattern matching SessionFunc above 26 | Auth(expectcode int, cmd, arg string) (int, string, error) 27 | 28 | Mail(expectcode int, cmd, arg string) (int, string, error) 29 | 30 | Rcpt(expectcode int, cmd, arg string) (int, string, error) 31 | 32 | Reset(expectcode int, cmd, arg string) (int, string, error) 33 | 34 | Quit(expectcode int, cmd, arg string) (int, string, error) 35 | 36 | // DataCommand pass upstream, returning a place to write the data AND the usual responses 37 | DataCommand() (w io.WriteCloser, code int, msg string, err error) 38 | 39 | // Data body (dot delimited) pass upstream, returning the usual responses 40 | Data(r io.Reader, w io.WriteCloser) (int, string, error) 41 | 42 | // This is called if we see any unknown command 43 | Unknown(expectcode int, cmd, arg string) (int, string, error) 44 | } 45 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # flag -ldflags "-s -w" could be used to reduce size of binaries slightly 3 | go build -v ./cmd/proxy 4 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2010 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 6 | package smtpproxy 7 | 8 | import ( 9 | "crypto/tls" 10 | "errors" 11 | "io" 12 | "net" 13 | "net/textproto" 14 | "sort" 15 | "strings" 16 | ) 17 | 18 | // A Client represents a client connection to an SMTP server. Stripped out unused functionality for proxy 19 | type Client struct { 20 | Text *textproto.Conn // Text is the textproto.Conn used by the Client. It is exported to allow for clients to add extensions. 21 | conn net.Conn // keep a reference to the connection so it can be used to create a TLS connection later 22 | tls bool // whether the Client is using TLS 23 | serverName string 24 | ext map[string]string // map of supported extensions 25 | localName string // the name to use in HELO/EHLO/LHLO 26 | didHello bool // whether we've said HELO/EHLO/LHLO 27 | helloMsg string // the error message from the hello 28 | helloCode int // the error code from the hello 29 | helloErr error // Error form of the above 30 | DataResponseCode int // proxy error reporting for data phase (as writeCloser can only return "error" class) 31 | DataResponseMsg string 32 | } 33 | 34 | // Dial returns a new Client connected to an SMTP server at addr. 35 | // The addr must include a port, as in "mail.example.com:smtp". 36 | func Dial(addr string) (*Client, error) { 37 | conn, err := net.Dial("tcp", addr) 38 | if err != nil { 39 | return nil, err 40 | } 41 | host, _, _ := net.SplitHostPort(addr) 42 | return NewClient(conn, host) 43 | } 44 | 45 | // DialTLS returns a new Client connected to an SMTP server via TLS at addr. 46 | // The addr must include a port, as in "mail.example.com:smtps". 47 | func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) { 48 | conn, err := tls.Dial("tcp", addr, tlsConfig) 49 | if err != nil { 50 | return nil, err 51 | } 52 | host, _, _ := net.SplitHostPort(addr) 53 | return NewClient(conn, host) 54 | } 55 | 56 | // NewClient returns a new Client using an existing connection and host as a 57 | // server name to be used when authenticating. 58 | func NewClient(conn net.Conn, host string) (*Client, error) { 59 | text := textproto.NewConn(conn) 60 | _, _, err := text.ReadResponse(220) 61 | if err != nil { 62 | text.Close() 63 | return nil, err 64 | } 65 | _, isTLS := conn.(*tls.Conn) 66 | c := &Client{Text: text, conn: conn, serverName: host, localName: "localhost", tls: isTLS} 67 | return c, nil 68 | } 69 | 70 | // Close closes the connection. 71 | func (c *Client) Close() error { 72 | return c.Text.Close() 73 | } 74 | 75 | // hello runs a hello exchange if needed. 76 | func (c *Client) hello() (int, string, error) { 77 | if !c.didHello { 78 | c.didHello = true 79 | // Try Extended hello first 80 | c.helloCode, c.helloMsg, c.helloErr = c.ehlo() 81 | if c.helloErr != nil { 82 | // Didn't succeed, try a basic hello 83 | c.helloCode, c.helloMsg, c.helloErr = c.helo() 84 | } 85 | } 86 | return c.helloCode, c.helloMsg, c.helloErr 87 | } 88 | 89 | // Hello sends a HELO or EHLO to the server as the given host name. 90 | // Calling this method is only necessary if the client needs control 91 | // over the host name used. The client will introduce itself as "localhost" 92 | // automatically otherwise. If Hello is called, it must be called before 93 | // any of the other methods. 94 | // 95 | // This version does not specifically check for repeat calling of (E)HELO, 96 | // we'll let the upstream server tell us that 97 | func (c *Client) Hello(localName string) (int, string, error) { 98 | if err := validateLine(localName); err != nil { 99 | return 421, err.Error(), err 100 | } 101 | c.localName = localName 102 | return c.hello() 103 | } 104 | 105 | // cmd is a convenience function that sends a command and returns the response 106 | func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { 107 | id, err := c.Text.Cmd(format, args...) 108 | if err != nil { 109 | return 0, "", err 110 | } 111 | c.Text.StartResponse(id) 112 | defer c.Text.EndResponse(id) 113 | code, msg, err := c.Text.ReadResponse(expectCode) 114 | return code, msg, err 115 | } 116 | 117 | // MyCmd - is a wrapper for underlying method 118 | func (c *Client) MyCmd(expectCode int, format string, args ...interface{}) (int, string, error) { 119 | return c.cmd(expectCode, format, args...) 120 | } 121 | 122 | // helo sends the HELO greeting to the server. It should be used only when the 123 | // server does not support ehlo. 124 | func (c *Client) helo() (int, string, error) { 125 | c.ext = nil 126 | return c.cmd(250, "HELO %s", c.localName) 127 | } 128 | 129 | // ehlo sends the EHLO (extended hello) greeting to the server. It 130 | // should be the preferred greeting for servers that support it. 131 | // Now returns code, msg, error for transparency. 132 | func (c *Client) ehlo() (int, string, error) { 133 | cmd := "EHLO" 134 | code, msg, err := c.cmd(250, "%s %s", cmd, c.localName) 135 | if err == nil { 136 | ext := make(map[string]string) 137 | extList := strings.Split(msg, "\n") 138 | if len(extList) > 1 { 139 | extList = extList[1:] 140 | for _, line := range extList { 141 | args := strings.SplitN(line, " ", 2) 142 | if len(args) > 1 { 143 | ext[args[0]] = args[1] 144 | } else { 145 | ext[args[0]] = "" 146 | } 147 | } 148 | } 149 | c.ext = ext 150 | } 151 | return code, msg, err 152 | } 153 | 154 | // StartTLS sends the STARTTLS command and encrypts all further communication. 155 | // This is stripped down to not attempt (E)HLOs first. 156 | func (c *Client) StartTLS(config *tls.Config) (int, string, error) { 157 | code, msg, err := c.cmd(220, "STARTTLS") 158 | if err != nil { 159 | return code, msg, err 160 | } 161 | if config == nil { 162 | config = &tls.Config{} 163 | } 164 | if config.ServerName == "" { 165 | // Make a copy to avoid polluting argument 166 | config = config.Clone() 167 | config.ServerName = c.serverName 168 | } 169 | if testHookStartTLS != nil { 170 | testHookStartTLS(config) 171 | } 172 | c.conn = tls.Client(c.conn, config) 173 | c.Text = textproto.NewConn(c.conn) 174 | c.tls = true 175 | c.didHello = false // Important to pass internal checks before next EHLO 176 | return code, msg, err 177 | } 178 | 179 | // TLSConnectionState returns the client's TLS connection state. 180 | // The return values are their zero values if StartTLS did 181 | // not succeed. 182 | func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { 183 | tc, ok := c.conn.(*tls.Conn) 184 | if !ok { 185 | return 186 | } 187 | return tc.ConnectionState(), true 188 | } 189 | 190 | type dataCloser struct { 191 | c *Client 192 | io.WriteCloser 193 | } 194 | 195 | // Data closer 196 | // Conforms to the WriteCloser spec (returning only error) 197 | func (d *dataCloser) Close() error { 198 | d.WriteCloser.Close() 199 | // Pass the extended response info back via Client structure. 200 | code, msg, err := d.c.Text.ReadResponse(250) 201 | d.c.DataResponseCode = code 202 | d.c.DataResponseMsg = msg 203 | return err 204 | } 205 | 206 | // Data issues a DATA command to the server and returns a writer that 207 | // can be used to write the mail headers and body. The caller should 208 | // close the writer before calling any more methods on c. A call to 209 | // Data must be preceded by one or more calls to Rcpt. 210 | func (c *Client) Data() (io.WriteCloser, int, string, error) { 211 | code, msg, err := c.cmd(354, "DATA") 212 | if err != nil { 213 | return nil, code, msg, err 214 | } 215 | return &dataCloser{c, c.Text.DotWriter()}, code, msg, err 216 | } 217 | 218 | var testHookStartTLS func(*tls.Config) // nil, except for tests 219 | 220 | // Extension reports whether an extension is support by the server. 221 | // The extension name is case-insensitive. If the extension is supported, 222 | // Extension also returns a string that contains any parameters the 223 | // server specifies for the extension. 224 | func (c *Client) Extension(ext string) (bool, string) { 225 | if c.ext == nil { 226 | return false, "" 227 | } 228 | ext = strings.ToUpper(ext) 229 | param, ok := c.ext[ext] 230 | return ok, param 231 | } 232 | 233 | // Capabilities reports all supported by the client, as a slice of strings 234 | // Second param indicates is STARTTLS is available 235 | // Return in lexically sorted order, so we get the same results each time 236 | func (c *Client) Capabilities() []string { 237 | caps := []string{} 238 | for cap, param := range c.ext { 239 | cap = strings.ToUpper(cap) 240 | param = strings.ToUpper(param) 241 | if param != "" { 242 | cap += " " + param 243 | } 244 | caps = append(caps, cap) 245 | } 246 | sort.Strings(caps) 247 | return caps 248 | } 249 | 250 | // validateLine checks to see if a line has CR or LF as per RFC 5321 251 | func validateLine(line string) error { 252 | if strings.ContainsAny(line, "\n\r") { 253 | return errors.New("smtp: A line must not contain CR or LF") 254 | } 255 | return nil 256 | } 257 | -------------------------------------------------------------------------------- /cmd/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | // proxy command-line example 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | 11 | "github.com/tuck1s/go-smtpproxy" 12 | "gopkg.in/natefinch/lumberjack.v2" // timed rotating log handler 13 | ) 14 | 15 | // myLogger sets up a custom logger, if filename is given, emitting to stdout as well 16 | // If filename is blank string, then output is stdout only 17 | func myLogger(filename string) { 18 | if filename != "" { 19 | log.SetOutput(&lumberjack.Logger{ 20 | Filename: filename, 21 | MaxAge: 7, //days 22 | Compress: true, // disabled by default 23 | }) 24 | } 25 | } 26 | 27 | func main() { 28 | inHostPort := flag.String("in_hostport", "localhost:587", "Port number to serve incoming SMTP requests") 29 | outHostPort := flag.String("out_hostport", "smtp.sparkpostmail.com:587", "host:port for onward routing of SMTP requests") 30 | certfile := flag.String("certfile", "", "Certificate file for this server") 31 | privkeyfile := flag.String("privkeyfile", "", "Private key file for this server") 32 | logfile := flag.String("logfile", "", "File written with message logs (also to stdout)") 33 | verboseOpt := flag.Bool("verbose", false, "print out lots of messages") 34 | downstreamDebug := flag.String("downstream_debug", "", "File to write downstream server SMTP conversation for debugging") 35 | insecureSkipVerify := flag.Bool("insecure_skip_verify", false, "Skip check of peer cert on upstream side") 36 | flag.Usage = func() { 37 | const helpText = "SMTP proxy that accepts incoming messages from your downstream client, and relays on to an upstream server.\n" + 38 | "Usage of %s:\n" 39 | fmt.Fprintf(flag.CommandLine.Output(), helpText, os.Args[0]) 40 | flag.PrintDefaults() 41 | } 42 | flag.Parse() 43 | myLogger(*logfile) 44 | fmt.Println("Starting smtp proxy service on port", *inHostPort, ", logging to", *logfile) 45 | log.Println("Starting smtp proxy service on port", *inHostPort) 46 | log.Println("Outgoing host:port set to", *outHostPort) 47 | 48 | var cert, privkey []byte 49 | var err error 50 | // Gather TLS credentials for the proxy server 51 | if *certfile != "" && *privkeyfile != "" { 52 | cert, err = ioutil.ReadFile(*certfile) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | privkey, err = ioutil.ReadFile(*privkeyfile) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | log.Println("Gathered certificate", *certfile, "and key", *privkeyfile) 61 | } else { 62 | log.Println("certfile or privkeyfile not specified - proxy will NOT offer STARTTLS to clients") 63 | } 64 | 65 | // Logging of downstream (client to proxy server) commands and responses 66 | var dbgFile *os.File 67 | if *downstreamDebug != "" { 68 | dbgFile, err = os.OpenFile(*downstreamDebug, os.O_CREATE|os.O_WRONLY, 0644) 69 | if err != nil { 70 | log.Fatal(err) 71 | } else { 72 | defer dbgFile.Close() 73 | log.Println("Proxy logging SMTP commands, responses and downstream DATA to", dbgFile.Name()) 74 | } 75 | } 76 | 77 | s, _, err := smtpproxy.CreateProxy(*inHostPort, *outHostPort, *verboseOpt, cert, privkey, *insecureSkipVerify, dbgFile) 78 | if err != nil { 79 | log.Fatal(err) 80 | } 81 | 82 | log.Println("Proxy will advertise itself as", s.Domain) 83 | log.Println("Verbose SMTP conversation logging:", *verboseOpt) 84 | log.Println("insecure_skip_verify (Skip check of peer cert on upstream side):", *insecureSkipVerify) 85 | 86 | // Begin serving requests 87 | if err := s.ListenAndServe(); err != nil { 88 | log.Fatal(err) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | //Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net" 10 | "net/textproto" 11 | "runtime/debug" 12 | "strings" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // ConnectionState gives useful info about the incoming connection, including the TLS status 18 | type ConnectionState struct { 19 | Hostname string 20 | LocalAddr net.Addr 21 | RemoteAddr net.Addr 22 | TLS tls.ConnectionState 23 | } 24 | 25 | // Conn is the incoming connection 26 | type Conn struct { 27 | conn net.Conn 28 | text *textproto.Conn 29 | server *Server 30 | helo string 31 | nbrErrors int 32 | session Session 33 | locker sync.Mutex 34 | } 35 | 36 | func newConn(c net.Conn, s *Server) *Conn { 37 | sc := &Conn{ 38 | server: s, 39 | conn: c, 40 | } 41 | 42 | sc.init() 43 | return sc 44 | } 45 | 46 | func (c *Conn) init() { 47 | var rwc io.ReadWriteCloser = c.conn 48 | if c.server.Debug != nil { 49 | rwc = struct { 50 | io.Reader 51 | io.Writer 52 | io.Closer 53 | }{ 54 | io.TeeReader(c.conn, c.server.Debug), 55 | io.MultiWriter(c.conn, c.server.Debug), 56 | c.conn, 57 | } 58 | } 59 | c.text = textproto.NewConn(rwc) 60 | } 61 | 62 | // Commands are dispatched to the appropriate handler functions. 63 | func (c *Conn) handle(cmd string, arg string) { 64 | // If panic happens during command handling - send 421 response 65 | // and close connection. 66 | defer func() { 67 | if err := recover(); err != nil { 68 | c.WriteResponse(421, EnhancedCode{4, 0, 0}, "Internal server error") 69 | c.Close() 70 | 71 | stack := debug.Stack() 72 | c.server.ErrorLog.Printf("panic serving %v: %v\n%s", c.State().RemoteAddr, err, stack) 73 | } 74 | }() 75 | 76 | if cmd == "" { 77 | c.WriteResponse(500, EnhancedCode{5, 5, 2}, "Speak up") 78 | return 79 | } 80 | 81 | cmd = strings.ToUpper(cmd) 82 | switch cmd { 83 | case "HELO", "EHLO": 84 | c.handleHelo(cmd, arg) // Pass in cmd as could be either 85 | case "AUTH": 86 | c.handleAuth(arg) 87 | case "MAIL": 88 | c.handleMail(arg) 89 | case "RCPT": 90 | c.handleRcpt(arg) 91 | case "RSET": // Reset session 92 | c.handleReset() 93 | case "DATA": 94 | c.handleData(arg) 95 | case "STARTTLS": 96 | c.handleStartTLS() 97 | case "QUIT": 98 | c.handleQuit() 99 | c.Close() 100 | default: 101 | c.handleUnknown(cmd, arg) // Rather than rejecting this here, use the upstream server's responses 102 | } 103 | } 104 | 105 | // Server name of this connection 106 | func (c *Conn) Server() *Server { 107 | return c.server 108 | } 109 | 110 | // Session associated with this connection 111 | func (c *Conn) Session() Session { 112 | c.locker.Lock() 113 | defer c.locker.Unlock() 114 | return c.session 115 | } 116 | 117 | // SetSession - setting the user resets any message being generated 118 | func (c *Conn) SetSession(session Session) { 119 | c.locker.Lock() 120 | defer c.locker.Unlock() 121 | c.session = session 122 | } 123 | 124 | // Close this connection 125 | func (c *Conn) Close() error { 126 | return c.conn.Close() 127 | } 128 | 129 | // TLSConnectionState returns the connection's TLS connection state. 130 | // Zero values are returned if the connection doesn't use TLS. 131 | func (c *Conn) TLSConnectionState() (state tls.ConnectionState, ok bool) { 132 | tc, ok := c.conn.(*tls.Conn) 133 | if !ok { 134 | return 135 | } 136 | return tc.ConnectionState(), true 137 | } 138 | 139 | // State of this connection 140 | func (c *Conn) State() ConnectionState { 141 | state := ConnectionState{} 142 | tlsState, ok := c.TLSConnectionState() 143 | if ok { 144 | state.TLS = tlsState 145 | } 146 | 147 | state.Hostname = c.helo 148 | state.LocalAddr = c.conn.LocalAddr() 149 | state.RemoteAddr = c.conn.RemoteAddr() 150 | 151 | return state 152 | } 153 | 154 | func code2xxSuccess(code int) bool { 155 | return (code >= 200) && (code <= 299) 156 | } 157 | 158 | func code3xxIntermediate(code int) bool { 159 | return (code >= 300) && (code <= 399) 160 | } 161 | 162 | func code5xxPermFail(code int) bool { 163 | return (code >= 500) && (code <= 559) 164 | } 165 | 166 | // Change the downstream (client) connection, and upstream connection (via backend) to TLS 167 | func (c *Conn) handleStartTLS() { 168 | if _, isTLS := c.TLSConnectionState(); isTLS { 169 | c.WriteResponse(502, EnhancedCode{5, 5, 1}, "Already running in TLS") 170 | return 171 | } 172 | 173 | // Change upstream to TLS. If this fails, don't continue with the downstream change 174 | code, msg, err := c.Session().StartTLS() 175 | c.WriteResponse(code, NoEnhancedCode, msg) 176 | if err != nil { 177 | return 178 | } 179 | 180 | // Change downstream to TLS 181 | var tlsConn *tls.Conn 182 | tlsConn = tls.Server(c.conn, c.server.TLSConfig) 183 | if err := tlsConn.Handshake(); err != nil { 184 | c.WriteResponse(550, EnhancedCode{5, 0, 0}, "Handshake error") 185 | } 186 | c.conn = tlsConn 187 | c.init() 188 | } 189 | 190 | // WriteResponse back to the incoming connection. 191 | // If you do not want an enhanced code added, pass in value NoEnhancedCode. 192 | func (c *Conn) WriteResponse(code int, enhCode EnhancedCode, text ...string) { 193 | // TODO: error handling 194 | if c.server.WriteTimeout != 0 { 195 | c.conn.SetWriteDeadline(time.Now().Add(c.server.WriteTimeout)) 196 | } 197 | 198 | // All responses must include an enhanced code, if it is missing - use 199 | // a generic code X.0.0. 200 | if enhCode == EnhancedCodeNotSet { 201 | cat := code / 100 202 | switch cat { 203 | case 2, 4, 5: 204 | enhCode = EnhancedCode{cat, 0, 0} 205 | default: 206 | enhCode = NoEnhancedCode 207 | } 208 | } 209 | 210 | for i := 0; i < len(text)-1; i++ { 211 | c.text.PrintfLine("%v-%v", code, text[i]) 212 | } 213 | if enhCode == NoEnhancedCode { 214 | c.text.PrintfLine("%v %v", code, text[len(text)-1]) 215 | } else { 216 | c.text.PrintfLine("%v %v.%v.%v %v", code, enhCode[0], enhCode[1], enhCode[2], text[len(text)-1]) 217 | } 218 | } 219 | 220 | // ReadLine reads a line of input from the incoming connection 221 | func (c *Conn) ReadLine() (string, error) { 222 | if c.server.ReadTimeout != 0 { 223 | if err := c.conn.SetReadDeadline(time.Now().Add(c.server.ReadTimeout)); err != nil { 224 | return "", err 225 | } 226 | } 227 | return c.text.ReadLine() 228 | } 229 | 230 | func (c *Conn) greet() { 231 | c.WriteResponse(220, NoEnhancedCode, fmt.Sprintf("%v ESMTP Service Ready", c.server.Domain)) 232 | } 233 | 234 | //----------------------------------------------------------------------------- 235 | // Transparent incoming command handlers 236 | //----------------------------------------------------------------------------- 237 | 238 | // handleHelo - HELO / EHLO received 239 | func (c *Conn) handleHelo(cmd, arg string) { 240 | domain, err := parseHelloArgument(arg) 241 | if err != nil { 242 | c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Domain/address argument required") 243 | return 244 | } 245 | c.helo = domain 246 | 247 | // If no existing session, establish one 248 | if c.Session() == nil { 249 | s, err := c.server.Backend.Init() 250 | if err != nil { 251 | c.WriteResponse(421, EnhancedCode{4, 0, 0}, "Internal server error") 252 | return 253 | } 254 | c.session = s 255 | } 256 | // Pass greeting to the backend, updating our server capabilities to mirror them 257 | upstreamCaps, code, msg, err := c.Session().Greet(cmd) 258 | if err != nil { 259 | c.WriteResponse(code, EnhancedCode{4, 0, 0}, msg) 260 | return 261 | } 262 | if len(upstreamCaps) > 0 { 263 | c.server.caps = []string{} 264 | for _, i := range upstreamCaps { 265 | if i == "STARTTLS" { 266 | // Offer STARTTLS to the downstream client, but only if our TLS is configured 267 | // and downstream not already in TLS 268 | if _, isTLS := c.TLSConnectionState(); c.server.TLSConfig == nil || isTLS { 269 | continue 270 | } 271 | } 272 | c.server.caps = append(c.server.caps, i) 273 | } 274 | } 275 | if cmd == "HELO" { 276 | c.WriteResponse(250, EnhancedCode{2, 0, 0}, fmt.Sprintf("Hello %s", domain)) 277 | } 278 | args := []string{"Hello " + domain} 279 | args = append(args, c.server.caps...) 280 | c.WriteResponse(250, NoEnhancedCode, args...) 281 | } 282 | 283 | func (c *Conn) handleAuth(arg string) { 284 | if s := c.Session(); s != nil { 285 | c.handlePassthru("AUTH", arg, s.Auth) 286 | } 287 | } 288 | 289 | func (c *Conn) handleMail(arg string) { 290 | if s := c.Session(); s != nil { 291 | c.handlePassthru("MAIL", arg, s.Mail) 292 | } 293 | } 294 | 295 | func (c *Conn) handleRcpt(arg string) { 296 | if s := c.Session(); s != nil { 297 | c.handlePassthru("RCPT", arg, s.Rcpt) 298 | } 299 | } 300 | 301 | func (c *Conn) handleReset() { 302 | if s := c.Session(); s != nil { 303 | c.handlePassthru("RSET", "", s.Reset) 304 | } 305 | } 306 | 307 | func (c *Conn) handleQuit() { 308 | if s := c.Session(); s != nil { 309 | c.handlePassthru("QUIT", "", s.Quit) 310 | } 311 | } 312 | 313 | func (c *Conn) handleUnknown(cmd, arg string) { 314 | if s := c.Session(); s != nil { 315 | c.handlePassthru(cmd, arg, s.Unknown) 316 | } 317 | } 318 | 319 | // handlePassthru - pass the command and args through to the specified backend session function, handling responses transparently until success or permanent failure. 320 | func (c *Conn) handlePassthru(cmd, arg string, fn SessionFunc) { 321 | code, msg, err := fn(0, cmd, arg) 322 | c.WriteResponse(code, NoEnhancedCode, msg) 323 | if err != nil { 324 | return 325 | } 326 | // If we have an intermediate response, need to keep going 327 | if code3xxIntermediate(code) { 328 | for { 329 | encoded, err := c.ReadLine() 330 | if err != nil { 331 | return 332 | } 333 | code, msg, err := fn(0, encoded, "") 334 | c.WriteResponse(code, NoEnhancedCode, msg) 335 | if code2xxSuccess(code) || code5xxPermFail(code) || code == 0 { 336 | return 337 | } 338 | } 339 | } 340 | } 341 | 342 | // handleData 343 | func (c *Conn) handleData(arg string) { 344 | w, code, msg, err := c.Session().DataCommand() 345 | // Enhanced code is at the beginning of msg, no need to add anything 346 | c.WriteResponse(code, NoEnhancedCode, msg) 347 | if err != nil { 348 | return 349 | } 350 | r := newDataReader(c) 351 | code, msg, err = c.Session().Data(r, w) 352 | io.Copy(ioutil.Discard, r) // Make sure all the incoming data has been consumed 353 | c.WriteResponse(code, NoEnhancedCode, msg) 354 | } 355 | -------------------------------------------------------------------------------- /data.go: -------------------------------------------------------------------------------- 1 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "io" 6 | ) 7 | 8 | // EnhancedCode as per https://tools.ietf.org/html/rfc3463 9 | type EnhancedCode [3]int 10 | 11 | // SMTPError specifies the error code and message that needs to be returned to the client 12 | type SMTPError struct { 13 | Code int 14 | EnhancedCode EnhancedCode 15 | Message string 16 | } 17 | 18 | // NoEnhancedCode is used to indicate that enhanced error code should not be 19 | // included in response. 20 | // 21 | // Note that RFC 2034 requires an enhanced code to be included in all 2xx, 4xx 22 | // and 5xx responses. This constant is exported for use by extensions, you 23 | // should probably use EnhancedCodeNotSet instead. 24 | var NoEnhancedCode = EnhancedCode{-1, -1, -1} 25 | 26 | // EnhancedCodeNotSet is a nil value of EnhancedCode field in SMTPError, used 27 | // to indicate that backend failed to provide enhanced status code. X.0.0 will 28 | // be used (X is derived from error code). 29 | var EnhancedCodeNotSet = EnhancedCode{0, 0, 0} 30 | 31 | func (err *SMTPError) Error() string { 32 | return err.Message 33 | } 34 | 35 | type dataReader struct { 36 | r io.Reader 37 | } 38 | 39 | func newDataReader(c *Conn) io.Reader { 40 | dr := &dataReader{ 41 | r: c.text.DotReader(), 42 | } 43 | return dr 44 | } 45 | 46 | func (r *dataReader) Read(b []byte) (n int, err error) { 47 | n, err = r.r.Read(b) 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /linesplitter.go: -------------------------------------------------------------------------------- 1 | //Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import "io" 5 | 6 | // Linesplitter is an io.Writer 7 | // See https://www.ietf.org/rfc/rfc2045.txt, section 6.8 for notes on maximum line length of 76 characters 8 | 9 | // LineSplitter splits input every len bytes with a sep byte sequence, outputting to writer w 10 | type lineSplitter struct { 11 | len int 12 | count int 13 | sep []byte 14 | w io.Writer 15 | } 16 | 17 | // NewLineSplitterWriter creates a new instance 18 | func NewLineSplitterWriter(len int, sep []byte, w io.Writer) io.Writer { 19 | return &lineSplitter{len: len, count: 0, sep: sep, w: w} 20 | } 21 | 22 | // Write a line in to ls.len chunks with separator 23 | func (ls *lineSplitter) Write(in []byte) (n int, err error) { 24 | writtenThisCall := 0 25 | readPos := 0 26 | // Leading chunk size is limited by: how much input there is; defined split length; and 27 | // any residual from last time 28 | chunkSize := min(len(in), ls.len-ls.count) 29 | // Pass on chunk(s) 30 | for { 31 | ls.w.Write(in[readPos:(readPos + chunkSize)]) 32 | readPos += chunkSize // Skip forward ready for next chunk 33 | ls.count += chunkSize 34 | writtenThisCall += chunkSize 35 | 36 | // if we have completed a chunk, emit a separator 37 | if ls.count >= ls.len { 38 | ls.w.Write(ls.sep) 39 | // Don't increment writtenThisCall - io.Copy expects a count of bytes *copied* not written (otherwise raises a panic) 40 | ls.count = 0 41 | } 42 | inToGo := len(in) - readPos 43 | if inToGo <= 0 { 44 | break // reached end of input data 45 | } 46 | // Determine size of the NEXT chunk 47 | chunkSize = min(inToGo, ls.len) 48 | } 49 | return writtenThisCall, nil 50 | } 51 | 52 | // no min() built-in function for integers, so declare this here 53 | func min(a, b int) int { 54 | if a < b { 55 | return a 56 | } 57 | return b 58 | } 59 | -------------------------------------------------------------------------------- /linesplitter_test.go: -------------------------------------------------------------------------------- 1 | package smtpproxy_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "testing" 10 | 11 | smtpproxy "github.com/tuck1s/go-smtpproxy" 12 | ) 13 | 14 | func TestLineSplitter(t *testing.T) { 15 | const s10 = "1234567890" 16 | checkSplit(t, strings.Repeat(s10, 10), 76) 17 | } 18 | 19 | func checkSplit(t *testing.T, inS string, n int) { 20 | in1 := strings.NewReader(inS) 21 | var buf bytes.Buffer 22 | lsWriter := smtpproxy.NewLineSplitterWriter(76, []byte("\r\n"), &buf) 23 | _, err := io.Copy(lsWriter, in1) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | lines := strings.Split(buf.String(), "\r\n") 28 | for _, vSEP := range lines { 29 | v := strings.TrimRight(vSEP, "\r\n") 30 | fmt.Println(hex.Dump([]byte(v))) 31 | if len(v) < 0 || len(v) > n { 32 | t.Errorf("Line '%s', length %d, expected 0 .. %d\n", v, len(v), n) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /parse.go: -------------------------------------------------------------------------------- 1 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // ParseCmd parses an SMTP command line 10 | func ParseCmd(line string) (cmd string, arg string, err error) { 11 | line = strings.TrimRight(line, "\r\n") 12 | 13 | l := len(line) 14 | switch { 15 | case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"): 16 | return "STARTTLS", "", nil 17 | case l == 0: 18 | return "", "", nil 19 | case l < 4: 20 | return "", "", fmt.Errorf("Command too short: %q", line) 21 | case l == 4: 22 | return strings.ToUpper(line), "", nil 23 | case l == 5: 24 | // Too long to be only command, too short to have args 25 | return "", "", fmt.Errorf("Mangled command: %q", line) 26 | } 27 | 28 | // If we made it here, command is long enough to have args 29 | if line[4] != ' ' { 30 | // There wasn't a space after the command? 31 | return "", "", fmt.Errorf("Mangled command: %q", line) 32 | } 33 | 34 | // I'm not sure if we should trim the args or not, but we will for now 35 | //return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), nil 36 | return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " \n\r"), nil 37 | } 38 | 39 | /* Not used by proxy 40 | // Takes the arguments proceeding a command and files them 41 | // into a map[string]string after uppercasing each key. Sample arg 42 | // string: 43 | // " BODY=8BITMIME SIZE=1024" 44 | // The leading space is mandatory. 45 | func parseArgs(args []string) (map[string]string, error) { 46 | argMap := map[string]string{} 47 | for _, arg := range args { 48 | if arg == "" { 49 | continue 50 | } 51 | m := strings.Split(arg, "=") 52 | if len(m) != 2 { 53 | return nil, fmt.Errorf("Failed to parse arg string: %q", arg) 54 | } 55 | argMap[strings.ToUpper(m[0])] = m[1] 56 | } 57 | return argMap, nil 58 | } 59 | */ 60 | 61 | func parseHelloArgument(arg string) (string, error) { 62 | domain := arg 63 | if idx := strings.IndexRune(arg, ' '); idx >= 0 { 64 | domain = arg[:idx] 65 | } 66 | if domain == "" { 67 | return "", fmt.Errorf("Invalid domain") 68 | } 69 | return domain, nil 70 | } 71 | -------------------------------------------------------------------------------- /proxy_app.go: -------------------------------------------------------------------------------- 1 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "crypto/tls" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "time" 11 | ) 12 | 13 | // This file contains functions for an example Proxy app, including 14 | // TLS negotiation, command pass-through, AUTH pass-through. 15 | 16 | // CreateProxy sets up a proxy server with provided parameters and options, also returns the backend. 17 | func CreateProxy(inHostPort, outHostPort string, verboseOpt bool, cert, privkey []byte, insecureSkipVerify bool, dbgFile *os.File) (*Server, *ProxyBackend, error) { 18 | // Set up parameters that the backend will use 19 | be := NewBackend(outHostPort, verboseOpt, insecureSkipVerify) 20 | s := NewServer(be) 21 | s.Addr = inHostPort 22 | s.ReadTimeout = 60 * time.Second 23 | s.WriteTimeout = 60 * time.Second 24 | if dbgFile != nil { 25 | s.Debug = dbgFile // Important to write this only if valid 26 | } 27 | var err error 28 | if len(cert) > 0 && len(privkey) > 0 { 29 | err = s.ServeTLS(cert, privkey) 30 | } else { 31 | s.Domain, err = os.Hostname() // This is the fallback in case we have no cert / privkey to give us a Subject 32 | } 33 | return s, be, err 34 | } 35 | 36 | //----------------------------------------------------------------------------- 37 | // Backend handlers 38 | 39 | // The ProxyBackend implements SMTP server methods. 40 | type ProxyBackend struct { 41 | outHostPort string 42 | verbose bool 43 | insecureSkipVerify bool 44 | } 45 | 46 | // NewBackend creates a proxy backend with specified params 47 | func NewBackend(outHostPort string, verbose bool, insecureSkipVerify bool) *ProxyBackend { 48 | b := ProxyBackend{ 49 | outHostPort: outHostPort, 50 | verbose: verbose, 51 | insecureSkipVerify: insecureSkipVerify, 52 | } 53 | return &b 54 | } 55 | 56 | // SetVerbose allows changing logging options on-the-fly 57 | func (bkd *ProxyBackend) SetVerbose(v bool) { 58 | bkd.verbose = v 59 | } 60 | 61 | func (bkd *ProxyBackend) logger(args ...interface{}) { 62 | if bkd.verbose { 63 | log.Println(args...) 64 | } 65 | } 66 | 67 | func (bkd *ProxyBackend) loggerAlways(args ...interface{}) { 68 | log.Println(args...) 69 | } 70 | 71 | // MakeSession returns a session for this client and backend 72 | func (bkd *ProxyBackend) MakeSession(c *Client) Session { 73 | var s proxySession 74 | s.bkd = bkd // just for logging 75 | s.upstream = c // keep record of the upstream Client connection 76 | return &s 77 | } 78 | 79 | // Init the backend. Here we establish the upstream connection 80 | func (bkd ProxyBackend) Init() (Session, error) { 81 | bkd.logger("---Connecting upstream") 82 | c, err := Dial(bkd.outHostPort) 83 | if err != nil { 84 | bkd.loggerAlways("< Connection error", bkd.outHostPort, err.Error()) 85 | return nil, err 86 | } 87 | bkd.logger("< Connection success", bkd.outHostPort) 88 | return bkd.MakeSession(c), nil 89 | } 90 | 91 | //----------------------------------------------------------------------------- 92 | // Session handlers 93 | 94 | // A Session is returned after successful login. Here hold information that needs to persist across message phases. 95 | type proxySession struct { 96 | bkd *ProxyBackend // The backend that created this session. Allows session methods to e.g. log 97 | upstream *Client // the upstream client this backend is driving 98 | } 99 | 100 | // cmdTwiddle returns different flow markers depending on whether connection is secure (like Swaks does) 101 | func cmdTwiddle(s *proxySession) string { 102 | if s.upstream != nil { 103 | if _, isTLS := s.upstream.TLSConnectionState(); isTLS { 104 | return "~>" 105 | } 106 | } 107 | return "->" 108 | } 109 | 110 | // respTwiddle returns different flow markers depending on whether connection is secure (like Swaks does) 111 | func respTwiddle(s *proxySession) string { 112 | if s.upstream != nil { 113 | if _, isTLS := s.upstream.TLSConnectionState(); isTLS { 114 | return "\t<~" 115 | } 116 | } 117 | return "\t<-" 118 | } 119 | 120 | // Greet the upstream host and report capabilities back. 121 | func (s *proxySession) Greet(helotype string) ([]string, int, string, error) { 122 | s.bkd.logger(cmdTwiddle(s), helotype) 123 | host, _, _ := net.SplitHostPort(s.bkd.outHostPort) 124 | if host == "" { 125 | host = "smtpproxy.localhost" // add dummy value in 126 | } 127 | code, msg, err := s.upstream.Hello(host) 128 | if err != nil { 129 | s.bkd.loggerAlways(respTwiddle(s), helotype, "error", err.Error()) 130 | if code == 0 { 131 | // some errors don't show up in (code,msg) e.g. TLS cert errors, so map as a specific SMTP code/msg response 132 | code = 599 133 | msg = err.Error() 134 | } 135 | return nil, code, msg, err 136 | } 137 | s.bkd.logger(respTwiddle(s), helotype, "success") 138 | caps := s.upstream.Capabilities() 139 | s.bkd.logger("\tUpstream capabilities:", caps) 140 | return caps, code, msg, err 141 | } 142 | 143 | // StartTLS command 144 | func (s *proxySession) StartTLS() (int, string, error) { 145 | host, _, _ := net.SplitHostPort(s.bkd.outHostPort) 146 | // Try the upstream server, it will report error if unsupported 147 | tlsconfig := &tls.Config{ 148 | InsecureSkipVerify: s.bkd.insecureSkipVerify, 149 | ServerName: host, 150 | } 151 | s.bkd.logger(cmdTwiddle(s), "STARTTLS") 152 | code, msg, err := s.upstream.StartTLS(tlsconfig) 153 | if err != nil { 154 | s.bkd.loggerAlways(respTwiddle(s), code, msg) 155 | } else { 156 | s.bkd.logger(respTwiddle(s), code, msg) 157 | } 158 | return code, msg, err 159 | } 160 | 161 | //Auth command backend handler 162 | func (s *proxySession) Auth(expectcode int, cmd, arg string) (int, string, error) { 163 | return s.Passthru(expectcode, cmd, arg) 164 | } 165 | 166 | //Mail command backend handler 167 | func (s *proxySession) Mail(expectcode int, cmd, arg string) (int, string, error) { 168 | return s.Passthru(expectcode, cmd, arg) 169 | } 170 | 171 | //Rcpt command backend handler 172 | func (s *proxySession) Rcpt(expectcode int, cmd, arg string) (int, string, error) { 173 | return s.Passthru(expectcode, cmd, arg) 174 | } 175 | 176 | //Reset command backend handler 177 | func (s *proxySession) Reset(expectcode int, cmd, arg string) (int, string, error) { 178 | return s.Passthru(expectcode, cmd, arg) 179 | } 180 | 181 | //Quit command backend handler 182 | func (s *proxySession) Quit(expectcode int, cmd, arg string) (int, string, error) { 183 | return s.Passthru(expectcode, cmd, arg) 184 | } 185 | 186 | //Unknown command backend handler 187 | func (s *proxySession) Unknown(expectcode int, cmd, arg string) (int, string, error) { 188 | return s.Passthru(expectcode, cmd, arg) 189 | } 190 | 191 | // Passthru a command to the upstream server, logging 192 | func (s *proxySession) Passthru(expectcode int, cmd, arg string) (int, string, error) { 193 | s.bkd.logger(cmdTwiddle(s), cmd, arg) 194 | joined := cmd 195 | if arg != "" { 196 | joined = cmd + " " + arg 197 | } 198 | code, msg, err := s.upstream.MyCmd(expectcode, joined) 199 | if err != nil { 200 | s.bkd.loggerAlways(respTwiddle(s), cmd, code, msg, "error", err.Error()) 201 | if code == 0 { 202 | // some errors don't show up in (code,msg) e.g. TLS cert errors, so map as a specific SMTP code/msg response 203 | code = 599 204 | msg = err.Error() 205 | } 206 | } else { 207 | s.bkd.logger(respTwiddle(s), code, msg) 208 | } 209 | return code, msg, err 210 | } 211 | 212 | // DataCommand pass upstream, returning a place to write the data AND the usual responses 213 | func (s *proxySession) DataCommand() (io.WriteCloser, int, string, error) { 214 | s.bkd.logger(cmdTwiddle(s), "DATA") 215 | w, code, msg, err := s.upstream.Data() 216 | if err != nil { 217 | s.bkd.loggerAlways(respTwiddle(s), "DATA error", err.Error()) 218 | } 219 | return w, code, msg, err 220 | } 221 | 222 | // Data body (dot delimited) pass upstream, returning the usual responses 223 | func (s *proxySession) Data(r io.Reader, w io.WriteCloser) (int, string, error) { 224 | // Send the data upstream 225 | count, err := io.Copy(w, r) 226 | if err != nil { 227 | msg := "DATA io.Copy error" 228 | s.bkd.loggerAlways(respTwiddle(s), msg, err.Error()) 229 | return 0, msg, err 230 | } 231 | err = w.Close() // Need to close the data phase - then we should have response from upstream 232 | code := s.upstream.DataResponseCode 233 | msg := s.upstream.DataResponseMsg 234 | if err != nil { 235 | s.bkd.loggerAlways(respTwiddle(s), "DATA Close error", err, ", bytes written =", count) 236 | return 0, msg, err 237 | } 238 | if s.bkd.verbose { 239 | s.bkd.logger(respTwiddle(s), "DATA accepted, bytes written =", count) 240 | } else { 241 | // Short-form logging - one line per message - used when "verbose" not set 242 | log.Printf("Message DATA upstream,%d,%d,%s\n", count, code, msg) 243 | } 244 | return code, msg, err 245 | } 246 | -------------------------------------------------------------------------------- /proxy_test.go: -------------------------------------------------------------------------------- 1 | // smtpproxy_test is a simplified (non-wrapping) version of https://github.com/tuck1s/sparkypmtatracking/blob/master/wrap_smtp_test.go 2 | package smtpproxy_test 3 | 4 | import ( 5 | "bytes" 6 | "crypto/tls" 7 | "encoding/base64" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "math/rand" 14 | "net/mail" 15 | "net/smtp" 16 | "net/textproto" 17 | "os" 18 | "strings" 19 | "testing" 20 | "time" 21 | 22 | "github.com/google/uuid" 23 | smtpproxy "github.com/tuck1s/go-smtpproxy" 24 | ) 25 | 26 | // localhostCert is a PEM-encoded TLS cert.pem, made for domain test.example.com 27 | // openssl req -nodes -new -x509 -keyout key.pem -out cert.pem 28 | var localhostCert = []byte(` 29 | -----BEGIN CERTIFICATE----- 30 | MIIDvDCCAqQCCQDG9Km7C037rDANBgkqhkiG9w0BAQsFADCBnzELMAkGA1UEBhMC 31 | dWsxDzANBgNVBAgMBkxvbmRvbjEPMA0GA1UEBwwGTG9uZG9uMRIwEAYDVQQKDAlT 32 | cGFya1Bvc3QxHjAcBgNVBAsMFU1lc3NhZ2luZyBFbmdpbmVlcmluZzEZMBcGA1UE 33 | AwwQdGVzdC5leGFtcGxlLmNvbTEfMB0GCSqGSIb3DQEJARYQdGVzdEBleGFtcGxl 34 | LmNvbTAeFw0yMDAyMDYyMTIyMDNaFw0yMDAzMDcyMTIyMDNaMIGfMQswCQYDVQQG 35 | EwJ1azEPMA0GA1UECAwGTG9uZG9uMQ8wDQYDVQQHDAZMb25kb24xEjAQBgNVBAoM 36 | CVNwYXJrUG9zdDEeMBwGA1UECwwVTWVzc2FnaW5nIEVuZ2luZWVyaW5nMRkwFwYD 37 | VQQDDBB0ZXN0LmV4YW1wbGUuY29tMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1w 38 | bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4yGJRYAI6xtQ 39 | ZPRxIWU+ZjlKo66LFvfr2VrWd30m8dflB0CNMkaaEMGt29jwLvzkP/mfn5dYVw3E 40 | dFJ2yBGR3wDy02ssmBVaOYkbYxgxeFa9jIgBLJONA3HIJRjn91/3lSCxDo6cE7l+ 41 | ufhf8pc78YBZvhbC50kBajQtYaENcca9asj5cCRHS44hL7sCzN4kGETkg1jYtocT 42 | CMjJIgQ3dJool7M9MEAafWiFnIcO76O/jxewggLgOkfj7i9Y1iP6aWScEq6nNkW7 43 | 8xFNqFafnK7W85TzkpfRIN/ntpEwgPcUHG4b4AWpXWR6q+1do25WgaWvt/od45KN 44 | aIo1kylOwQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCODjmvtqracVuOjsRp7841 45 | 7glTqDQeXIUr3X7UvDTyvl70oeGeqnaEs3hO79T15gz0pKlKbYlB3B4v7fldmrLU 46 | mu0uQ7W112NBXYt71wpwuVQWdWSRi9rcAyvuf2nHLZ9fVjczxbCAi+QUFVY+ERoO 47 | CfngvPkPQvLB7VT/oKXKN+j8bXBJ+fYLA6fX4kzpuwx9hf+ay9x+JpPAB/dPEDjB 48 | KsbnfZsIPeuERAlWoSX/c9ggXPXzh95oZz6RhicmtPy3z2ZYJL4BsgEtbazOc6aO 49 | 7c/t3Z1FScoSgCql4MXv9kLVL2LNGTWja89pnFnRaobagQ7XB0MEUotrM0ow18SM 50 | -----END CERTIFICATE-----`) 51 | 52 | // corresponding private key.pem 53 | var localhostKey = []byte(` 54 | -----BEGIN PRIVATE KEY----- 55 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDjIYlFgAjrG1Bk 56 | 9HEhZT5mOUqjrosW9+vZWtZ3fSbx1+UHQI0yRpoQwa3b2PAu/OQ/+Z+fl1hXDcR0 57 | UnbIEZHfAPLTayyYFVo5iRtjGDF4Vr2MiAEsk40DccglGOf3X/eVILEOjpwTuX65 58 | +F/ylzvxgFm+FsLnSQFqNC1hoQ1xxr1qyPlwJEdLjiEvuwLM3iQYROSDWNi2hxMI 59 | yMkiBDd0miiXsz0wQBp9aIWchw7vo7+PF7CCAuA6R+PuL1jWI/ppZJwSrqc2Rbvz 60 | EU2oVp+crtbzlPOSl9Eg3+e2kTCA9xQcbhvgBaldZHqr7V2jblaBpa+3+h3jko1o 61 | ijWTKU7BAgMBAAECggEAHbtvH8Tx5ezuajjBcnCxaWpIhgK8PGZ53jsQ5hVg+rmb 62 | RobBtPofAuCHpMbSMiRysJk5twd1zfeEZwHAgNIj+UBDiT93V/U7mVqEVkV9fFZG 63 | e9X16WLrS68iVxDalLxgSYo9Az3R2pcmqquDy9rWQvfdR4/tNZ+N6twnsKcHfoQZ 64 | Z2lIZrmbR1ZqAEK7T7J5rm2WR+430cuTGEl/X39iIVimwo9QZIs6VikYRYyJoS8u 65 | 8VtNsPY7lhnoPctMyErzWeslZXThFmuA5xqtEgFai51dhiJd/+iLkKtbHkfiLeF9 66 | ej+b40LnPT/rnYkBkyyvp2vVXnEUxPEAOzImzE8bQQKBgQD8TP5/Lg/lGK6CcSjD 67 | XG3/w0sfFQtC+oN3I/iFv/tgTQQRF/el7uF79si31TicZPDJgKbnuOGkOdSEyl4u 68 | Mg4yEwX4e+Grb13aENZb5p+fyN91P0jD+4lzLm6k4RaSN/EkDEe9LSn+wIUedO/A 69 | iG4S79EPyYo8pWdNUBO4ZQx3uQKBgQDmdhFiPIdynNDWy1IxhVUnrUuDMyUKFNZB 70 | Rd3KgABgfOBcdB9oeFEijsH86DI2kjHO+rVyCC9F1s8H5VC3eDKtuUaExqBixtu6 71 | TB3BXX+ZapiH8dThXtIa8vteTD5MHLC7pDcESVGzJH3vhdcOhek7es8j78vXZRZq 72 | q/teONQDSQKBgGBh2WckZZYTU7cpG3VmPe9S38PD+kVgBhDhgPM3YARt53vQOB7/ 73 | nswIfq0bm0DDnuibaSdkjW57WSBRXqEvJhUjB0jhqlgfdy7y97Cr7ZbQ2eykfFvC 74 | H8QMnOAHzOOW01v+BPnT4xMa4L+91Eks1UAOtULerxxz4365dI8gqx6hAoGAT5iZ 75 | um8jbN9idb01fysI1TJSMVc5xLibo2GpD6aT+r9Gkkf9DQz5INFjiKD9rsFheJY4 76 | ktDm2t0tFhIKhcN65WtnQraDcHo0K6zcXguX5Xnegp1wpAIm2O3xCYmVvp3uIHDA 77 | G7fjAtdos5BrTXXMryFkZ4oLwjIEwwTxRYKlHxkCgYEAi3lkuZl5soQT3d2tkhmc 78 | F6WuDkR4nHxalD05oYtpjAPGpJqwJsyChFAyuUm7kn3qeX0l/Ll4GT6V4KsGQyin 79 | g3Iip0KPOiY+ndAxffTAAiyjFHB7UVe5vfe8NAIU9eBDT8Ibbi2ay9IhQaRABWOc 80 | KnpOfyDnCZbjNekskQaOqiE= 81 | -----END PRIVATE KEY-----`) 82 | 83 | const ( 84 | Init = iota 85 | Greeted 86 | AskedUsername 87 | AskedPassword 88 | GotPassword 89 | ) 90 | 91 | // Test design is to make a "sandwich" with wrapper in the middle. 92 | // test client <--> wrapper <--> mock SMTP server (Backend, Session) 93 | // The mock SMTP server returns realistic looking response codes etc 94 | type mockBackend struct { 95 | mockReply chan []byte 96 | } 97 | 98 | // A Session is returned after successful login. Here hold information that needs to persist across message phases. 99 | type mockSession struct { 100 | MockState int 101 | bkd *mockBackend 102 | } 103 | 104 | // mockSMTPServer should be invoked as a goroutine to allow tests to continue 105 | func mockSMTPServer(t *testing.T, addr string, mockReply chan []byte) { 106 | mockbe := mockBackend{ 107 | mockReply: mockReply, 108 | } 109 | s := smtpproxy.NewServer(&mockbe) 110 | s.Addr = addr 111 | s.ReadTimeout = 60 * time.Second // changeme? 112 | s.WriteTimeout = 60 * time.Second 113 | if err := s.ServeTLS(localhostCert, localhostKey); err != nil { 114 | t.Fatal(err) 115 | } 116 | 117 | // Begin serving requests 118 | t.Log("Upstream mock SMTP server listening on", s.Addr) 119 | if err := s.ListenAndServe(); err != nil { 120 | t.Fatal(err) 121 | } 122 | } 123 | 124 | // Init the backend. This does not need to do much. 125 | func (bkd *mockBackend) Init() (smtpproxy.Session, error) { 126 | var s mockSession 127 | s.MockState = Init 128 | s.bkd = bkd 129 | return &s, nil 130 | } 131 | 132 | // Greet the upstream host and report capabilities back. 133 | func (s *mockSession) Greet(helotype string) ([]string, int, string, error) { 134 | s.MockState = Greeted 135 | caps := []string{"8BITMIME", "STARTTLS", "ENHANCEDSTATUSCODES", "AUTH LOGIN PLAIN", "SMTPUTF8"} 136 | return caps, 220, "", nil 137 | } 138 | 139 | // StartTLS command 140 | func (s *mockSession) StartTLS() (int, string, error) { 141 | return 220, "", nil 142 | } 143 | 144 | const mockMsg = "2.0.0 mock server accepts all" 145 | 146 | //Auth command mock backend handler - naive, handles only AUTH LOGIN PLAIN 147 | func (s *mockSession) Auth(expectcode int, cmd, arg string) (int, string, error) { 148 | var code int 149 | var msg string 150 | switch s.MockState { 151 | case Init: 152 | case Greeted: 153 | if arg == "LOGIN" { 154 | code = 334 155 | msg = base64.StdEncoding.EncodeToString([]byte("Username:")) 156 | s.MockState = AskedUsername 157 | } else if strings.HasPrefix(arg, "PLAIN") { 158 | code = 235 159 | msg = mockMsg 160 | s.MockState = GotPassword 161 | } 162 | case AskedUsername: 163 | code = 334 164 | msg = base64.StdEncoding.EncodeToString([]byte("Password:")) 165 | s.MockState = AskedPassword 166 | case AskedPassword: 167 | code = 235 168 | msg = mockMsg 169 | s.MockState = GotPassword 170 | } 171 | return code, msg, nil 172 | } 173 | 174 | //Mail command mock backend handler 175 | func (s *mockSession) Mail(expectcode int, cmd, arg string) (int, string, error) { 176 | return 250, mockMsg, nil 177 | } 178 | 179 | //Rcpt command mock backend handler 180 | func (s *mockSession) Rcpt(expectcode int, cmd, arg string) (int, string, error) { 181 | return 250, mockMsg, nil 182 | } 183 | 184 | //Reset command mock backend handler 185 | func (s *mockSession) Reset(expectcode int, cmd, arg string) (int, string, error) { 186 | s.MockState = Init 187 | return 250, "2.0.0 mock reset", nil 188 | } 189 | 190 | //Quit command mock backend handler 191 | func (s *mockSession) Quit(expectcode int, cmd, arg string) (int, string, error) { 192 | s.MockState = Init 193 | return 221, "2.3.0 mock says bye", nil 194 | } 195 | 196 | //Unknown command mock backend handler 197 | func (s *mockSession) Unknown(expectcode int, cmd, arg string) (int, string, error) { 198 | return 500, "mock does not recognize this command", nil 199 | } 200 | 201 | type myWriteCloser struct { 202 | io.Writer 203 | } 204 | 205 | func (myWriteCloser) Close() error { 206 | return nil 207 | } 208 | 209 | // DataCommand pass upstream, returning a place to write the data AND the usual responses 210 | // If you want to see the mail contents, replace Discard with os.Stdout 211 | func (s *mockSession) DataCommand() (io.WriteCloser, int, string, error) { 212 | return myWriteCloser{Writer: ioutil.Discard}, 354, `3.0.0 mock says continue. finished with "\r\n.\r\n"`, nil 213 | } 214 | 215 | // Data body (dot delimited) pass upstream, returning the usual responses. 216 | // Also emit a copy back in the test harness response channel, if present 217 | func (s *mockSession) Data(r io.Reader, w io.WriteCloser) (int, string, error) { 218 | var buf bytes.Buffer 219 | _, err := io.Copy(&buf, r) 220 | resp := buf.Bytes() // get the whole received mail body 221 | _, err = w.Write(resp) // copy through to the writer 222 | if s.bkd.mockReply != nil { 223 | s.bkd.mockReply <- resp 224 | } 225 | return 250, "2.0.0 OK mock got your dot", err 226 | } 227 | 228 | //----------------------------------------------------------------------------- 229 | // Start proxy server 230 | 231 | func startProxy(t *testing.T, s *smtpproxy.Server) { 232 | t.Log("Proxy (unit under test) listening on", s.Addr) 233 | if err := s.ListenAndServe(); err != nil { 234 | t.Fatal(err) 235 | } 236 | } 237 | 238 | // tlsClientConfig is built from the passed in cert, privkey. InsecureSkipVerify allows self-signed certs to work 239 | func tlsClientConfig(cert []byte, privkey []byte) (*tls.Config, error) { 240 | cer, err := tls.X509KeyPair(cert, privkey) 241 | if err != nil { 242 | return nil, err 243 | } 244 | config := &tls.Config{Certificates: []tls.Certificate{cer}} 245 | config.InsecureSkipVerify = true 246 | return config, nil 247 | } 248 | 249 | //----------------------------------------------------------------------------- 250 | // proxy tests 251 | 252 | const inHostPort = "localhost:5580" // need to specifically have keyword localhost in here for c.Auth to accept nonsecure connections 253 | const outHostPort = ":5581" 254 | const downstreamDebug = "debug_proxy_test.log" 255 | const inHostPort2 = "localhost:5582" // need to specifically have keyword localhost in here for c.Auth to accept nonsecure connections 256 | 257 | func TestProxy(t *testing.T) { 258 | rand.Seed(time.Now().UTC().UnixNano()) 259 | verboseOpt := true 260 | insecureSkipVerify := true 261 | var err error 262 | 263 | // Logging of downstream (client to proxy server) commands and responses 264 | var dbgFile *os.File 265 | if downstreamDebug != "" { 266 | dbgFile, err = os.OpenFile(downstreamDebug, os.O_CREATE|os.O_WRONLY, 0644) 267 | if err != nil { 268 | log.Fatal(err) 269 | } else { 270 | defer dbgFile.Close() 271 | log.Println("Proxy logging SMTP commands, responses and downstream DATA to", dbgFile.Name()) 272 | } 273 | } 274 | 275 | s, be, err := smtpproxy.CreateProxy(inHostPort, outHostPort, verboseOpt, localhostCert, localhostKey, insecureSkipVerify, dbgFile) 276 | if err != nil { 277 | log.Fatal(err) 278 | } 279 | 280 | // start the upstream mock SMTP server, which will reply in the channel 281 | mockReply := make(chan []byte, 1) 282 | go mockSMTPServer(t, outHostPort, mockReply) 283 | 284 | // start the proxy 285 | go startProxy(t, s) 286 | 287 | // Exercise various combinations of security, logging, whether expecting a tracking link to show up in the output etc 288 | sendAndCheckEmails(t, inHostPort, 20, "", mockReply, PlainEmail) // plaintext email (won't be tracked) 289 | 290 | sendAndCheckEmails(t, inHostPort, 20, "", mockReply, RandomTestEmail) // html email 291 | 292 | sendAndCheckEmails(t, inHostPort, 20, "STARTTLS", mockReply, PlainEmail) // plaintext email (won't be tracked) 293 | 294 | sendAndCheckEmails(t, inHostPort, 20, "STARTTLS", mockReply, RandomTestEmail) // html email 295 | 296 | // Flip the logging to non-verbose after the first pass, to exercise that path 297 | be.SetVerbose(false) 298 | sendAndCheckEmails(t, inHostPort, 20, "STARTTLS", mockReply, RandomTestEmail) 299 | } 300 | 301 | func sendAndCheckEmails(t *testing.T, inHostPort string, n int, secure string, mockReply chan []byte, makeEmail func() string) { 302 | // Allow server a little while to start, then send a test mail using standard net/smtp.Client 303 | c, err := smtp.Dial(inHostPort) 304 | for i := 0; err != nil && i < 10; i++ { 305 | time.Sleep(time.Millisecond * 100) 306 | c, err = smtp.Dial(inHostPort) 307 | } 308 | if err != nil { 309 | t.Fatalf("Can't connect to proxy: %v\n", err) 310 | } 311 | // EHLO 312 | err = c.Hello("localhost") 313 | if err != nil { 314 | t.Error(err) 315 | } 316 | 317 | // STARTTLS 318 | if strings.ToUpper(secure) == "STARTTLS" { 319 | if tls, _ := c.Extension("STARTTLS"); tls { 320 | // client uses same certs as mock server and proxy, which seems fine for testing purposes 321 | cfg, err := tlsClientConfig(localhostCert, localhostKey) 322 | if err != nil { 323 | t.Error(err) 324 | } 325 | // only upgrade connection if not already in TLS 326 | if _, isTLS := c.TLSConnectionState(); !isTLS { 327 | err = c.StartTLS(cfg) 328 | if err != nil { 329 | t.Fatal(err) 330 | } 331 | } 332 | } 333 | } 334 | 335 | // Check AUTH supported 336 | ok, param := c.Extension("AUTH") 337 | if !ok { 338 | t.Errorf("Got %v, expected %v, param=%s\n", ok, true, param) 339 | } 340 | 341 | // AUTH 342 | auth := smtp.PlainAuth("", "user@example.com", "password", "localhost") 343 | err = c.Auth(auth) 344 | if err != nil { 345 | t.Fatal(err) 346 | } 347 | for i := 0; i < n; i++ { 348 | // Submit an email .. MAIL FROM, RCPT TO, DATA ... 349 | err = c.Mail(RandomRecipient()) 350 | if err != nil { 351 | t.Error(err) 352 | } 353 | err = c.Rcpt(RandomRecipient()) 354 | if err != nil { 355 | t.Error(err) 356 | } 357 | w, err := c.Data() 358 | if err != nil { 359 | t.Error(err) 360 | } 361 | testEmail := makeEmail() 362 | r := strings.NewReader(testEmail) 363 | bytesWritten, err := io.Copy(w, r) 364 | if err != nil { 365 | t.Error(err) 366 | } 367 | if int(bytesWritten) != len(testEmail) { 368 | t.Fatalf("Unexpected DATA copy length %v", bytesWritten) 369 | } 370 | 371 | err = w.Close() // Close the data phase 372 | if err != nil { 373 | t.Error(err) 374 | } 375 | 376 | // Collect the response from the mock server 377 | mockr := <-mockReply 378 | 379 | // buf now contains the "wrapped" email 380 | outputMail, err := mail.ReadMessage(bytes.NewReader(mockr)) 381 | if err != nil { 382 | t.Error(err) 383 | } 384 | inputMail, err := mail.ReadMessage(strings.NewReader(testEmail)) 385 | if err != nil { 386 | t.Error(err) 387 | } 388 | compareInOutMail(t, inputMail, outputMail) 389 | } 390 | 391 | // Provoke unknown command 392 | id, err := c.Text.Cmd("WEIRD") 393 | if err != nil { 394 | t.Error(err) 395 | } 396 | c.Text.StartResponse(id) 397 | code, msg, err := c.Text.ReadResponse(501) 398 | t.Log("Response to WEIRD command:", code, msg) 399 | if code != 501 { 400 | t.Fatalf("Provoked unknown command - got error %v", err) 401 | } 402 | c.Text.EndResponse(id) 403 | 404 | // RESET is not part of the usual happy path for a message ,but we can test 405 | err = c.Reset() 406 | if err != nil { 407 | t.Error(err) 408 | } 409 | 410 | // QUIT 411 | err = c.Quit() 412 | if err != nil { 413 | t.Error(err) 414 | } 415 | } 416 | 417 | func compareInOutMail(t *testing.T, inputMail *mail.Message, outputMail *mail.Message) { 418 | // check the headers match 419 | for hdrType, _ := range inputMail.Header { 420 | in := inputMail.Header.Get(hdrType) 421 | out := outputMail.Header.Get(hdrType) 422 | if in != out { 423 | t.Errorf("Header %v mismatch", hdrType) 424 | } 425 | } 426 | 427 | // Compare body lengths 428 | inBody, err := ioutil.ReadAll(inputMail.Body) 429 | if err != nil { 430 | t.Error(err) 431 | } 432 | outBody, err := ioutil.ReadAll(outputMail.Body) 433 | if err != nil { 434 | t.Error(err) 435 | } 436 | 437 | // Compare lengths - should be nonzero and within an approx ratio of each other. 438 | inL := len(inBody) 439 | outL := len(outBody) 440 | if inL != outL { 441 | t.Errorf("Output email length %d, was expecting %d\n", outL, inL) 442 | } 443 | } 444 | 445 | func makeFakeSession(t *testing.T, be *smtpproxy.ProxyBackend, url string) smtpproxy.Session { 446 | c, err := textproto.Dial("tcp", url) 447 | if err != nil { 448 | t.Error(err) 449 | } 450 | return be.MakeSession(&smtpproxy.Client{Text: c}) 451 | } 452 | 453 | func TestProxyFaultyInputs(t *testing.T) { 454 | outHostPort := ":9988" 455 | verboseOpt := false // vary this from the usual 456 | insecureSkipVerify := true 457 | 458 | // Set up parameters that the backend will use, and initialise the proxy server parameters 459 | be := smtpproxy.NewBackend(outHostPort, verboseOpt, insecureSkipVerify) 460 | _, err := be.Init() // expect an error 461 | if err == nil { 462 | t.Errorf("This test should have returned a non-nil error code") 463 | } 464 | 465 | const dummyServer = "example.com:80" 466 | // Provoke error path in Greet (hitting an http server, not an smtp one) 467 | s := makeFakeSession(t, be, dummyServer) 468 | caps, code, msg, err := s.Greet("EHLO") 469 | if err == nil { 470 | t.Errorf("This test should have returned a non-nil error code") 471 | } 472 | 473 | // Provoke error path in STARTTLS. Need to get a fresh connection each time 474 | s = makeFakeSession(t, be, dummyServer) 475 | code, msg, err = s.StartTLS() 476 | if err == nil { 477 | t.Errorf("This test should have returned a non-nil error code") 478 | } 479 | 480 | // Exercise the session unknown command handler (passthru) 481 | s = makeFakeSession(t, be, dummyServer) 482 | code, msg, err = s.Unknown(0, "NONSENSE", "") 483 | if err == nil { 484 | t.Errorf("This test should have returned a non-nil error code") 485 | } 486 | 487 | // Exercise the error paths in DataCommand 488 | s = makeFakeSession(t, be, dummyServer) 489 | w, code, msg, err := s.DataCommand() 490 | if err == nil { 491 | t.Errorf("This test should have returned a non-nil error code") 492 | } 493 | 494 | // Exercise the error paths in Data (body) 495 | s = makeFakeSession(t, be, dummyServer) 496 | r := strings.NewReader("it is only the hairs on a gooseberry") // this should cause a mailcopy error, as it's not valid RFC822 497 | code, msg, err = s.Data(r, myWriteCloser{Writer: ioutil.Discard}) 498 | /* Simple proxy can send any text, does not check MIME parts etc 499 | if err == nil { 500 | t.Errorf("This test should have returned a non-nil error code") 501 | } 502 | */ 503 | 504 | // Valid input mail, but cannot write to the destination stream 505 | s = makeFakeSession(t, be, dummyServer) 506 | testEmail := RandomTestEmail() 507 | r = strings.NewReader(testEmail) 508 | code, msg, err = s.Data(r, brokenWriteCloser(t)) 509 | if err == nil { 510 | t.Errorf("This test should have returned a non-nil error code") 511 | } 512 | 513 | // Valid input mail and output stream, but broken upstream debug stream 514 | s = makeFakeSession(t, be, dummyServer) 515 | r = strings.NewReader(testEmail) 516 | // Set up parameters that the backend will use, and initialise the proxy server parameters 517 | be2 := smtpproxy.NewBackend(outHostPort, verboseOpt, insecureSkipVerify) 518 | s = makeFakeSession(t, be2, dummyServer) 519 | code, msg, err = s.Data(r, myWriteCloser{Writer: ioutil.Discard}) 520 | /* Simple proxy can send any text, does not check MIME parts etc 521 | if err == nil { 522 | t.Errorf("This test should have returned a non-nil error code") 523 | } 524 | */ 525 | 526 | _, _, _, _ = caps, code, msg, w // workaround these variables being "unused" yet useful for debugging the test 527 | } 528 | 529 | // Deliberately return a WriteCloser that should break 530 | func brokenWriteCloser(t *testing.T) io.WriteCloser { 531 | f := alreadyClosedFile(t) 532 | return myWriteCloser{Writer: f} 533 | } 534 | 535 | // Deliberately return an unusable file handle 536 | func alreadyClosedFile(t *testing.T) *os.File { 537 | f, err := ioutil.TempFile(".", "tmp") 538 | if err != nil { 539 | t.Error(err) 540 | } 541 | err = f.Close() 542 | if err != nil { 543 | t.Error(err) 544 | } 545 | os.Remove(f.Name()) 546 | return f 547 | } 548 | 549 | //----------------------------------------------------------------------------- 550 | // test email & html templates 551 | 552 | // string params: initial_pixel, testTargetURL, end_pixel 553 | const testHTMLTemplate1 = ` 554 | 555 | 556 | 557 | test mail 558 | 559 | %s 560 | Click SparkPost 561 |

This is a very long line of text indeed containing !"#$%%&'()*+,-./0123456789:; escaped 562 | ?@ABCDEFGHIJKLMNOPQRSTUVWXYZ\[ ]^_abcdefghijklmnopqrstuvwxyz ~

563 |

Here's some exotic characters to work the quoted-printable stuff ¡¢£¤¥¦§¨©ª« ¡¢£¤¥¦§¨©ª« 564 |

565 | Click Another tracked link 566 | %s 567 | 568 | ` 569 | 570 | const testTextTemplate1 = `Spicy jalapeno bacon ipsum dolor amet pariatur mollit fatback venison, cillum occaecat quis ut labore pork belly culpa ea bacon in spare ribs.` 571 | 572 | // string params: to, from, bound, bound, testTextTemplate1, bound, testHTML, bound 573 | const testEmailTemplate = `To: %s 574 | From: %s 575 | Subject: Fresh, tasty Avocados delivered straight to your door! 576 | Content-Type: multipart/alternative; boundary="%s" 577 | MIME-Version: 1.0 578 | 579 | --%s 580 | Content-Transfer-Encoding: 7bit 581 | Content-Type: text/plain; charset="UTF-8" 582 | 583 | %s 584 | 585 | --%s 586 | Content-Transfer-Encoding: 8bit 587 | Content-Type: text/html; charset="UTF-8" 588 | 589 | %s 590 | --%s-- 591 | ` 592 | 593 | func testHTML(htmlTemplate, URL1, URL2 string) string { 594 | return fmt.Sprintf(htmlTemplate, "", URL1, URL2, "") 595 | } 596 | 597 | // RandomTestEmail creates HTML body contents, and places inside an email 598 | func RandomTestEmail() string { 599 | URL1 := RandomURLWithPath() 600 | URL2 := RandomURLWithPath() 601 | testHTML := testHTML(testHTMLTemplate1, URL1, URL2) 602 | to := RandomRecipient() 603 | from := RandomRecipient() 604 | u := uuid.New() // randomise boundary marker 605 | bound := fmt.Sprintf("%0x", u[:8]) 606 | return fmt.Sprintf(testEmailTemplate, to, from, bound, bound, testTextTemplate1, bound, testHTML, bound) 607 | } 608 | 609 | const plainEmailTemplate = `To: %s 610 | From: %s 611 | Subject: A plaintext email 612 | MIME-Version: 1.0 613 | 614 | short plaintext 615 | ` 616 | 617 | func PlainEmail() string { 618 | to := RandomRecipient() 619 | from := RandomRecipient() 620 | return fmt.Sprintf(plainEmailTemplate, to, from) 621 | } 622 | 623 | func RandomWord() string { 624 | const dict = "abcdefghijklmnopqrstuvwxyz" 625 | l := rand.Intn(20) + 1 626 | s := "" 627 | for ; l > 0; l-- { 628 | p := rand.Intn(len(dict)) 629 | s = s + string(dict[p]) 630 | } 631 | return s 632 | } 633 | 634 | // A completely random URL (not belonging to any actual TLD), pathlen should be >= 0 635 | func RandomURL(pathlen int) string { 636 | var method string 637 | if rand.Intn(2) > 0 { 638 | method = "https" 639 | } else { 640 | method = "http" 641 | } 642 | path := "" 643 | for ; pathlen > 0; pathlen-- { 644 | path = path + "/" + RandomWord() 645 | } 646 | return method + "://" + RandomWord() + "." + RandomWord() + path 647 | } 648 | 649 | func RandomBaseURL() string { 650 | return RandomURL(0) 651 | } 652 | 653 | func RandomURLWithPath() string { 654 | return RandomURL(rand.Intn(4)) 655 | } 656 | 657 | func RandomRecipient() string { 658 | return RandomWord() + "@" + RandomWord() + "." + RandomWord() 659 | } 660 | 661 | //----------------------------------------------------------------------------- 662 | 663 | func TestClientOtherFunctions(t *testing.T) { 664 | // client uses same certs as mock server and proxy, which seems fine for testing purposes 665 | cfg, err := tlsClientConfig(localhostCert, localhostKey) 666 | if err != nil { 667 | t.Error(err) 668 | } 669 | // DialTLS is not used by the proxy app in its current form, but may be useful 670 | smtps := "smtp.gmail.com:465" 671 | c, err := smtpproxy.DialTLS(smtps, cfg) 672 | if err != nil { 673 | t.Error(err) 674 | } 675 | 676 | // Greet the endpoint 677 | code, msg, err := c.Hello("there") 678 | if err != nil { 679 | t.Errorf("code %v msg %v err %v\n", code, msg, err) 680 | } 681 | 682 | // Check extensions 683 | ok, params := c.Extension("AUTH") 684 | if !ok { 685 | t.Errorf("ok %v, expected %v, params %v\n", ok, true, params) 686 | } 687 | 688 | // Close 689 | err = c.Close() 690 | if err != nil { 691 | t.Error(err) 692 | } 693 | } 694 | 695 | // errorMatch allows for fuzzy matching when specific errors expected 696 | func errorMatch(e1, eChk error) bool { 697 | if e1 == nil && eChk == nil { 698 | return true 699 | } 700 | return strings.Contains(e1.Error(), eChk.Error()) 701 | } 702 | 703 | func TestServerOtherFunctions(t *testing.T) { 704 | verboseOpt := true 705 | insecureSkipVerify := true 706 | // this time, don't log 707 | s, _, err := smtpproxy.CreateProxy(inHostPort2, outHostPort, verboseOpt, nil, nil, insecureSkipVerify, nil) 708 | if err != nil { 709 | log.Fatal(err) 710 | } 711 | _ = s // currently unused 712 | 713 | type actionExpectedResponse struct { 714 | line string 715 | cmd string 716 | arg string 717 | err error 718 | } 719 | arList := []actionExpectedResponse{ 720 | {"", "", "", nil}, 721 | {"STARTTLS", "STARTTLS", "", nil}, 722 | {"M", "", "", errors.New(`Command too short`)}, 723 | {"QUIT", "QUIT", "", nil}, 724 | {"MAIL ", "", "", errors.New(`Mangled command`)}, 725 | {"MAILxx", "", "", errors.New(`Mangled command`)}, 726 | {"MAIL FROM", "MAIL", "FROM", nil}, 727 | } 728 | for _, v := range arList { 729 | cmd, arg, err := smtpproxy.ParseCmd(v.line) 730 | if cmd != v.cmd || arg != v.arg || !errorMatch(err, v.err) { 731 | t.Errorf("Unexpected value cmd, arg, err = (%v, %v, %v) - expected (%v, %v, %v)\n", cmd, arg, err, v.cmd, v.arg, v.err) 732 | } 733 | } 734 | } 735 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Package smtpproxy is based heavily on https://github.com/emersion/go-smtp, with increased transparency of response codes and no sasl dependency. 2 | package smtpproxy 3 | 4 | import ( 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "io" 9 | "log" 10 | "net" 11 | "os" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var errTCPAndLMTP = errors.New("smtp: cannot start LMTP server listening on a TCP socket") 17 | 18 | // Logger interface is used by Server to report unexpected internal errors. 19 | type Logger interface { 20 | Printf(format string, v ...interface{}) 21 | Println(v ...interface{}) 22 | } 23 | 24 | // Server is an SMTP server. 25 | type Server struct { 26 | // TCP or Unix address to listen on. 27 | Addr string 28 | // The server TLS configuration. 29 | TLSConfig *tls.Config 30 | 31 | Domain string 32 | 33 | Debug io.Writer 34 | ErrorLog Logger 35 | ReadTimeout time.Duration 36 | WriteTimeout time.Duration 37 | 38 | // If set, the AUTH command will not be advertised and authentication 39 | // attempts will be rejected. This setting overrides AllowInsecureAuth. 40 | AuthDisabled bool 41 | 42 | // The server backend. 43 | Backend Backend 44 | 45 | listener net.Listener 46 | caps []string 47 | 48 | //auths no longer using sasl library 49 | 50 | locker sync.Mutex 51 | conns map[*Conn]struct{} 52 | } 53 | 54 | // NewServer creates a new SMTP server, with a Backend interface, supporting many connections 55 | func NewServer(be Backend) *Server { 56 | return &Server{ 57 | Backend: be, 58 | ErrorLog: log.New(os.Stderr, "smtp/server ", log.LstdFlags), 59 | caps: []string{"PIPELINING", "8BITMIME", "ENHANCEDSTATUSCODES"}, 60 | conns: make(map[*Conn]struct{}), 61 | } 62 | } 63 | 64 | // ServeTLS configures the server with TLS credentials from supplied cert/key 65 | // and sets the EHLO server name 66 | func (s *Server) ServeTLS(cert []byte, privkey []byte) error { 67 | cer, err := tls.X509KeyPair(cert, privkey) 68 | if err != nil { 69 | return err 70 | } 71 | config := &tls.Config{Certificates: []tls.Certificate{cer}} 72 | s.TLSConfig = config 73 | leafCer, err := x509.ParseCertificate(cer.Certificate[0]) 74 | if err != nil { 75 | return err 76 | } 77 | s.Domain = leafCer.Subject.CommonName 78 | return nil 79 | } 80 | 81 | // Serve accepts incoming connections on the Listener l. 82 | func (s *Server) Serve(l net.Listener) error { 83 | s.listener = l 84 | defer s.Close() 85 | 86 | for { 87 | c, err := l.Accept() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | go s.handleConn(newConn(c, s)) 93 | } 94 | } 95 | 96 | // handleConn handles incoming SMTP connections 97 | func (s *Server) handleConn(c *Conn) error { 98 | s.locker.Lock() 99 | s.conns[c] = struct{}{} 100 | s.locker.Unlock() 101 | 102 | defer func() { 103 | c.Close() 104 | 105 | s.locker.Lock() 106 | delete(s.conns, c) 107 | s.locker.Unlock() 108 | }() 109 | 110 | c.greet() 111 | 112 | for { 113 | line, err := c.ReadLine() 114 | if err == nil { 115 | cmd, arg, err := ParseCmd(line) 116 | if err != nil { 117 | c.nbrErrors++ 118 | c.WriteResponse(501, EnhancedCode{5, 5, 2}, "Bad command") 119 | continue 120 | } 121 | 122 | c.handle(cmd, arg) 123 | } else { 124 | if err == io.EOF { 125 | return nil 126 | } 127 | 128 | if neterr, ok := err.(net.Error); ok && neterr.Timeout() { 129 | c.WriteResponse(221, EnhancedCode{2, 4, 2}, "Idle timeout, bye bye") 130 | return nil 131 | } 132 | 133 | c.WriteResponse(221, EnhancedCode{2, 4, 0}, "Connection error, sorry") 134 | return err 135 | } 136 | } 137 | } 138 | 139 | // ListenAndServe listens on the network address s.Addr and then calls Serve 140 | // to handle requests on incoming connections. 141 | // 142 | // If s.Addr is blank and LMTP is disabled, ":smtp" is used. 143 | func (s *Server) ListenAndServe() error { 144 | network := "tcp" 145 | /* if s.LMTP { 146 | network = "unix" 147 | } */ 148 | 149 | addr := s.Addr 150 | if addr == "" { 151 | addr = ":smtp" 152 | } 153 | 154 | l, err := net.Listen(network, addr) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | return s.Serve(l) 160 | } 161 | 162 | // Close function not needed 163 | func (s *Server) Close() { 164 | s.listener.Close() 165 | 166 | s.locker.Lock() 167 | defer s.locker.Unlock() 168 | 169 | for conn := range s.conns { 170 | conn.Close() 171 | } 172 | } 173 | 174 | // ForEachConn not needed 175 | --------------------------------------------------------------------------------