...
 
Commits (17)
......@@ -19,6 +19,7 @@ cmd/smtp-check/smtp-check
cmd/spf-check/spf-check
cmd/mda-lmtp/mda-lmtp
cmd/dovecot-auth-cli/dovecot-auth-cli
test/util/minidns
# Test binary, generated during coverage tests.
chasquid.test
......
......@@ -31,4 +31,5 @@ nav:
- monitoring.md
- sec-levels.md
- tests.md
- upgrading.md
......@@ -6,9 +6,8 @@ dist: trusty
sudo: false
go:
# Normally we'd want to support the version in Debian stable, but
# golang.org/x/crypto requires math/bits which appeared in 1.9.
- 1.9
# Check against the version in Debian stable.
- 1.11
- stable
- master
......
......@@ -35,6 +35,8 @@ notes](upgrading.md).*
List of exported variables:
- **chasquid/aliases/hookResults** (hook result -> counter): count of aliases
hook results, by hook and result.
- **chasquid/queue/deliverAttempts** (recipient type -> counter): attempts to
deliver mail, by recipient type (pipe/local email/remote email).
- **chasquid/queue/dsnQueued** (counter): count of DSNs that we generated
......@@ -48,7 +50,7 @@ List of exported variables:
- **chasquid/smtpIn/hookResults** (result -> counter): count of hook
invocations, by result.
- **chasquid/smtpIn/loopsDetected** (counter): count of email loops detected.
- **chasquid/smtpIn/responseCodeCount** (result -> counter): count of response
- **chasquid/smtpIn/responseCodeCount** (code -> counter): count of response
codes returned to incoming SMTP connections, by result code.
- **chasquid/smtpIn/securityLevelChecks** (result -> counter): count of
security level checks on incoming connections, by result.
......@@ -90,3 +92,114 @@ List of exported variables:
errors as part of keeping the STS cache.
- **chasquid/version** (string): version string.
## Prometheus
To monitor chasquid using [Prometheus](https://prometheus.io), you can use the
[prometheus-expvar-exporter](https://blitiri.com.ar/git/r/prometheus-expvar-exporter/b/master/t/f=README.md.html)
with the following configuration:
```toml
# Address to listen on. Prometheus should be told to scrape this.
listen_addr = ":8000"
[chasquid]
# Replace with the address of chasquid's monitoring server.
url = "http://localhost:1099/debug/vars"
# Metrics are auto-imported, but some can't be; in particular the ones with
# labels need explicit definitions here.
m.aliases_hook_results.expvar ="chasquid/aliases/hookResults"
m.aliases_hook_results.help ="aliases hook results"
m.aliases_hook_results.label_name ="result"
m.deliver_attempts.expvar = "chasquid/queue/deliverAttempts"
m.deliver_attempts.help = "attempts to deliver mail"
m.deliver_attempts.label_name = "recipient_type"
m.dsn_queued.expvar = "chasquid/queue/dsnQueued"
m.dsn_queued.help = "DSN queued"
m.items_written.expvar = "chasquid/queue/itemsWritten"
m.items_written.help = "items written"
m.queue_puts.expvar = "chasquid/queue/putCount"
m.queue_puts.help = "chasquid/queue/putCount"
m.smtpin_commands.expvar = "chasquid/smtpIn/commandCount"
m.smtpin_commands.help = "incoming SMTP command count"
m.smtpin_commands.label_name = "command"
m.smtp_hook_results.expvar = "chasquid/smtpIn/hookResults"
m.smtp_hook_results.help = "hook invocation results"
m.smtp_hook_results.label_name = "result"
m.loops_detected.expvar = "chasquid/smtpIn/loopsDetected"
m.loops_detected.help = "loops detected"
m.smtp_response_codes.expvar = "chasquid/smtpIn/responseCodeCount"
m.smtp_response_codes.help = "response codes returned to SMTP commands"
m.smtp_response_codes.label_name = "code"
m.in_sec_level_checks.expvar = "chasquid/smtpIn/securityLevelChecks"
m.in_sec_level_checks.help = "incoming security level check results"
m.in_sec_level_checks.label_name = "result"
m.spf_results.expvar = "chasquid/smtpIn/spfResultCount"
m.spf_results.help = "SPF result count"
m.spf_results.label_name = "result"
m.in_tls_usage.expvar = "chasquid/smtpIn/tlsCount"
m.in_tls_usage.help = "count of TLS usage in incoming connections"
m.in_tls_usage.label_name = "status"
m.out_sec_level_checks.expvar = "chasquid/smtpOut/securityLevelChecks"
m.out_sec_level_checks.help = "outgoing security level check results"
m.out_sec_level_checks.label_name = "result"
m.sts_modes.expvar = "chasquid/smtpOut/sts/mode"
m.sts_modes.help = "STS checks on outgoing connections, by mode"
m.sts_modes.label_name = "mode"
m.sts_security.expvar = "chasquid/smtpOut/sts/security"
m.sts_security.help = "STS security checks on outgoing connections, by result"
m.sts_security.label_name = "result"
m.out_tls_usage.expvar = "chasquid/smtpOut/tlsCount"
m.out_tls_usage.help = "count of TLS usage in outgoing connections"
m.out_tls_usage.label_name = "status"
m.sts_cache_expired.expvar = "chasquid/sts/cache/expired"
m.sts_cache_expired.help = "expired entries in the STS cache"
m.sts_cache_failed_fetch.expvar = "chasquid/sts/cache/failedFetch"
m.sts_cache_failed_fetch.help = "failed fetches in the STS cache"
m.sts_cache_fetches.expvar = "chasquid/sts/cache/fetches"
m.sts_cache_fetches.help = "total fetches in the STS cache"
m.sts_cache_hits.expvar = "chasquid/sts/cache/hits"
m.sts_cache_hits.help = "hits in the STS cache"
m.sts_cache_invalid.expvar = "chasquid/sts/cache/invalid"
m.sts_cache_invalid.help = "invalid policies in the STS cache"
m.sts_cache_io_errors.expvar = "chasquid/sts/cache/ioErrors"
m.sts_cache_io_errors.help = "I/O errors when maintaining STS cache"
m.sts_cache_marshal_errors.expvar = "chasquid/sts/cache/marshalErrors"
m.sts_cache_marshal_errors.help = "marshalling errors when maintaining STS cache"
m.sts_cache_refresh_cycles.expvar = "chasquid/sts/cache/refreshCycles"
m.sts_cache_refresh_cycles.help = "STS cache refresh cycles"
m.sts_cache_refresh_errors.expvar = "chasquid/sts/cache/refreshErrors"
m.sts_cache_refresh_errors.help = "STS cache refresh errors"
m.sts_cache_refreshes.expvar = "chasquid/sts/cache/refreshes"
m.sts_cache_refreshes.help = "count of STS cache refreshes"
m.sts_cache_unmarshal_errors.expvar = "chasquid/sts/cache/unmarshalErrors"
m.sts_cache_unmarshal_errors.help = "unmarshalling errors in STS cache"
```
......@@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"strings"
"sync"
"testing"
"time"
......@@ -13,46 +12,16 @@ import (
"blitiri.com.ar/go/chasquid/internal/testlib"
)
type deliverRequest struct {
from string
to string
data []byte
}
// Courier for test purposes. Never fails, and always remembers everything.
type TestCourier struct {
wg sync.WaitGroup
requests []*deliverRequest
reqFor map[string]*deliverRequest
sync.Mutex
}
func (tc *TestCourier) Deliver(from string, to string, data []byte) (error, bool) {
defer tc.wg.Done()
dr := &deliverRequest{from, to, data}
tc.Lock()
tc.requests = append(tc.requests, dr)
tc.reqFor[to] = dr
tc.Unlock()
return nil, false
}
func newTestCourier() *TestCourier {
return &TestCourier{
reqFor: map[string]*deliverRequest{},
}
}
func TestBasic(t *testing.T) {
dir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, dir)
localC := newTestCourier()
remoteC := newTestCourier()
localC := testlib.NewTestCourier()
remoteC := testlib.NewTestCourier()
q := New(dir, set.NewString("loco"), aliases.NewResolver(),
localC, remoteC)
localC.wg.Add(2)
remoteC.wg.Add(1)
localC.Expect(2)
remoteC.Expect(1)
id, err := q.Put("from", []string{"[email protected]", "[email protected]", "nodomain"}, []byte("data"))
if err != nil {
t.Fatalf("Put: %v", err)
......@@ -62,22 +31,17 @@ func TestBasic(t *testing.T) {
t.Errorf("short ID: %v", id)
}
localC.wg.Wait()
remoteC.wg.Wait()
localC.Wait()
remoteC.Wait()
// Make sure the delivered items leave the queue.
for d := time.Now().Add(2 * time.Second); time.Now().Before(d); {
if q.Len() == 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
testlib.WaitFor(func() bool { return q.Len() == 0 }, 2*time.Second)
if q.Len() != 0 {
t.Fatalf("%d items not removed from the queue after delivery", q.Len())
}
cases := []struct {
courier *TestCourier
courier *testlib.TestCourier
expectedTo string
}{
{localC, "nodomain"},
......@@ -85,22 +49,22 @@ func TestBasic(t *testing.T) {
{remoteC, "[email protected]"},
}
for _, c := range cases {
req := c.courier.reqFor[c.expectedTo]
req := c.courier.ReqFor[c.expectedTo]
if req == nil {
t.Errorf("missing request for %q", c.expectedTo)
continue
}
if req.from != "from" || req.to != c.expectedTo ||
!bytes.Equal(req.data, []byte("data")) {
if req.From != "from" || req.To != c.expectedTo ||
!bytes.Equal(req.Data, []byte("data")) {
t.Errorf("wrong request for %q: %v", c.expectedTo, req)
}
}
}
func TestDSNOnTimeout(t *testing.T) {
localC := newTestCourier()
remoteC := newTestCourier()
localC := testlib.NewTestCourier()
remoteC := testlib.NewTestCourier()
dir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, dir)
q := New(dir, set.NewString("loco"), aliases.NewResolver(),
......@@ -127,24 +91,24 @@ func TestDSNOnTimeout(t *testing.T) {
q.DumpString()
// Launch the sending loop, expect 1 local delivery (the DSN).
localC.wg.Add(1)
localC.Expect(1)
go item.SendLoop(q)
localC.wg.Wait()
localC.Wait()
req := localC.reqFor["[email protected]"]
req := localC.ReqFor["[email protected]"]
if req == nil {
t.Fatal("missing DSN")
}
if req.from != "<>" || req.to != "[email protected]" ||
!strings.Contains(string(req.data), "X-Failed-Recipients: [email protected],") {
t.Errorf("wrong DSN: %q", string(req.data))
if req.From != "<>" || req.To != "[email protected]" ||
!strings.Contains(string(req.Data), "X-Failed-Recipients: [email protected],") {
t.Errorf("wrong DSN: %q", string(req.Data))
}
}
func TestAliases(t *testing.T) {
localC := newTestCourier()
remoteC := newTestCourier()
localC := testlib.NewTestCourier()
remoteC := testlib.NewTestCourier()
dir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, dir)
q := New(dir, set.NewString("loco"), aliases.NewResolver(),
......@@ -157,17 +121,17 @@ func TestAliases(t *testing.T) {
// Note the pipe aliases are tested below, as they don't use the couriers
// and it can be quite inconvenient to test them in this way.
localC.wg.Add(2)
remoteC.wg.Add(1)
localC.Expect(2)
remoteC.Expect(1)
_, err := q.Put("from", []string{"[email protected]", "[email protected]"}, []byte("data"))
if err != nil {
t.Fatalf("Put: %v", err)
}
localC.wg.Wait()
remoteC.wg.Wait()
localC.Wait()
remoteC.Wait()
cases := []struct {
courier *TestCourier
courier *testlib.TestCourier
expectedTo string
}{
{localC, "[email protected]"},
......@@ -175,33 +139,24 @@ func TestAliases(t *testing.T) {
{remoteC, "[email protected]"},
}
for _, c := range cases {
req := c.courier.reqFor[c.expectedTo]
req := c.courier.ReqFor[c.expectedTo]
if req == nil {
t.Errorf("missing request for %q", c.expectedTo)
continue
}
if req.from != "from" || req.to != c.expectedTo ||
!bytes.Equal(req.data, []byte("data")) {
if req.From != "from" || req.To != c.expectedTo ||
!bytes.Equal(req.Data, []byte("data")) {
t.Errorf("wrong request for %q: %v", c.expectedTo, req)
}
}
}
// Dumb courier, for when we just want to return directly.
type DumbCourier struct{}
func (c DumbCourier) Deliver(from string, to string, data []byte) (error, bool) {
return nil, false
}
var dumbCourier = DumbCourier{}
func TestFullQueue(t *testing.T) {
dir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, dir)
q := New(dir, set.NewString(), aliases.NewResolver(),
dumbCourier, dumbCourier)
testlib.DumbCourier, testlib.DumbCourier)
// Force-insert maxQueueSize items in the queue.
oneID := ""
......@@ -243,7 +198,7 @@ func TestPipes(t *testing.T) {
dir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, dir)
q := New(dir, set.NewString("loco"), aliases.NewResolver(),
dumbCourier, dumbCourier)
testlib.DumbCourier, testlib.DumbCourier)
item := &Item{
Message: Message{
ID: <-newID,
......@@ -303,21 +258,21 @@ func TestSerialization(t *testing.T) {
}
// Create the queue; should load the
remoteC := newTestCourier()
remoteC.wg.Add(1)
remoteC := testlib.NewTestCourier()
remoteC.Expect(1)
q := New(dir, set.NewString("loco"), aliases.NewResolver(),
dumbCourier, remoteC)
testlib.DumbCourier, remoteC)
q.Load()
// Launch the sending loop, expect 1 remote delivery for the item we saved.
remoteC.wg.Wait()
remoteC.Wait()
req := remoteC.reqFor["[email protected]"]
req := remoteC.ReqFor["[email protected]"]
if req == nil {
t.Fatal("email not delivered")
}
if req.from != "[email protected]" || req.to != "[email protected]" {
if req.From != "[email protected]" || req.To != "[email protected]" {
t.Errorf("wrong email: %v", req)
}
}
......
......@@ -7,6 +7,8 @@
package smtp
import (
"bufio"
"io"
"net"
"net/smtp"
"net/textproto"
......@@ -28,6 +30,14 @@ func NewClient(conn net.Conn, host string) (*Client, error) {
if err != nil {
return nil, err
}
// Wrap the textproto.Conn reader so we are not exposed to a memory
// exhaustion DoS on very long replies from the server.
// Limit to 2 MiB total (all replies through the lifetime of the client),
// which should be plenty for our uses of SMTP.
lr := &io.LimitedReader{R: c.Text.Reader.R, N: 2 * 1024 * 1024}
c.Text.Reader.R = bufio.NewReader(lr)
return &Client{c}, nil
}
......
......@@ -4,8 +4,8 @@ import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"net/smtp"
"net/textproto"
"strings"
"testing"
......@@ -48,8 +48,19 @@ func TestIsASCII(t *testing.T) {
}
}
func mustNewClient(t *testing.T, nc net.Conn) *Client {
t.Helper()
c, err := NewClient(nc, "")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
return c
}
func TestBasic(t *testing.T) {
fake, client := fakeDialog(`> EHLO a_test
fake, client := fakeDialog(`< 220 welcome
> EHLO a_test
< 250-server replies your hello
< 250-SIZE 35651584
< 250-SMTPUTF8
......@@ -61,8 +72,7 @@ func TestBasic(t *testing.T) {
< 250 RCPT TO is fine
`)
c := &Client{
Client: &smtp.Client{Text: textproto.NewConn(fake)}}
c := mustNewClient(t, fake)
if err := c.Hello("a_test"); err != nil {
t.Fatalf("Hello failed: %v", err)
}
......@@ -78,7 +88,8 @@ func TestBasic(t *testing.T) {
}
func TestSMTPUTF8(t *testing.T) {
fake, client := fakeDialog(`> EHLO araña
fake, client := fakeDialog(`< 220 welcome
> EHLO araña
< 250-chasquid replies your hello
< 250-SIZE 35651584
< 250-SMTPUTF8
......@@ -90,8 +101,7 @@ func TestSMTPUTF8(t *testing.T) {
< 250 RCPT TO is fine
`)
c := &Client{
Client: &smtp.Client{Text: textproto.NewConn(fake)}}
c := mustNewClient(t, fake)
if err := c.Hello("araña"); err != nil {
t.Fatalf("Hello failed: %v", err)
}
......@@ -107,15 +117,15 @@ func TestSMTPUTF8(t *testing.T) {
}
func TestSMTPUTF8NotSupported(t *testing.T) {
fake, client := fakeDialog(`> EHLO araña
fake, client := fakeDialog(`< 220 welcome
> EHLO araña
< 250-chasquid replies your hello
< 250-SIZE 35651584
< 250-8BITMIME
< 250 HELP
`)
c := &Client{
Client: &smtp.Client{Text: textproto.NewConn(fake)}}
c := mustNewClient(t, fake)
if err := c.Hello("araña"); err != nil {
t.Fatalf("Hello failed: %v", err)
}
......@@ -135,7 +145,8 @@ func TestSMTPUTF8NotSupported(t *testing.T) {
}
func TestFallbackToIDNA(t *testing.T) {
fake, client := fakeDialog(`> EHLO araña
fake, client := fakeDialog(`< 220 welcome
> EHLO araña
< 250-chasquid replies your hello
< 250-SIZE 35651584
< 250-8BITMIME
......@@ -146,8 +157,7 @@ func TestFallbackToIDNA(t *testing.T) {
< 250 RCPT TO is fine
`)
c := &Client{
Client: &smtp.Client{Text: textproto.NewConn(fake)}}
c := mustNewClient(t, fake)
if err := c.Hello("araña"); err != nil {
t.Fatalf("Hello failed: %v", err)
}
......@@ -166,6 +176,38 @@ func TestFallbackToIDNA(t *testing.T) {
}
}
func TestLineTooLong(t *testing.T) {
// Fake the server sending a >2MiB reply.
dialog := `< 220 welcome
> EHLO araña
< 250 HELP
> NOOP
< 250 longreply:` + fmt.Sprintf("%2097152s", "x") + `:
> NOOP
< 250 ok
`
fake, client := fakeDialog(dialog)
c := mustNewClient(t, fake)
if err := c.Hello("araña"); err != nil {
t.Fatalf("Hello failed: %v", err)
}
if err := c.Noop(); err != nil {
t.Errorf("Noop failed: %v", err)
}
if err := c.Noop(); err != io.EOF {
t.Errorf("Expected EOF, got: %v", err)
}
cmds := fake.Client()
if client != cmds {
t.Errorf("Got:\n%s\nExpected:\n%s", cmds, client)
}
}
type faker struct {
buf *bytes.Buffer
*bufio.ReadWriter
......@@ -182,6 +224,8 @@ func (f faker) Client() string {
return f.buf.String()
}
var _ net.Conn = faker{}
// Takes a dialog, returns the corresponding faker and expected client
// messages. Ideally we would check this interactively, and it's not that
// difficult, but this is good enough for now.
......
package smtpsrv
import (
"bufio"
"bytes"
"context"
"crypto/tls"
......@@ -94,10 +95,13 @@ type Conn struct {
// Connection information.
conn net.Conn
tc *textproto.Conn
mode SocketMode
tlsConnState *tls.ConnectionState
// Reader and text writer, so we can control limits.
reader *bufio.Reader
writer *bufio.Writer
// Tracer to use.
tr *trace.Trace
......@@ -178,7 +182,12 @@ func (c *Conn) Handle() {
}
}
c.tc.PrintfLine("220 %s ESMTP chasquid", c.hostname)
// Set up a buffered reader and writer from the conn.
// They will be used to do line-oriented, limited I/O.
c.reader = bufio.NewReader(c.conn)
c.writer = bufio.NewWriter(c.conn)
c.printfLine("220 %s ESMTP chasquid", c.hostname)
var cmd, params string
var err error
......@@ -196,7 +205,7 @@ loop:
cmd, params, err = c.readCommand()
if err != nil {
c.tc.PrintfLine("554 error reading command: %v", err)
c.printfLine("554 error reading command: %v", err)
break
}
......@@ -577,9 +586,14 @@ func (c *Conn) DATA(params string) (code int, msg string) {
// one, we don't want the command timeout to interfere.
c.conn.SetDeadline(c.deadline)
dotr := io.LimitReader(c.tc.DotReader(), c.maxDataSize)
// Create a dot reader, limited to the maximum size.
dotr := textproto.NewReader(bufio.NewReader(
io.LimitReader(c.reader, c.maxDataSize))).DotReader()
c.data, err = ioutil.ReadAll(dotr)
if err != nil {
if err == io.ErrUnexpectedEOF {
return 552, fmt.Sprintf("5.3.4 Message too big")
}
return 554, fmt.Sprintf("5.4.0 Error reading DATA: %v", err)
}
......@@ -875,9 +889,10 @@ func (c *Conn) STARTTLS(params string) (code int, msg string) {
c.tr.Debugf("<> ... jump to TLS was successful")
// Override the connections. We don't need the older ones anymore.
// Override the connection. We don't need the older one anymore.
c.conn = server
c.tc = textproto.NewConn(server)
c.reader = bufio.NewReader(c.conn)
c.writer = bufio.NewWriter(c.conn)
// Take the connection state, so we can use it later for logging and
// tracing purposes.
......@@ -1001,9 +1016,7 @@ func (c *Conn) userExists(addr string) bool {
}
func (c *Conn) readCommand() (cmd, params string, err error) {
var msg string
msg, err = c.tc.ReadLine()
msg, err := c.readLine()
if err != nil {
return "", "", err
}
......@@ -1018,14 +1031,36 @@ func (c *Conn) readCommand() (cmd, params string, err error) {
}
func (c *Conn) readLine() (line string, err error) {
return c.tc.ReadLine()
// The bufio reader's ReadLine will only read up to the buffer size, which
// prevents DoS due to memory exhaustion on extremely long lines.
l, more, err := c.reader.ReadLine()
if err != nil {
return "", err
}
// As per RFC, the maximum length of a text line is 1000 octets.
// https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6
if len(l) > 1000 || more {
// Keep reading to maintain the protocol status, but discard the data.
for more && err == nil {
_, more, err = c.reader.ReadLine()
}
return "", fmt.Errorf("line too long")
}
return string(l), nil
}
func (c *Conn) writeResponse(code int, msg string) error {
defer c.tc.W.Flush()
defer c.writer.Flush()
responseCodeCount.Add(strconv.Itoa(code), 1)
return writeResponse(c.tc.W, code, msg)
return writeResponse(c.writer, code, msg)
}
func (c *Conn) printfLine(format string, args ...interface{}) error {
fmt.Fprintf(c.writer, format+"\r\n", args...)
return c.writer.Flush()
}
// writeResponse writes a multi-line response to the given writer.
......
// Fuzz testing for package smtpsrv. Based on server_test.
// +build gofuzz
package smtpsrv
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"fmt"
"io"
"io/ioutil"
"math/big"
"net"
"net/textproto"
"os"
"strings"
"time"
"blitiri.com.ar/go/chasquid/internal/aliases"
"blitiri.com.ar/go/chasquid/internal/courier"
"blitiri.com.ar/go/chasquid/internal/testlib"
"blitiri.com.ar/go/chasquid/internal/userdb"
"blitiri.com.ar/go/log"
)
var (
// Server addresses. Will be filled in at init time.
smtpAddr = ""
submissionAddr = ""
submissionTLSAddr = ""
// TLS configuration to use in the clients.
// Will contain the generated server certificate as root CA.
tlsConfig *tls.Config
)
//
// === Fuzz test ===
//
func Fuzz(data []byte) int {
// Byte 0: mode
// The rest is what we will send the server, one line per command.
if len(data) < 1 {
return 0
}
var mode SocketMode
addr := ""
switch data[0] {
case '0':
mode = ModeSMTP
addr = smtpAddr
case '1':
mode = ModeSubmission
addr = submissionAddr
case '2':
mode = ModeSubmissionTLS
addr = submissionTLSAddr
default:
return 0
}
data = data[1:]
var err error
var conn net.Conn
if mode.TLS {
conn, err = tls.Dial("tcp", addr, tlsConfig)
} else {
conn, err = net.Dial("tcp", addr)
}
if err != nil {
panic(fmt.Errorf("failed to dial: %v", err))
}
defer conn.Close()
tconn := textproto.NewConn(conn)
defer tconn.Close()
in_data := false
scanner := bufio.NewScanner(bytes.NewBuffer(data))
for scanner.Scan() {
line := scanner.Text()
// Skip STARTTLS if it happens on a non-TLS connection - the jump is
// not going to happen via fuzzer, it will just cause a timeout (which
// is considered a crash).
if strings.TrimSpace(strings.ToUpper(line)) == "STARTTLS" && !mode.TLS {
continue
}
if err = tconn.PrintfLine(line); err != nil {
break
}
if in_data {
if line == "." {
in_data = false
} else {
continue
}
}
if _, _, err = tconn.ReadResponse(-1); err != nil {
break
}
in_data = strings.HasPrefix(strings.ToUpper(line), "DATA")
}
if (err != nil && err != io.EOF) || scanner.Err() != nil {
return 1
}
return 0
}
//
// === Test environment ===
//
// generateCert generates a new, INSECURE self-signed certificate and writes
// it to a pair of (cert.pem, key.pem) files to the given path.
// Note the certificate is only useful for testing purposes.
func generateCert(path string) error {
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1234),
Subject: pkix.Name{
Organization: []string{"chasquid_test.go"},
},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment |
x509.KeyUsageDigitalSignature |
x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
priv, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
return err
}
derBytes, err := x509.CreateCertificate(
rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return err
}
// Create a global config for convenience.
srvCert, err := x509.ParseCertificate(derBytes)
if err != nil {
return err
}
rootCAs := x509.NewCertPool()
rootCAs.AddCert(srvCert)
tlsConfig = &tls.Config{
ServerName: "localhost",
RootCAs: rootCAs,
}
certOut, err := os.Create(path + "/cert.pem")
if err != nil {
return err
}
defer certOut.Close()
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyOut, err := os.OpenFile(
path+"/key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer keyOut.Close()
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(priv),
}
pem.Encode(keyOut, block)
return nil
}
// waitForServer waits 10 seconds for the server to start, and returns an error
// if it fails to do so.
// It does this by repeatedly connecting to the address until it either
// replies or times out. Note we do not do any validation of the reply.
func waitForServer(addr string) {
start := time.Now()
for time.Since(start) < 10*time.Second {
conn, err := net.Dial("tcp", addr)
if err == nil {
conn.Close()
return
}
time.Sleep(100 * time.Millisecond)
}
panic(fmt.Errorf("%v not reachable", addr))
}
func init() {
flag.Parse()
log.Default.Level = log.Debug
// Generate certificates in a temporary directory.
tmpDir, err := ioutil.TempDir("", "chasquid_smtpsrv_fuzz:")
if err != nil {
panic(fmt.Errorf("Failed to create temp dir: %v\n", tmpDir))
}
defer os.RemoveAll(tmpDir)
err = generateCert(tmpDir)
if err != nil {
panic(fmt.Errorf("Failed to generate cert for testing: %v\n", err))
}
smtpAddr = testlib.GetFreePort()
submissionAddr = testlib.GetFreePort()
submissionTLSAddr = testlib.GetFreePort()
s := NewServer()
s.Hostname = "localhost"
s.MaxDataSize = 50 * 1024 * 1025
s.AddCerts(tmpDir+"/cert.pem", tmpDir+"/key.pem")
s.AddAddr(smtpAddr, ModeSMTP)
s.AddAddr(submissionAddr, ModeSubmission)
s.AddAddr(submissionTLSAddr, ModeSubmissionTLS)
localC := &courier.Procmail{}
remoteC := &courier.SMTP{}
s.InitQueue(tmpDir+"/queue", localC, remoteC)
s.InitDomainInfo(tmpDir + "/domaininfo")
udb := userdb.New("/dev/null")
udb.AddUser("testuser", "testpasswd")
s.aliasesR.AddAliasForTesting(
"[email protected]", "[email protected]", aliases.EMAIL)
s.AddDomain("localhost")
s.AddUserDB("localhost", udb)
// Disable SPF lookups, to avoid leaking DNS queries.
disableSPFForTesting = true
go s.ListenAndServe()
waitForServer(smtpAddr)
waitForServer(submissionAddr)
waitForServer(submissionTLSAddr)
}
......@@ -6,7 +6,6 @@ import (
"flag"
"net"
"net/http"
"net/textproto"
"path"
"time"
......@@ -164,6 +163,9 @@ func (s *Server) InitQueue(path string, localC, remoteC courier.Courier) {
// periodicallyReload some of the server's information, such as aliases and
// the user databases.
func (s *Server) periodicallyReload() {
if reloadEvery == nil {
return
}
for range time.Tick(*reloadEvery) {
err := s.aliasesR.Reload()
if err != nil {
......@@ -247,7 +249,6 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
maxDataSize: s.MaxDataSize,
postDataHook: pdhook,
conn: conn,
tc: textproto.NewConn(conn),
mode: mode,
tlsConfig: s.tlsConfig,
onTLS: mode.TLS,
......
......@@ -18,7 +18,8 @@ import (
"time"
"blitiri.com.ar/go/chasquid/internal/aliases"
"blitiri.com.ar/go/chasquid/internal/courier"
"blitiri.com.ar/go/chasquid/internal/maillog"
"blitiri.com.ar/go/chasquid/internal/testlib"
"blitiri.com.ar/go/chasquid/internal/userdb"
)
......@@ -28,19 +29,27 @@ var (
"SMTP server address to test (defaults to use internal)")
externalSubmissionAddr = flag.String("external_submission_addr", "",
"submission server address to test (defaults to use internal)")
externalSubmissionTLSAddr = flag.String("external_submission_tls_addr", "",
"submission+TLS server address to test (defaults to use internal)")
)
var (
// Server addresses.
// Server addresses. Will be filled in at init time.
// We default to internal ones, but may get overridden via flags.
// TODO: Don't hard-code the default.
smtpAddr = "127.0.0.1:13444"
submissionAddr = "127.0.0.1:13999"
submissionTLSAddr = "127.0.0.1:13777"
smtpAddr = ""
submissionAddr = ""
submissionTLSAddr = ""
// TLS configuration to use in the clients.
// Will contain the generated server certificate as root CA.
tlsConfig *tls.Config
// Test couriers, so we can validate that emails got sent.
localC = testlib.NewTestCourier()
remoteC = testlib.NewTestCourier()
// Max data size, in MiB.
maxDataSizeMiB = 5
)
//
......@@ -126,9 +135,13 @@ func sendEmailWithAuth(tb testing.TB, c *smtp.Client, auth smtp.Auth) {
tb.Errorf("Data write: %v", err)
}
localC.Expect(1)
if err = w.Close(); err != nil {
tb.Errorf("Data close: %v", err)
}
localC.Wait()
}
func TestSimple(t *testing.T) {
......@@ -257,14 +270,98 @@ func TestRelayForbidden(t *testing.T) {
}
}
func simpleCmd(t *testing.T, c *smtp.Client, cmd string, expected int) {
func TestTooManyRecipients(t *testing.T) {
c := mustDial(t, ModeSubmission, true)
defer c.Close()
auth := smtp.PlainAuth("", "[email protected]", "testpasswd", "127.0.0.1")
if err := c.Auth(auth); err != nil {
t.Fatalf("Auth: %v", err)
}
if err := c.Mail("[email protected]"); err != nil {
t.Fatalf("Mail: %v", err)
}
for i := 0; i < 101; i++ {
if err := c.Rcpt(fmt.Sprintf("to%[email protected]", i)); err != nil {
t.Fatalf("Rcpt: %v", err)
}
}
err := c.Rcpt("[email protected]")
if err == nil || err.Error() != "452 4.5.3 Too many recipients" {
t.Errorf("Expected too many recipients, got: %v", err)
}
}
var str1MiB string
func sendLargeEmail(tb testing.TB, c *smtp.Client, sizeMiB int) error {
tb.Helper()
if err := c.Mail("[email protected]"); err != nil {
tb.Fatalf("Mail: %v", err)
}
if err := c.Rcpt("[email protected]"); err != nil {
tb.Fatalf("Rcpt: %v", err)
}
w, err := c.Data()
if err != nil {
tb.Fatalf("Data: %v", err)
}
if _, err := w.Write([]byte("Subject: I ate too much\n\n")); err != nil {
tb.Fatalf("Data write: %v", err)
}
// Write the 1 MiB string sizeMiB times.
for i := 0; i < sizeMiB; i++ {
if _, err := w.Write([]byte(str1MiB)); err != nil {
tb.Fatalf("Data write: %v", err)
}
}
return w.Close()
}
func TestTooMuchData(t *testing.T) {
c := mustDial(t, ModeSMTP, true)
defer c.Close()
localC.Expect(1)
err := sendLargeEmail(t, c, maxDataSizeMiB-1)
if err != nil {
t.Errorf("Error sending large but ok email: %v", err)
}
localC.Wait()
// Repeat the test - we want to check that the limit applies to each
// message, not the entire connection.
localC.Expect(1)
err = sendLargeEmail(t, c, maxDataSizeMiB-1)
if err != nil {
t.Errorf("Error sending large but ok email: %v", err)
}
localC.Wait()
err = sendLargeEmail(t, c, maxDataSizeMiB+1)
if err == nil || err.Error() != "552 5.3.4 Message too big" {
t.Fatalf("Expected message too big, got: %v", err)
}
}
func simpleCmd(t *testing.T, c *smtp.Client, cmd string, expected int) string {
t.Helper()
if err := c.Text.PrintfLine(cmd); err != nil {
t.Fatalf("Failed to write %s: %v", cmd, err)
}
if _, _, err := c.Text.ReadResponse(expected); err != nil {
_, msg, err := c.Text.ReadResponse(expected)
if err != nil {
t.Errorf("Incorrect %s response: %v", cmd, err)
}
return msg
}
func TestSimpleCommands(t *testing.T) {
......@@ -276,6 +373,20 @@ func TestSimpleCommands(t *testing.T) {
simpleCmd(t, c, "EXPN", 502)
}
func TestLongLines(t *testing.T) {
c := mustDial(t, ModeSMTP, false)
defer c.Close()
// Send a not-too-long line.
simpleCmd(t, c, fmt.Sprintf("%1000s", "x"), 500)
// Send a very long line, expect an error.
msg := simpleCmd(t, c, fmt.Sprintf("%1001s", "x"), 554)
if msg != "error reading command: line too long" {
t.Errorf("Expected 'line too long', got %v", msg)
}
}
func TestReset(t *testing.T) {
c := mustDial(t, ModeSMTP, false)
defer c.Close()
......@@ -329,9 +440,6 @@ func BenchmarkManyEmails(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
sendEmail(b, c)
// TODO: Make sendEmail() wait for delivery, and remove this.
time.Sleep(10 * time.Millisecond)
}
}
......@@ -342,9 +450,6 @@ func BenchmarkManyEmailsParallel(b *testing.B) {
for pb.Next() {
sendEmail(b, c)
// TODO: Make sendEmail() wait for delivery, and remove this.
time.Sleep(100 * time.Millisecond)
}
})
}
......@@ -446,9 +551,21 @@ func waitForServer(addr string) error {
func realMain(m *testing.M) int {
flag.Parse()
// Create a 1MiB string, which the large message tests use.
buf := make([]byte, 1024*1024)
for i := 0; i < len(buf); i++ {
buf[i] = 'a'
}
str1MiB = string(buf)
// Set up the mail log to stdout, which is captured by the test runner,
// so we have better debugging information on failures.
maillog.Default = maillog.New(os.Stdout)
if *externalSMTPAddr != "" {
smtpAddr = *externalSMTPAddr
submissionAddr = *externalSubmissionAddr
submissionTLSAddr = *externalSubmissionTLSAddr
tlsConfig = &tls.Config{
InsecureSkipVerify: true,
}
......@@ -467,16 +584,18 @@ func realMain(m *testing.M) int {
return 1
}
smtpAddr = testlib.GetFreePort()
submissionAddr = testlib.GetFreePort()
submissionTLSAddr = testlib.GetFreePort()
s := NewServer()
s.Hostname = "localhost"
s.MaxDataSize = 50 * 1024 * 1025
s.MaxDataSize = int64(maxDataSizeMiB) * 1024 * 1024
s.AddCerts(tmpDir+"/cert.pem", tmpDir+"/key.pem")
s.AddAddr(smtpAddr, ModeSMTP)
s.AddAddr(submissionAddr, ModeSubmission)
s.AddAddr(submissionTLSAddr, ModeSubmissionTLS)
localC := &courier.Procmail{}
remoteC := &courier.SMTP{}
s.InitQueue(tmpDir+"/queue", localC, remoteC)
s.InitDomainInfo(tmpDir + "/domaininfo")
......@@ -490,11 +609,15 @@ func realMain(m *testing.M) int {
// Disable SPF lookups, to avoid leaking DNS queries.
disableSPFForTesting = true
// Disable reloading.
reloadEvery = nil
go s.ListenAndServe()
}
waitForServer(smtpAddr)
waitForServer(submissionAddr)
waitForServer(submissionTLSAddr)
return m.Run()
}
......
2EHLO localhost
AUTH SOMETHINGELSE
AUTH PLAIN
dXNlckB0ZXN0c2VydmVyAHlalala==
AUTH PLAIN
dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgB3cm9uZ3Bhc3N3b3Jk
AUTH PLAIN
dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgBzZWNyZXRwYXNzd29yZA==
AUTH PLAIN
0EHLO localhost
AUTH PLAIN something
AUTH PLAIN something
AUTH PLAIN something
AUTH PLAIN something
AUTH PLAIN something
0DATA
HELO localhost
DATA
MAIL FROM:<[email protected]>
RCPT TO: [email protected]
DATA
From: Mailer daemon <[email protected]>
Subject: I've come to haunt you
Bad header
Muahahahaha
.
QUIT
0HELO localhost
MAIL LALA: <>
MAIL FROM:
MAIL FROM:<pepe>
MAIL FROM:<[email protected]>
MAIL FROM:<aaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aa[email protected]bbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbX>
0HELO localhost
MAIL FROM:<[email protected]>
RCPT LALA: <>
RCPT TO:
RCPT TO:<pepe>
RCPT TO:<[email protected]>
RCPT TO:<henryⅣ@testserver>
RCPT TO:<aaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aaaaXaaaa5aa[email protected]bbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbXbbbb5bbbbX>
0EHLO localhost
MAIL FROM: <>
RCPT TO: [email protected]
DATA
From: Mailer daemon <[email protected]áratro>
Subject: I've come to haunt you
Message-ID: <booooo>
Ñañañañaña!
.
QUIT
0EHLO localhost
MAIL FROM: <>
RCPT TO: [email protected]
DATA
From: Mailer daemon <[email protected]>
Subject: I've come to haunt you
Muahahahaha
.
QUIT
......@@ -3,9 +3,12 @@ package testlib
import (
"io/ioutil"
"net"
"os"
"strings"
"sync"
"testing"
"time"
)
// MustTempDir creates a temporary directory, or dies trying.
......@@ -52,3 +55,73 @@ func Rewrite(t *testing.T, path, contents string) error {
return err
}
// GetFreePort returns a free TCP port. This is hacky and not race-free, but
// it works well enough for testing purposes.
func GetFreePort() string {
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
panic(err)
}
defer l.Close()
return l.Addr().String()
}
func WaitFor(f func() bool, d time.Duration) bool {
start := time.Now()
for time.Since(start) < d {
if f() {
return true
}
time.Sleep(20 * time.Millisecond)
}
return false
}
type DeliverRequest struct {
From string
To string
Data []byte
}
// Courier for test purposes. Never fails, and always remembers everything.
type TestCourier struct {
wg sync.WaitGroup
Requests []*DeliverRequest
ReqFor map[string]*DeliverRequest
sync.Mutex
}
func (tc *TestCourier) Deliver(from string, to string, data []byte) (error, bool) {
defer tc.wg.Done()
dr := &DeliverRequest{from, to, data}
tc.Lock()
tc.Requests = append(tc.Requests, dr)
tc.ReqFor[to] = dr
tc.Unlock()
return nil, false
}
func (tc *TestCourier) Expect(i int) {
tc.wg.Add(i)
}
func (tc *TestCourier) Wait() {
tc.wg.Wait()
}
// NewTestCourier returns a new, empty TestCourier instance.
func NewTestCourier() *TestCourier {
return &TestCourier{
ReqFor: map[string]*DeliverRequest{},
}
}
type dumbCourier struct{}
func (c dumbCourier) Deliver(from string, to string, data []byte) (error, bool) {
return nil, false
}
// Dumb courier, for when we just don't care about the result.
var DumbCourier = dumbCourier{}
......@@ -76,3 +76,10 @@ func TestRewrite(t *testing.T) {
t.Errorf("basic rewrite failed")
}
}
func TestGetFreePort(t *testing.T) {
p := GetFreePort()
if p == "" {
t.Errorf("failed to get free port")
}
}
......@@ -10,5 +10,12 @@ if [ "$RCPT_TO" == "[email protected]" ]; then
exit 1
fi
if [ "$RCPT_TO" == "[email protected]" ]; then
echo "Nos hacemos la permanente"
exit 20 # permanent
fi
echo "X-Post-Data: success"
echo "X-Post-Data-Multiline: multiline"
echo " header for testing."
......@@ -12,3 +12,4 @@ auth on
user [email protected]
password secretpassword
logfile .logs/msmtp
......@@ -9,6 +9,7 @@ generate_certs_for testserver
add_user [email protected] secretpassword
add_user [email protected] secretpassword
add_user [email protected] secretpassword
add_user [email protected] secretpassword
mkdir -p .logs
chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config &
......@@ -45,10 +46,24 @@ check "REMOTE_ADDR="
check "SPF_PASS=0"
# Check that a failure in the script results in failing delivery.
# Check that failures in the script result in failing delivery.
# Transient failure.
if run_msmtp [email protected] < content 2>/dev/null; then
fail "ERROR: hook did not block email as expected"
fi
if ! tail -n 1 .logs/msmtp | grep -q "smtpstatus=451"; then
tail -n 1 .logs/msmtp
fail "ERROR: transient hook error not returned correctly"
fi
# Permanent failure.
if run_msmtp [email protected] < content 2>/dev/null; then
fail "ERROR: hook did not block email as expected"
fi
if ! tail -n 1 .logs/msmtp | grep -q "smtpstatus=554"; then
tail -n 1 .logs/msmtp
fail "ERROR: permanent hook error not returned correctly"
fi
# Check that the bad hooks don't prevent delivery.
for i in config/hooks/post-data.bad*; do
......
c tcp_connect localhost:1025
c <~ 220