Merge branch 'master' into outbound
authorRobert Sesek <rsesek@bluestatic.org>
Sun, 24 May 2020 05:39:12 +0000 (01:39 -0400)
committerRobert Sesek <rsesek@bluestatic.org>
Sun, 24 May 2020 05:39:12 +0000 (01:39 -0400)
13 files changed:
README.md
config.go
pop3.go
smtp.go
smtp/conn.go
smtp/conn_test.go
smtp/relay.go [new file with mode: 0644]
smtp/relay_test.go [new file with mode: 0644]
smtp/server.go
smtp/server_test.go [new file with mode: 0644]
smtp_test.go [new file with mode: 0644]
testtls/domain.crt [new file with mode: 0644]
testtls/domain.key [new file with mode: 0644]

index e424860261601dd2fefcfa06ff0259898611e493..05a0e51a4068668357ae0c037784ea9ff831d715 100644 (file)
--- a/README.md
+++ b/README.md
@@ -9,11 +9,28 @@ single mailbox, which can then be accessed using the POP3 protocol.
 TLS is recommended in production environments. To facilitate live-reloading of certificates, you can
 send a running instance SIGHUP.
 
+## Send-As SMTP
+
+Since mailpopbox is designed as a catch-all mail server, it would be impractical to administer SMTP
+accounts to enable replying from any address handled by the server. The SMTP server instead
+provides a way to send messages from arbitrary addresses by authenticating as the mailbox@DOMAIN
+user. Any valid SMTP MAIL FROM is supported after authentication, but mail clients will typically
+use the mailbox@DOMAIN user or the From header. The SMTP server's feature is that if the message's
+Subject header has a special "[sendas:ANYTHING]" string, the server will alter the From message
+header to be from ANYTHING@DOMAIN.
+
+Practically, this means configuring an outbound mail client to send mail as mailbox@DOMAIN and
+authenticate to the SMTP server as such. And in order to change the sending address as perceived by
+the recipient, edit the subject with [sendas:ADDRESS].
+
 ## RFCs
 
 This server implements the following RFCs:
 
 - [Post Office Protocol - Version 3, RFC 1939](https://tools.ietf.org/html/rfc1939)
 - [Simple Mail Transfer Protocol, RFC 5321](https://tools.ietf.org/html/rfc5321)
+- [Message Submission for Mail, RFC 6409](https://tools.ietf.org/html/rfc6409)
 - [SMTP Service Extension for Secure SMTP over Transport Layer Security, RFC 3207](https://tools.ietf.org/html/rfc3207)
+- [SMTP Service Extension for Authentication, RFC 2554](https://tools.ietf.org/html/rfc2554)
+- [The PLAIN Simple Authentication and Security Layer (SASL) Mechanism, RFC 4616](https://tools.ietf.org/html/rfc4616)
 - [POP3 Extension Mechanism, RFC 2449](https://tools.ietf.org/html/rfc2449)
index ff695751f579c69698061d13695637d8c8f88d7c..c5f24ca2a6313a8a9543d88cf936a3dc7611671e 100644 (file)
--- a/config.go
+++ b/config.go
@@ -20,6 +20,8 @@ type Config struct {
        Servers []Server
 }
 
+const MailboxAccount = "mailbox@"
+
 type Server struct {
        // Domain is the second component of a mail address: <local-part@domain.com>.
        Domain string
diff --git a/pop3.go b/pop3.go
index cada451f5e4a86b0ea05e32956bda66ef86524ed..085cfdfe09ed46170c0c825a5400ed577a0ee594 100644 (file)
--- a/pop3.go
+++ b/pop3.go
@@ -104,7 +104,7 @@ func (server *pop3Server) Name() string {
 
 func (server *pop3Server) OpenMailbox(user, pass string) (pop3.Mailbox, error) {
        for _, s := range server.config.Servers {
-               if user == "mailbox@"+s.Domain && pass == s.MailboxPassword {
+               if user == MailboxAccount+s.Domain && pass == s.MailboxPassword {
                        return server.openMailbox(s.MaildropPath)
                }
        }
diff --git a/smtp.go b/smtp.go
index 14ead8fc41fd5652ca0fbf5fd614f48812d2fe86..2a27279b636aacc068139be322790ffda3410128 100644 (file)
--- a/smtp.go
+++ b/smtp.go
@@ -13,7 +13,6 @@ import (
        "net/mail"
        "os"
        "path"
-       "strings"
 
        "go.uber.org/zap"
 
@@ -102,8 +101,28 @@ func (server *smtpServer) VerifyAddress(addr mail.Address) smtp.ReplyLine {
        return smtp.ReplyOK
 }
 
-func (server *smtpServer) OnEHLO() *smtp.ReplyLine {
-       return nil
+func (server *smtpServer) Authenticate(authz, authc, passwd string) bool {
+       authcAddr, err := mail.ParseAddress(authc)
+       if err != nil {
+               return false
+       }
+
+       authzAddr, err := mail.ParseAddress(authz)
+       if authz != "" && err != nil {
+               return false
+       }
+
+       domain := smtp.DomainForAddress(*authcAddr)
+       for _, s := range server.config.Servers {
+               if domain == s.Domain {
+                       authOk := authc == MailboxAccount+s.Domain && passwd == s.MailboxPassword
+                       if authzAddr != nil {
+                               authOk = authOk && smtp.DomainForAddress(*authzAddr) == domain
+                       }
+                       return authOk
+               }
+       }
+       return false
 }
 
 func (server *smtpServer) OnMessageDelivered(en smtp.Envelope) *smtp.ReplyLine {
@@ -124,14 +143,13 @@ func (server *smtpServer) OnMessageDelivered(en smtp.Envelope) *smtp.ReplyLine {
        return nil
 }
 
-func (server *smtpServer) maildropForAddress(addr mail.Address) string {
-       domainIdx := strings.LastIndex(addr.Address, "@")
-       if domainIdx == -1 {
-               return ""
-       }
-
-       domain := addr.Address[domainIdx+1:]
+func (server *smtpServer) RelayMessage(en smtp.Envelope) {
+       log := server.log.With(zap.String("id", en.ID))
+       go smtp.RelayMessage(server, en, log)
+}
 
+func (server *smtpServer) maildropForAddress(addr mail.Address) string {
+       domain := smtp.DomainForAddress(addr)
        for _, s := range server.config.Servers {
                if domain == s.Domain {
                        return s.MaildropPath
index 94db8a8d785ac6c19dca759940e59d8b583bc393..ec1644e327888edc0d0c605ff8fd8ce69866b7f6 100644 (file)
@@ -7,8 +7,10 @@
 package smtp
 
 import (
+       "bytes"
        "crypto/rand"
        "crypto/tls"
+       "encoding/base64"
        "fmt"
        "net"
        "net/mail"
@@ -29,6 +31,26 @@ const (
        stateData
 )
 
+type delivery int
+
+func (d delivery) String() string {
+       switch d {
+       case deliverUnknown:
+               return "unknown"
+       case deliverInbound:
+               return "inbound"
+       case deliverOutbound:
+               return "outbound"
+       }
+       panic("Unknown delivery")
+}
+
+const (
+       deliverUnknown  delivery = iota
+       deliverInbound           // Mail is not from one of this server's domains.
+       deliverOutbound          // Mail IS from one of this server's domains.
+)
+
 type connection struct {
        server Server
 
@@ -42,9 +64,17 @@ type connection struct {
 
        log *zap.Logger
 
+       // The authcid from a PLAIN SASL login. Non-empty iff tls is non-nil and
+       // doAUTH() succeeded.
+       authc string
+
        state
        line string
 
+       delivery
+       // For deliverOutbound, replaces the From and Reply-To values.
+       sendAs *mail.Address
+
        ehlo     string
        mailFrom *mail.Address
        rcptTo   []mail.Address
@@ -73,7 +103,12 @@ func AcceptConnection(netConn net.Conn, server Server, log *zap.Logger) {
                        return
                }
 
-               conn.log.Info("ReadLine()", zap.String("line", conn.line))
+               lineForLog := conn.line
+               const authPlain = "AUTH PLAIN "
+               if strings.HasPrefix(conn.line, authPlain) {
+                       lineForLog = authPlain + "[redacted]"
+               }
+               conn.log.Info("ReadLine()", zap.String("line", lineForLog))
 
                var cmd string
                if _, err = fmt.Sscanf(conn.line, "%s", &cmd); err != nil {
@@ -94,6 +129,8 @@ func AcceptConnection(netConn net.Conn, server Server, log *zap.Logger) {
                        conn.doEHLO()
                case "STARTTLS":
                        conn.doSTARTTLS()
+               case "AUTH":
+                       conn.doAUTH()
                case "MAIL":
                        conn.doMAIL()
                case "RCPT":
@@ -171,6 +208,9 @@ func (conn *connection) doEHLO() {
                if conn.server.TLSConfig() != nil && conn.tls == nil {
                        conn.tp.PrintfLine("250-STARTTLS")
                }
+               if conn.tls != nil {
+                       conn.tp.PrintfLine("250-AUTH PLAIN")
+               }
                conn.tp.PrintfLine("250 SIZE %d", 40960000)
        }
 
@@ -210,6 +250,72 @@ func (conn *connection) doSTARTTLS() {
        conn.log.Info("TLS connection done", zap.String("state", conn.getTransportString()))
 }
 
+func (conn *connection) doAUTH() {
+       if conn.state != stateInitial || conn.tls == nil {
+               conn.reply(ReplyBadSequence)
+               return
+       }
+
+       if conn.authc != "" {
+               conn.writeReply(503, "already authenticated")
+               return
+       }
+
+       var cmd, authType, authString string
+       n, err := fmt.Sscanf(conn.line, "%s %s %s", &cmd, &authType, &authString)
+       if n < 2 {
+               conn.reply(ReplyBadSyntax)
+               return
+       }
+
+       if authType != "PLAIN" {
+               conn.writeReply(504, "unrecognized auth type")
+               return
+       }
+
+       // If only 2 tokens were scanned, then an initial response was not provided.
+       if n == 2 && conn.line[len(conn.line)-1] != ' ' {
+               conn.reply(ReplyBadSyntax)
+               return
+       }
+
+       conn.log.Info("doAUTH()")
+
+       if authString == "" {
+               conn.writeReply(334, " ")
+
+               authString, err = conn.tp.ReadLine()
+               if err != nil {
+                       conn.log.Error("failed to read auth line", zap.Error(err))
+                       conn.reply(ReplyBadSyntax)
+                       return
+               }
+       }
+
+       authBytes, err := base64.StdEncoding.DecodeString(authString)
+       if err != nil {
+               conn.reply(ReplyBadSyntax)
+               return
+       }
+
+       authParts := strings.Split(string(authBytes), "\x00")
+       if len(authParts) != 3 {
+               conn.log.Error("bad auth line syntax")
+               conn.reply(ReplyBadSyntax)
+               return
+       }
+
+       if !conn.server.Authenticate(authParts[0], authParts[1], authParts[2]) {
+               conn.log.Error("failed to authenticate", zap.String("authc", authParts[1]))
+               conn.writeReply(535, "invalid credentials")
+               return
+       }
+
+       conn.log.Info("authenticated", zap.String("authz", authParts[0]), zap.String("authc", authParts[1]))
+       conn.authc = authParts[1]
+       conn.reply(ReplyOK)
+}
+
 func (conn *connection) doMAIL() {
        if conn.state != stateInitial {
                conn.reply(ReplyBadSequence)
@@ -229,6 +335,16 @@ func (conn *connection) doMAIL() {
                return
        }
 
+       if conn.server.VerifyAddress(*conn.mailFrom) == ReplyOK {
+               if DomainForAddress(*conn.mailFrom) != DomainForAddressString(conn.authc) {
+                       conn.writeReply(550, "not authenticated")
+                       return
+               }
+               conn.delivery = deliverOutbound
+       } else {
+               conn.delivery = deliverInbound
+       }
+
        conn.log.Info("doMAIL()", zap.String("address", conn.mailFrom.Address))
 
        conn.state = stateMail
@@ -253,7 +369,7 @@ func (conn *connection) doRCPT() {
                return
        }
 
-       if reply := conn.server.VerifyAddress(*address); reply != ReplyOK {
+       if reply := conn.server.VerifyAddress(*address); reply != ReplyOK && conn.delivery == deliverInbound {
                conn.log.Warn("invalid address",
                        zap.String("address", address.Address),
                        zap.Stringer("reply", reply))
@@ -261,7 +377,9 @@ func (conn *connection) doRCPT() {
                return
        }
 
-       conn.log.Info("doRCPT()", zap.String("address", address.Address))
+       conn.log.Info("doRCPT()",
+               zap.String("address", address.Address),
+               zap.String("delivery", conn.delivery.String()))
 
        conn.rcptTo = append(conn.rcptTo, *address)
 
@@ -295,27 +413,103 @@ func (conn *connection) doDATA() {
                RcptTo:     conn.rcptTo,
                Received:   received,
                ID:         conn.envelopeID(received),
+               Data:       data,
        }
 
+       conn.handleSendAs(&env)
+
        conn.log.Info("received message",
                zap.Int("bytes", len(data)),
                zap.Time("date", received),
-               zap.String("id", env.ID))
+               zap.String("id", env.ID),
+               zap.String("delivery", conn.delivery.String()))
 
        trace := conn.getReceivedInfo(env)
 
-       env.Data = append(trace, data...)
+       env.Data = append(trace, env.Data...)
 
-       if reply := conn.server.OnMessageDelivered(env); reply != nil {
-               conn.log.Warn("message was rejected", zap.String("id", env.ID))
-               conn.reply(*reply)
-               return
+       if conn.delivery == deliverInbound {
+               if reply := conn.server.OnMessageDelivered(env); reply != nil {
+                       conn.log.Warn("message was rejected", zap.String("id", env.ID))
+                       conn.reply(*reply)
+                       return
+               }
+       } else if conn.delivery == deliverOutbound {
+               conn.server.RelayMessage(env)
        }
 
        conn.state = stateInitial
+       conn.resetBuffers()
        conn.reply(ReplyOK)
 }
 
+func (conn *connection) handleSendAs(env *Envelope) {
+       if conn.delivery != deliverOutbound {
+               return
+       }
+
+       // Find the separator between the message header and body.
+       headerIdx := bytes.Index(env.Data, []byte("\n\n"))
+       if headerIdx == -1 {
+               conn.log.Error("send-as: could not find headers index")
+               return
+       }
+
+       var buf bytes.Buffer
+
+       headers := bytes.SplitAfter(env.Data[:headerIdx], []byte("\n"))
+
+       var fromIdx, subjectIdx int
+       for i, header := range headers {
+               if bytes.HasPrefix(header, []byte("From:")) {
+                       fromIdx = i
+                       continue
+               }
+               if bytes.HasPrefix(header, []byte("Subject:")) {
+                       subjectIdx = i
+                       continue
+               }
+       }
+
+       if subjectIdx == -1 {
+               conn.log.Error("send-as: could not find Subject header")
+               return
+       }
+       if fromIdx == -1 {
+               conn.log.Error("send-as: could not find From header")
+               return
+       }
+
+       sendAs := SendAsSubject.FindSubmatchIndex(headers[subjectIdx])
+       if sendAs == nil {
+               // No send-as modification.
+               return
+       }
+
+       // Submatch 0 is the whole sendas magic. Submatch 1 is the address prefix.
+       sendAsUser := headers[subjectIdx][sendAs[2]:sendAs[3]]
+       sendAsAddress := string(sendAsUser) + "@" + DomainForAddressString(conn.authc)
+
+       for i, header := range headers {
+               if i == subjectIdx {
+                       buf.Write(header[:sendAs[0]])
+                       buf.Write(header[sendAs[1]:])
+               } else if i == fromIdx {
+                       addressStart := bytes.LastIndexByte(header, byte('<'))
+                       buf.Write(header[:addressStart+1])
+                       buf.WriteString(sendAsAddress)
+                       buf.WriteString(">\n")
+               } else {
+                       buf.Write(header)
+               }
+       }
+
+       buf.Write(env.Data[headerIdx:])
+
+       env.Data = buf.Bytes()
+       env.MailFrom.Address = sendAsAddress
+}
+
 func (conn *connection) envelopeID(t time.Time) string {
        var idBytes [4]byte
        rand.Read(idBytes[:])
@@ -344,7 +538,9 @@ func (conn *connection) getReceivedInfo(envelope Envelope) []byte {
        }
        base += fmt.Sprintf("by %s (mailpopbox) with %s id %s\r\n        ", conn.server.Name(), with, envelope.ID)
 
-       base += fmt.Sprintf("for <%s>\r\n        ", envelope.RcptTo[0].Address)
+       if len(envelope.RcptTo) > 0 {
+               base += fmt.Sprintf("for <%s>\r\n        ", envelope.RcptTo[0].Address)
+       }
 
        transport := conn.getTransportString()
        date := envelope.Received.Format(time.RFC1123Z) // Same as RFC 5322 Â§ 3.3
@@ -412,6 +608,8 @@ func (conn *connection) doRSET() {
 }
 
 func (conn *connection) resetBuffers() {
+       conn.delivery = deliverUnknown
+       conn.sendAs = nil
        conn.mailFrom = nil
        conn.rcptTo = make([]mail.Address, 0)
 }
index 84fda8a12338b2c6f6bbd1adbc9a7ede8e5d6144..e1e1501d8e67a921317de649567c232805a13708 100644 (file)
@@ -7,6 +7,8 @@
 package smtp
 
 import (
+       "crypto/tls"
+       "encoding/base64"
        "fmt"
        "net"
        "net/mail"
@@ -32,9 +34,9 @@ func ok(t testing.TB, err error) {
 }
 
 func readCodeLine(t testing.TB, conn *textproto.Conn, code int) string {
-       _, message, err := conn.ReadCodeLine(code)
+       actual, message, err := conn.ReadCodeLine(code)
        if err != nil {
-               t.Errorf("%s ReadCodeLine error: %v", _fl(1), err)
+               t.Errorf("%s ReadCodeLine error, expected %d, got %d: %v", _fl(1), code, actual, err)
        }
        return message
 }
@@ -61,16 +63,31 @@ func runServer(t *testing.T, server Server) net.Listener {
        return l
 }
 
+type userAuth struct {
+       authz, authc, passwd string
+}
+
 type testServer struct {
        EmptyServerCallbacks
+       domain    string
        blockList []string
+       tlsConfig *tls.Config
+       *userAuth
+       relayed []Envelope
 }
 
 func (s *testServer) Name() string {
        return "Test-Server"
 }
 
+func (s *testServer) TLSConfig() *tls.Config {
+       return s.tlsConfig
+}
+
 func (s *testServer) VerifyAddress(addr mail.Address) ReplyLine {
+       if DomainForAddress(addr) != s.domain {
+               return ReplyBadMailbox
+       }
        for _, block := range s.blockList {
                if strings.ToLower(block) == addr.Address {
                        return ReplyBadMailbox
@@ -79,6 +96,16 @@ func (s *testServer) VerifyAddress(addr mail.Address) ReplyLine {
        return ReplyOK
 }
 
+func (s *testServer) Authenticate(authz, authc, passwd string) bool {
+       return s.userAuth.authz == authz &&
+               s.userAuth.authc == authc &&
+               s.userAuth.passwd == passwd
+}
+
+func (s *testServer) RelayMessage(en Envelope) {
+       s.relayed = append(s.relayed, en)
+}
+
 func createClient(t *testing.T, addr net.Addr) *textproto.Conn {
        conn, err := textproto.Dial(addr.Network(), addr.String())
        if err != nil {
@@ -96,19 +123,22 @@ type requestResponse struct {
 
 func runTableTest(t testing.TB, conn *textproto.Conn, seq []requestResponse) {
        for i, rr := range seq {
-               t.Logf("%s case %d", _fl(1), i)
                ok(t, conn.PrintfLine(rr.request))
                if rr.handler != nil {
                        rr.handler(t, conn)
                } else {
                        readCodeLine(t, conn, rr.responseCode)
                }
+               if t.Failed() {
+                       t.Logf("%s case %d", _fl(1), i)
+               }
        }
 }
 
 // RFC 5321 Â§ D.1
 func TestScenarioTypical(t *testing.T) {
        s := testServer{
+               domain:    "foo.com",
                blockList: []string{"Green@foo.com"},
        }
        l := runServer(t, &s)
@@ -156,6 +186,7 @@ func TestScenarioTypical(t *testing.T) {
 
 func TestVerifyAddress(t *testing.T) {
        s := testServer{
+               domain:    "test.mail",
                blockList: []string{"banned@test.mail"},
        }
        l := runServer(t, &s)
@@ -191,8 +222,10 @@ func TestBadAddress(t *testing.T) {
 }
 
 func TestCaseSensitivty(t *testing.T) {
-       s := &testServer{}
-       s.blockList = []string{"reject@mail.com"}
+       s := &testServer{
+               domain:    "mail.com",
+               blockList: []string{"reject@mail.com"},
+       }
        l := runServer(t, s)
        defer l.Close()
 
@@ -281,3 +314,297 @@ func TestGetReceivedInfo(t *testing.T) {
        }
 
 }
+
+func getTLSConfig(t *testing.T) *tls.Config {
+       cert, err := tls.LoadX509KeyPair("../testtls/domain.crt", "../testtls/domain.key")
+       if err != nil {
+               t.Fatal(err)
+               return nil
+       }
+       return &tls.Config{
+               ServerName:         "localhost",
+               Certificates:       []tls.Certificate{cert},
+               InsecureSkipVerify: true,
+       }
+}
+
+func setupTLSClient(t *testing.T, addr net.Addr) *textproto.Conn {
+       nc, err := net.Dial(addr.Network(), addr.String())
+       ok(t, err)
+
+       conn := textproto.NewConn(nc)
+       readCodeLine(t, conn, 220)
+
+       ok(t, conn.PrintfLine("EHLO test-tls"))
+       _, resp, err := conn.ReadResponse(250)
+       ok(t, err)
+       if !strings.Contains(resp, "STARTTLS\n") {
+               t.Errorf("STARTTLS not advertised")
+       }
+
+       ok(t, conn.PrintfLine("STARTTLS"))
+       readCodeLine(t, conn, 220)
+
+       tc := tls.Client(nc, getTLSConfig(t))
+       err = tc.Handshake()
+       ok(t, err)
+
+       conn = textproto.NewConn(tc)
+
+       ok(t, conn.PrintfLine("EHLO test-tls-started"))
+       _, resp, err = conn.ReadResponse(250)
+       ok(t, err)
+       if strings.Contains(resp, "STARTTLS\n") {
+               t.Errorf("STARTTLS advertised when already started")
+       }
+
+       return conn
+}
+
+func b64enc(s string) string {
+       return string(base64.StdEncoding.EncodeToString([]byte(s)))
+}
+
+func TestTLS(t *testing.T) {
+       l := runServer(t, &testServer{tlsConfig: getTLSConfig(t)})
+       defer l.Close()
+
+       setupTLSClient(t, l.Addr())
+}
+
+func TestAuthWithoutTLS(t *testing.T) {
+       l := runServer(t, &testServer{})
+       defer l.Close()
+
+       conn := createClient(t, l.Addr())
+       readCodeLine(t, conn, 220)
+
+       ok(t, conn.PrintfLine("EHLO test"))
+       _, resp, err := conn.ReadResponse(250)
+       ok(t, err)
+
+       if strings.Contains(resp, "AUTH") {
+               t.Errorf("AUTH should not be advertised over plaintext")
+       }
+}
+
+func TestAuth(t *testing.T) {
+       l := runServer(t, &testServer{
+               tlsConfig: getTLSConfig(t),
+               userAuth: &userAuth{
+                       authz:  "-authz-",
+                       authc:  "-authc-",
+                       passwd: "goats",
+               },
+       })
+       defer l.Close()
+
+       conn := setupTLSClient(t, l.Addr())
+
+       runTableTest(t, conn, []requestResponse{
+               {"AUTH", 501, nil},
+               {"AUTH OAUTHBEARER", 504, nil},
+               {"AUTH PLAIN", 501, nil}, // Bad syntax, missing space.
+               {"AUTH PLAIN ", 334, nil},
+               {b64enc("abc\x00def\x00ghf"), 535, nil},
+               {"AUTH PLAIN ", 334, nil},
+               {b64enc("\x00"), 501, nil},
+               {"AUTH PLAIN ", 334, nil},
+               {"this isn't base 64", 501, nil},
+               {"AUTH PLAIN ", 334, nil},
+               {b64enc("-authz-\x00-authc-\x00goats"), 250, nil},
+               {"AUTH PLAIN ", 503, nil}, // Already authenticated.
+               {"NOOP", 250, nil},
+       })
+}
+
+func TestAuthNoInitialResponse(t *testing.T) {
+       l := runServer(t, &testServer{
+               tlsConfig: getTLSConfig(t),
+               userAuth: &userAuth{
+                       authz:  "",
+                       authc:  "user",
+                       passwd: "longpassword",
+               },
+       })
+       defer l.Close()
+
+       conn := setupTLSClient(t, l.Addr())
+
+       runTableTest(t, conn, []requestResponse{
+               {"AUTH PLAIN " + b64enc("\x00user\x00longpassword"), 250, nil},
+       })
+}
+
+func TestRelayRequiresAuth(t *testing.T) {
+       l := runServer(t, &testServer{
+               domain:    "example.com",
+               tlsConfig: getTLSConfig(t),
+               userAuth: &userAuth{
+                       authz:  "",
+                       authc:  "mailbox@example.com",
+                       passwd: "test",
+               },
+       })
+       defer l.Close()
+
+       conn := setupTLSClient(t, l.Addr())
+
+       runTableTest(t, conn, []requestResponse{
+               {"MAIL FROM:<apples@example.com>", 550, nil},
+               {"MAIL FROM:<mailbox@example.com>", 550, nil},
+               {"AUTH PLAIN ", 334, nil},
+               {b64enc("\x00mailbox@example.com\x00test"), 250, nil},
+               {"MAIL FROM:<mailbox@example.com>", 250, nil},
+       })
+}
+
+func setupRelayTest(t *testing.T) (server *testServer, l net.Listener, conn *textproto.Conn) {
+       server = &testServer{
+               domain:    "example.com",
+               tlsConfig: getTLSConfig(t),
+               userAuth: &userAuth{
+                       authz:  "",
+                       authc:  "mailbox@example.com",
+                       passwd: "test",
+               },
+       }
+       l = runServer(t, server)
+       conn = setupTLSClient(t, l.Addr())
+       runTableTest(t, conn, []requestResponse{
+               {"AUTH PLAIN ", 334, nil},
+               {b64enc("\x00mailbox@example.com\x00test"), 250, nil},
+       })
+       return
+}
+
+func TestBasicRelay(t *testing.T) {
+       server, l, conn := setupRelayTest(t)
+       defer l.Close()
+
+       runTableTest(t, conn, []requestResponse{
+               {"MAIL FROM:<mailbox@example.com>", 250, nil},
+               {"RCPT TO:<dest@another.net>", 250, nil},
+               {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
+                       readCodeLine(t, conn, 354)
+
+                       ok(t, conn.PrintfLine("From: <mailbox@example.com>"))
+                       ok(t, conn.PrintfLine("To: <dest@example.com>"))
+                       ok(t, conn.PrintfLine("Subject: Basic relay\n"))
+                       ok(t, conn.PrintfLine("This is a basic relay message"))
+                       ok(t, conn.PrintfLine("."))
+                       readCodeLine(t, conn, 250)
+               }},
+       })
+
+       if len(server.relayed) != 1 {
+               t.Errorf("Expected 1 relayed message, got %d", len(server.relayed))
+       }
+}
+
+func TestSendAsRelay(t *testing.T) {
+       server, l, conn := setupRelayTest(t)
+       defer l.Close()
+
+       runTableTest(t, conn, []requestResponse{
+               {"MAIL FROM:<mailbox@example.com>", 250, nil},
+               {"RCPT TO:<valid@dest.xyz>", 250, nil},
+               {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
+                       readCodeLine(t, conn, 354)
+
+                       ok(t, conn.PrintfLine("From: <mailbox@example.com>"))
+                       ok(t, conn.PrintfLine("To: <valid@dest.xyz>"))
+                       ok(t, conn.PrintfLine("Subject: Send-as relay [sendas:source]\n"))
+                       ok(t, conn.PrintfLine("We've switched the senders!"))
+                       ok(t, conn.PrintfLine("."))
+                       readCodeLine(t, conn, 250)
+               }},
+       })
+
+       if len(server.relayed) != 1 {
+               t.Fatalf("Expected 1 relayed message, got %d", len(server.relayed))
+       }
+
+       replaced := "source@example.com"
+       original := "mailbox@example.com"
+
+       en := server.relayed[0]
+       if en.MailFrom.Address != replaced {
+               t.Errorf("Expected mail to be from %q, got %q", replaced, en.MailFrom.Address)
+       }
+
+       if len(en.RcptTo) != 1 {
+               t.Errorf("Expected 1 recipient, got %d", len(en.RcptTo))
+       }
+       if en.RcptTo[0].Address != "valid@dest.xyz" {
+               t.Errorf("Unexpected RcptTo %q", en.RcptTo[0].Address)
+       }
+
+       msg := string(en.Data)
+
+       if strings.Index(msg, original) != -1 {
+               t.Errorf("Should not find %q in message %q", original, msg)
+       }
+
+       if strings.Index(msg, "\nFrom: <source@example.com>\n") == -1 {
+               t.Errorf("Could not find From: header in message %q", msg)
+       }
+
+       if strings.Index(msg, "\nSubject: Send-as relay \n") == -1 {
+               t.Errorf("Could not find modified Subject: header in message %q", msg)
+       }
+}
+
+func TestSendMultipleRelay(t *testing.T) {
+       server, l, conn := setupRelayTest(t)
+       defer l.Close()
+
+       runTableTest(t, conn, []requestResponse{
+               {"MAIL FROM:<mailbox@example.com>", 250, nil},
+               {"RCPT TO:<valid@dest.xyz>", 250, nil},
+               {"RCPT TO:<another@dest.org>", 250, nil},
+               {"DATA", 354, func(t testing.TB, conn *textproto.Conn) {
+                       readCodeLine(t, conn, 354)
+
+                       ok(t, conn.PrintfLine("To: Cindy <valid@dest.xyz>, Sam <another@dest.org>"))
+                       ok(t, conn.PrintfLine("From: Finn <mailbox@example.com>"))
+                       ok(t, conn.PrintfLine("Subject: Two destinations [sendas:source]\n"))
+                       ok(t, conn.PrintfLine("And we've switched the senders!"))
+                       ok(t, conn.PrintfLine("."))
+                       readCodeLine(t, conn, 250)
+               }},
+       })
+
+       if len(server.relayed) != 1 {
+               t.Fatalf("Expected 1 relayed message, got %d", len(server.relayed))
+       }
+
+       replaced := "source@example.com"
+       original := "mailbox@example.com"
+
+       en := server.relayed[0]
+       if en.MailFrom.Address != replaced {
+               t.Errorf("Expected mail to be from %q, got %q", replaced, en.MailFrom.Address)
+       }
+
+       if len(en.RcptTo) != 2 {
+               t.Errorf("Expected 2 recipient, got %d", len(en.RcptTo))
+       }
+       if en.RcptTo[0].Address != "valid@dest.xyz" {
+               t.Errorf("Unexpected RcptTo %q", en.RcptTo[0].Address)
+       }
+
+       msg := string(en.Data)
+
+       if strings.Index(msg, original) != -1 {
+               t.Errorf("Should not find %q in message %q", original, msg)
+       }
+
+       if strings.Index(msg, "\nFrom: Finn <source@example.com>\n") == -1 {
+               t.Errorf("Could not find From: header in message %q", msg)
+       }
+
+       if strings.Index(msg, "\nSubject: Two destinations \n") == -1 {
+               t.Errorf("Could not find modified Subject: header in message %q", msg)
+       }
+}
diff --git a/smtp/relay.go b/smtp/relay.go
new file mode 100644 (file)
index 0000000..f709ce1
--- /dev/null
@@ -0,0 +1,101 @@
+// mailpopbox
+// Copyright 2020 Blue Static <https://www.bluestatic.org>
+// This program is free software licensed under the GNU General Public License,
+// version 3.0. The full text of the license can be found in LICENSE.txt.
+// SPDX-License-Identifier: GPL-3.0-only
+
+package smtp
+
+import (
+       "crypto/tls"
+       "net"
+       "net/smtp"
+
+       "github.com/uber-go/zap"
+)
+
+func RelayMessage(server Server, env Envelope, log zap.Logger) {
+       for _, rcptTo := range env.RcptTo {
+               sendLog := log.With(zap.String("address", rcptTo.Address))
+
+               domain := DomainForAddress(rcptTo)
+               mx, err := net.LookupMX(domain)
+               if err != nil || len(mx) < 1 {
+                       sendLog.Error("failed to lookup MX records",
+                               zap.Error(err))
+                       deliverRelayFailure(env, err)
+                       return
+               }
+               host := mx[0].Host + ":25"
+               relayMessageToHost(server, env, sendLog, rcptTo.Address, host)
+       }
+}
+
+func relayMessageToHost(server Server, env Envelope, log zap.Logger, to, host string) {
+       from := env.MailFrom.Address
+
+       c, err := smtp.Dial(host)
+       if err != nil {
+               // TODO - retry, or look at other MX records
+               log.Error("failed to dial host",
+                       zap.String("host", host),
+                       zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+       defer c.Quit()
+
+       log = log.With(zap.String("host", host))
+
+       if err = c.Hello(server.Name()); err != nil {
+               log.Error("failed to HELO", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+
+       if hasTls, _ := c.Extension("STARTTLS"); hasTls {
+               config := &tls.Config{ServerName: host}
+               if err = c.StartTLS(config); err != nil {
+                       log.Error("failed to STARTTLS", zap.Error(err))
+                       deliverRelayFailure(env, err)
+                       return
+               }
+       }
+
+       if err = c.Mail(from); err != nil {
+               log.Error("failed MAIL FROM", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+
+       if err = c.Rcpt(to); err != nil {
+               log.Error("failed to RCPT TO", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+
+       wc, err := c.Data()
+       if err != nil {
+               log.Error("failed to DATA", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+
+       _, err = wc.Write(env.Data)
+       if err != nil {
+               wc.Close()
+               log.Error("failed to write DATA", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+
+       if err = wc.Close(); err != nil {
+               log.Error("failed to close DATA", zap.Error(err))
+               deliverRelayFailure(env, err)
+               return
+       }
+}
+
+func deliverRelayFailure(env Envelope, err error) {
+       // TODO: constructo a delivery status notification
+}
diff --git a/smtp/relay_test.go b/smtp/relay_test.go
new file mode 100644 (file)
index 0000000..bfd3724
--- /dev/null
@@ -0,0 +1,64 @@
+// mailpopbox
+// Copyright 2020 Blue Static <https://www.bluestatic.org>
+// This program is free software licensed under the GNU General Public License,
+// version 3.0. The full text of the license can be found in LICENSE.txt.
+// SPDX-License-Identifier: GPL-3.0-only
+
+package smtp
+
+import (
+       "bytes"
+       "net/mail"
+       "testing"
+
+       "github.com/uber-go/zap"
+)
+
+type deliveryServer struct {
+       testServer
+       messages []Envelope
+}
+
+func (s *deliveryServer) OnMessageDelivered(env Envelope) *ReplyLine {
+       s.messages = append(s.messages, env)
+       return nil
+}
+
+func TestRelayRoundTrip(t *testing.T) {
+       s := &deliveryServer{
+               testServer: testServer{domain: "receive.net"},
+       }
+       l := runServer(t, s)
+       defer l.Close()
+
+       env := Envelope{
+               MailFrom: mail.Address{Address: "from@sender.org"},
+               RcptTo:   []mail.Address{{Address: "to@receive.net"}},
+               Data:     []byte("~~~Message~~~\n"),
+               ID:       "ididid",
+       }
+
+       relayMessageToHost(s, env, zap.New(zap.NullEncoder()), env.RcptTo[0].Address, l.Addr().String())
+
+       if len(s.messages) != 1 {
+               t.Errorf("Expected 1 message to be delivered, got %d", len(s.messages))
+               return
+       }
+
+       received := s.messages[0]
+
+       if env.MailFrom.Address != received.MailFrom.Address {
+               t.Errorf("Expected MailFrom %s, got %s", env.MailFrom.Address, received.MailFrom.Address)
+       }
+       if len(received.RcptTo) != 1 {
+               t.Errorf("Expected 1 RcptTo, got %d", len(received.RcptTo))
+               return
+       }
+       if env.RcptTo[0].Address != received.RcptTo[0].Address {
+               t.Errorf("Expected RcptTo %s, got %s", env.RcptTo[0].Address, received.RcptTo[0].Address)
+       }
+
+       if !bytes.HasSuffix(received.Data, env.Data) {
+               t.Errorf("Delivered message does not match relayed one. Delivered=%q Relayed=%q", string(env.Data), string(received.Data))
+       }
+}
index 547fb33df38f197744e46fafe6acc730df45dde2..14002c5be2ba28793fa719edda77b58a47f5bfc4 100644 (file)
@@ -8,10 +8,12 @@ package smtp
 
 import (
        "crypto/tls"
+       "regexp"
        "fmt"
        "io"
        "net"
        "net/mail"
+       "strings"
        "time"
 )
 
@@ -24,13 +26,28 @@ func (l ReplyLine) String() string {
        return fmt.Sprintf("%d %s", l.Code, l.Message)
 }
 
+var SendAsSubject = regexp.MustCompile(`(?i)\[sendas:\s*([a-zA-Z0-9\.\-_]+)\]`)
+
 var (
-       ReplyOK          = ReplyLine{250, "OK"}
-       ReplyBadSyntax   = ReplyLine{501, "syntax error"}
-       ReplyBadSequence = ReplyLine{503, "bad sequence of commands"}
-       ReplyBadMailbox  = ReplyLine{550, "mailbox unavailable"}
+       ReplyOK               = ReplyLine{250, "OK"}
+       ReplyBadSyntax        = ReplyLine{501, "syntax error"}
+       ReplyBadSequence      = ReplyLine{503, "bad sequence of commands"}
+       ReplyBadMailbox       = ReplyLine{550, "mailbox unavailable"}
+       ReplyMailboxUnallowed = ReplyLine{553, "mailbox name not allowed"}
 )
 
+func DomainForAddress(addr mail.Address) string {
+       return DomainForAddressString(addr.Address)
+}
+
+func DomainForAddressString(address string) string {
+       domainIdx := strings.LastIndex(address, "@")
+       if domainIdx == -1 {
+               return ""
+       }
+       return address[domainIdx+1:]
+}
+
 type Envelope struct {
        RemoteAddr net.Addr
        EHLO       string
@@ -50,9 +67,14 @@ func WriteEnvelopeForDelivery(w io.Writer, e Envelope) {
 type Server interface {
        Name() string
        TLSConfig() *tls.Config
-       OnEHLO() *ReplyLine
        VerifyAddress(mail.Address) ReplyLine
+       // Verify that the authc+passwd identity can send mail as authz.
+       Authenticate(authz, authc, passwd string) bool
        OnMessageDelivered(Envelope) *ReplyLine
+
+       // RelayMessage instructs the server to send the Envelope to another
+       // MTA for outbound delivery.
+       RelayMessage(Envelope)
 }
 
 type EmptyServerCallbacks struct{}
@@ -61,14 +83,17 @@ func (*EmptyServerCallbacks) TLSConfig() *tls.Config {
        return nil
 }
 
-func (*EmptyServerCallbacks) OnEHLO() *ReplyLine {
-       return nil
-}
-
 func (*EmptyServerCallbacks) VerifyAddress(mail.Address) ReplyLine {
        return ReplyOK
 }
 
+func (*EmptyServerCallbacks) Authenticate(authz, authc, passwd string) bool {
+       return false
+}
+
 func (*EmptyServerCallbacks) OnMessageDelivered(Envelope) *ReplyLine {
        return nil
 }
+
+func (*EmptyServerCallbacks) RelayMessage(Envelope) {
+}
diff --git a/smtp/server_test.go b/smtp/server_test.go
new file mode 100644 (file)
index 0000000..09cb306
--- /dev/null
@@ -0,0 +1,22 @@
+package smtp
+
+import (
+       "net/mail"
+       "testing"
+)
+
+func TestDomainForAddress(t *testing.T) {
+       cases := []struct{
+               address, domain string
+       }{
+               {"foo@bar.com", "bar.com"},
+               {"abc", ""},
+               {"abc@one.two.three.four.net", "one.two.three.four.net"},
+       }
+       for i, c := range cases {
+               actual := DomainForAddress(mail.Address{Address: c.address})
+               if actual != c.domain {
+                       t.Errorf("case %d, got %q, expected %q", i, actual, c.domain)
+               }
+       }
+}
diff --git a/smtp_test.go b/smtp_test.go
new file mode 100644 (file)
index 0000000..2eef868
--- /dev/null
@@ -0,0 +1,43 @@
+package main
+
+import (
+       "testing"
+)
+
+var testConfig = Config{
+       Servers: []Server{
+               Server{
+                       Domain:          "domain1.net",
+                       MailboxPassword: "d1",
+               },
+               Server{
+                       Domain:          "domain2.xyz",
+                       MailboxPassword: "d2",
+               },
+       },
+}
+
+func TestAuthenticate(t *testing.T) {
+       server := smtpServer{config: testConfig}
+
+       authTests := []struct {
+               authz, authc, passwd string
+               ok                   bool
+       }{
+               {"foo@domain1.net", "mailbox@domain1.net", "d1", true},
+               {"", "mailbox@domain1.net", "d1", true},
+               {"foo@domain2.xyz", "mailbox@domain1.xyz", "d1", false},
+               {"foo@domain2.xyz", "mailbox@domain1.xyz", "d2", false},
+               {"foo@domain2.xyz", "mailbox@domain2.xyz", "d2", true},
+               {"invalid", "mailbox@domain2.xyz", "d2", false},
+               {"", "mailbox@domain2.xyz", "d2", true},
+               {"", "", "", false},
+       }
+
+       for i, test := range authTests {
+               actual := server.Authenticate(test.authz, test.authc, test.passwd)
+               if actual != test.ok {
+                       t.Errorf("Test %d, got %v, expected %v", i, actual, test.ok)
+               }
+       }
+}
diff --git a/testtls/domain.crt b/testtls/domain.crt
new file mode 100644 (file)
index 0000000..e0d10bb
--- /dev/null
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID0DCCArigAwIBAgIJAKKsGKE6sUkAMA0GCSqGSIb3DQEBBQUAME4xCzAJBgNV
+BAYTAlVTMREwDwYDVQQIEwhOZXcgWW9yazEYMBYGA1UEChMPVEVTVCBNQUlMUE9Q
+Qk9YMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMTgwOTA2MDAzOTQxWhcNMTgxMDA2
+MDAzOTQxWjBOMQswCQYDVQQGEwJVUzERMA8GA1UECBMITmV3IFlvcmsxGDAWBgNV
+BAoTD1RFU1QgTUFJTFBPUEJPWDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkq
+hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqRckvnfo5MdbIWZHLP+g1wDrBLC3LS3M
+9JFumGXXe19Ooy9a+WvBK3tnFfEhpMIKJ3iphgPlf3fU8W96kuE3o6rPRUzpv6FU
+z+w1FTBiOd1FfUmKwULdjmMFFdl2nYzjp4le9yfaUDNdu8bj9Wk2tL6sBaO6gkHr
+thJiPPtCv19YxGPG5FZIDnlDwLkwuaUxQCTgZ7BZ0qM036gmXJ6gYd1Scgz5iSHC
+Pfj9v1dEOPowA9MRrDa9sQdjrFCq2vgdsdsVdVfoWhHeQfd6PCFUTy06xDHfGnor
+/+wAklbnIVCnVbyQeQHpPkQjlzcVMZkHnxwfG4CJ/WgF+inzQ0hNTQIDAQABo4Gw
+MIGtMB0GA1UdDgQWBBSqQHNqsbIav6Q0SYwla379Q19YsjB+BgNVHSMEdzB1gBSq
+QHNqsbIav6Q0SYwla379Q19YsqFSpFAwTjELMAkGA1UEBhMCVVMxETAPBgNVBAgT
+CE5ldyBZb3JrMRgwFgYDVQQKEw9URVNUIE1BSUxQT1BCT1gxEjAQBgNVBAMTCWxv
+Y2FsaG9zdIIJAKKsGKE6sUkAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD
+ggEBAJeT2v5N+i15zlEeOVSsuvV/aGe068K5e+FE1qOnkmBsvqN2/BmWT5oeJuNc
+AT0ZyKI1vR5/Enq1Fta5xSNWLVeFQwm4DYEFxJ21SGWnY6+hl213nr7x0pmLG3tT
+YFq9/A+4iG5LQuDroQOC2TAqZFMTpdgLQgFZYKJvI4rk6/QrcI2pdMdwpAIJgMgd
+USV/MHoj30ll17cAukHnaZGKxkkZu0BKhadOEkB7p4zSZsVFWJfGsi5Pf3+LFmyE
+dkxewA5SVD7KwVkZ+wSkxEBuaIYAArbuxc9aT2u4H3+fxVrd2UnuDeq0nv0lMZN/
+eAHjIwvvWme+6AxRhIA5z5eTpcA=
+-----END CERTIFICATE-----
diff --git a/testtls/domain.key b/testtls/domain.key
new file mode 100644 (file)
index 0000000..dc43434
--- /dev/null
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAqRckvnfo5MdbIWZHLP+g1wDrBLC3LS3M9JFumGXXe19Ooy9a
++WvBK3tnFfEhpMIKJ3iphgPlf3fU8W96kuE3o6rPRUzpv6FUz+w1FTBiOd1FfUmK
+wULdjmMFFdl2nYzjp4le9yfaUDNdu8bj9Wk2tL6sBaO6gkHrthJiPPtCv19YxGPG
+5FZIDnlDwLkwuaUxQCTgZ7BZ0qM036gmXJ6gYd1Scgz5iSHCPfj9v1dEOPowA9MR
+rDa9sQdjrFCq2vgdsdsVdVfoWhHeQfd6PCFUTy06xDHfGnor/+wAklbnIVCnVbyQ
+eQHpPkQjlzcVMZkHnxwfG4CJ/WgF+inzQ0hNTQIDAQABAoIBAGmTocWraScvsp7w
+FZDrK6oTUKrlC/qRll8+TyeorxrBL4CEmPETbtGPg5YXsUIGRgDPPkoHNMyaLcNy
+L752ER+ID1Ld6zVTrnkEq0BHrY0js7e+q3xwG5ZEDXDPD1jgF2UMSNdZct6Qs/4C
++WLKBvZj91SuHk4mit5sLBqXZ93EzvmK6mb2OSwrtla2500Pf1CsY9VAT75z0kBi
+cLV+WYrA1Q84eNqFm4gW1wjfz9xs8+u+jRTgtI0X4HkXpDBzliIecxR8RmjPG0ET
+xykW4gfnVZoTPI1iRnCdaXhzP2oyx96/i1BUWoNQmQjki0TzOY56XgiLRpWnrD5b
+XaoHrqECgYEA0s6STz8qe5Cz96J3B4nm4yZcvsfTPm9R/L5k9dg9bPed8KcqPuT8
+KSA/1JWk8Z4XcHdO3DilvHVYgENEBBNEPKfjL5Dz9r62EEn4Xjt2pHjgSkDAEKtr
+BRKXhdTWCoT0fpx8I6yCPzCDV6IA4yfT2J7RZeuoK5hYXVaE5I1oGUcCgYEAzVcZ
+9qhR/DkStQ41wtyepZXHmkgzwqi2drb6tj+EsQfi/jHm1SX7uCkrbYgLwWMARmBO
+zS2cqEKP7Fzv1yBw0ooDxvGOqtsi7HKFsHeYWXXZCrrxiinMg1lpVjfLFchW10jY
+deiUBDGeYsu8gCe1ekOVRJrXbFpC8mxxe2ICrssCgYEAjHC8fnkZh1qe2vJslCQm
+Itxy21LrA+RL3bLGNhbKzWal3Saw+Ve6OnfWrnzHd4SYHwANFJ/UopoWzNSDYqen
+RTWgIBdUwOTLDE0LX1QENYyl+DHtAu4AjU+WjL1/n2B2NkdwWJ/b4dcjGWW/a5Yk
+B2O/I0R9NBX5gK1cOZuPZ48CgYAoVJs83wJ7T5plBU154GsoiqqRmuzPpuNvnbDQ
+atldC/eBhbuY0cUG/s8QzE/Cw/ch23iew/6o7anm+roAvtZqA8GKKZej5zaMylGH
+v3Wk3IismtsmD9+jTMRrsrmopZio4B3jyrKHwFcjgHCdmy8BvJRszRzSo0fS5YnE
+ehOc0QKBgQCGhYKMbhGNobsXnjxKQDRlrKxvWXSHtx6wgcyAPdb1iqaFe+jIEgvx
+6Vd63Jin2/kruSr8m9VSltkdXmcbLKRNff55J8dyH322Z09R8WrVkCtw8nWIWvgJ
+EVR2rG4y0S2YhzhO8bOlybZS0egg93RSye0Xd3pPyLFgAXmixgqmgw==
+-----END RSA PRIVATE KEY-----