git » chasquid » commit e79586a

Implement HAProxy protocol support

author Alberto Bertogli
2020-11-12 22:00:46 UTC
committer Alberto Bertogli
2020-11-13 20:49:42 UTC
parent c9d3ba0ca095d0bf54c43014da50ae6d3d4d2f59

Implement HAProxy protocol support

This patch implements support for incoming connections wrapped in the
HAProxy protocol v1.

This is useful when running chasquid behind a HAProxy server, as it
needs the original source IP to perform SPF checks.

This patch is a reimplementation of one originally provided by Denys
Vitali in pull request #15, except the logic for the protocol handling
is moved to a new package, and the smtpsrv.Conn handling of the source
IP is simplified.

It is marked as experimental for now, since we want to give it a bit
more exposure just in case the option/api needs adjustment.

Thanks a lot to Denys Vitali (@denysvitali in github) for sending the
original patch for this, and helping test it!

.mkdocs.yml +1 -0
chasquid.go +1 -0
docs/haproxy.md +32 -0
docs/man/chasquid.conf.5 +9 -1
docs/man/chasquid.conf.5.pod +9 -0
etc/chasquid/chasquid.conf +8 -0
internal/config/config.go +5 -0
internal/config/config.pb.go +20 -6
internal/config/config.proto +5 -0
internal/haproxy/haproxy.go +76 -0
internal/haproxy/haproxy_test.go +97 -0
internal/smtpsrv/conn.go +32 -16
internal/smtpsrv/server.go +4 -0
test/Dockerfile +2 -1
test/README.md +2 -0
test/t-18-haproxy/config/chasquid.conf +12 -0
test/t-18-haproxy/content +4 -0
test/t-18-haproxy/haproxy.cfg +7 -0
test/t-18-haproxy/hosts +1 -0
test/t-18-haproxy/msmtprc +14 -0
test/t-18-haproxy/run.sh +39 -0
test/util/lib.sh +9 -0

diff --git a/.mkdocs.yml b/.mkdocs.yml
index 44340be..0d297a1 100644
--- a/.mkdocs.yml
+++ b/.mkdocs.yml
@@ -26,6 +26,7 @@ nav:
     - hooks.md
     - dovecot.md
     - dkim.md
+    - haproxy.md
     - docker.md
     - flow.md
     - monitoring.md
diff --git a/chasquid.go b/chasquid.go
index 00bb2a7..3c23487 100644
--- a/chasquid.go
+++ b/chasquid.go
@@ -100,6 +100,7 @@ func main() {
 	s.Hostname = conf.Hostname
 	s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
 	s.HookPath = "hooks/"
+	s.HAProxyEnabled = conf.HaproxyIncoming
 
 	s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
 
diff --git a/docs/haproxy.md b/docs/haproxy.md
new file mode 100644
index 0000000..1ad75fe
--- /dev/null
+++ b/docs/haproxy.md
@@ -0,0 +1,32 @@
+
+# HAProxy integration
+
+As of version 1.6, [chasquid] supports being deployed behind a [HAProxy]
+instance.
+
+**This is EXPERIMENTAL for now, and can change in backwards-incompatible
+ways.**
+
+
+## Configuring HAProxy
+
+In the backend server line, set the [send-proxy] parameter to turn on the use
+of the PROXY protocol against chasquid.
+
+You need to set this for each of the ports that are forwarded.
+
+
+## Configuring chasquid
+
+Add the following line to `/etc/chasquid/chasquid.conf`:
+
+```
+haproxy_incoming: true
+```
+
+That turns HAProxy support on for all incoming SMTP connections.
+
+
+[chasquid]: https://blitiri.com.ar/p/chasquid
+[HAProxy]: https://www.haproxy.org/
+[send-proxy]: http://cbonte.github.io/haproxy-dconv/2.0/configuration.html#5.2-send-proxy
diff --git a/docs/man/chasquid.conf.5 b/docs/man/chasquid.conf.5
index 64c45c2..bca08cb 100644
--- a/docs/man/chasquid.conf.5
+++ b/docs/man/chasquid.conf.5
@@ -133,7 +133,7 @@
 .\" ========================================================================
 .\"
 .IX Title "chasquid.conf 5"
-.TH chasquid.conf 5 "2020-05-24" "" ""
+.TH chasquid.conf 5 "2020-11-12" "" ""
 .\" For nroff, turn off justification.  Always turn off hyphenation; it makes
 .\" way too many mistakes in technical documents.
 .if n .ad l
@@ -234,6 +234,14 @@ databases will be authenticated via dovecot.  Default: \f(CW\*(C`false\*(C'\fR.
 The path to dovecot's auth sockets is autodetected, but can be manually
 overridden using the \f(CW\*(C`dovecot_userdb_path\*(C'\fR and \f(CW\*(C`dovecot_client_path\*(C'\fR if
 needed.
+.IP "\fBhaproxy_incoming\fR (bool):" 8
+.IX Item "haproxy_incoming (bool):"
+\&\fB\s-1EXPERIMENTAL\s0\fR, might change in backwards-incompatible ways.
+.Sp
+If true, expect incoming \s-1SMTP\s0 connections to use the HAProxy protocol.
+This allows deploying chasquid behind a HAProxy server, as the address
+information is preserved, and \s-1SPF\s0 checks can be performed properly.
+Default: \f(CW\*(C`false\*(C'\fR.
 .SH "SEE ALSO"
 .IX Header "SEE ALSO"
 \&\fBchasquid\fR\|(1)
diff --git a/docs/man/chasquid.conf.5.pod b/docs/man/chasquid.conf.5.pod
index fa6705d..436d5e3 100644
--- a/docs/man/chasquid.conf.5.pod
+++ b/docs/man/chasquid.conf.5.pod
@@ -113,6 +113,15 @@ The path to dovecot's auth sockets is autodetected, but can be manually
 overridden using the C<dovecot_userdb_path> and C<dovecot_client_path> if
 needed.
 
+=item B<haproxy_incoming> (bool):
+
+B<EXPERIMENTAL>, might change in backwards-incompatible ways.
+
+If true, expect incoming SMTP connections to use the HAProxy protocol.
+This allows deploying chasquid behind a HAProxy server, as the address
+information is preserved, and SPF checks can be performed properly.
+Default: C<false>.
+
 =back
 
 =head1 SEE ALSO
diff --git a/etc/chasquid/chasquid.conf b/etc/chasquid/chasquid.conf
index ea07108..5aa9f50 100644
--- a/etc/chasquid/chasquid.conf
+++ b/etc/chasquid/chasquid.conf
@@ -87,3 +87,11 @@
 # Default: "" (autodetect)
 #dovecot_userdb_path: ""
 #dovecot_client_path: ""
+
+# Expect incoming SMTP connections to use the HAProxy protocol.
+# EXPERIMENTAL - Might change in backwards-incompatible ways.
+# If set to true, this allows deploying chasquid behind a HAProxy server, as
+# the address information is preserved, and SPF checks can be performed
+# properly.
+# Default: false
+#haproxy_incoming: false
diff --git a/internal/config/config.go b/internal/config/config.go
index 3731dc5..6616856 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -123,6 +123,10 @@ func override(c, o *Config) {
 	if o.DovecotClientPath != "" {
 		c.DovecotClientPath = o.DovecotClientPath
 	}
+
+	if o.HaproxyIncoming {
+		c.HaproxyIncoming = true
+	}
 }
 
 // LogConfig logs the given configuration, in a human-friendly way.
@@ -141,4 +145,5 @@ func LogConfig(c *Config) {
 	log.Infof("  Mail log: %s", c.MailLogPath)
 	log.Infof("  Dovecot auth: %v (%q, %q)",
 		c.DovecotAuth, c.DovecotUserdbPath, c.DovecotClientPath)
+	log.Infof("  HAProxy incoming: %v", c.HaproxyIncoming)
 }
diff --git a/internal/config/config.pb.go b/internal/config/config.pb.go
index e9cbf2f..72fa74e 100644
--- a/internal/config/config.pb.go
+++ b/internal/config/config.pb.go
@@ -1,6 +1,6 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.23.0
+// 	protoc-gen-go v1.25.0
 // 	protoc        v3.12.3
 // source: config.proto
 
@@ -108,6 +108,10 @@ type Config struct {
 	// is not, we will try to autodetect it.
 	// Example: /var/run/dovecot/auth-client
 	DovecotClientPath string `protobuf:"bytes,15,opt,name=dovecot_client_path,json=dovecotClientPath,proto3" json:"dovecot_client_path,omitempty"`
+	// Expect incoming SMTP connections to use the HAProxy protocol.
+	// This allows deploying chasquid behind a HAProxy server, as the
+	// address information is preserved.
+	HaproxyIncoming bool `protobuf:"varint,16,opt,name=haproxy_incoming,json=haproxyIncoming,proto3" json:"haproxy_incoming,omitempty"`
 }
 
 func (x *Config) Reset() {
@@ -247,10 +251,17 @@ func (x *Config) GetDovecotClientPath() string {
 	return ""
 }
 
+func (x *Config) GetHaproxyIncoming() bool {
+	if x != nil {
+		return x.HaproxyIncoming
+	}
+	return false
+}
+
 var File_config_proto protoreflect.FileDescriptor
 
 var file_config_proto_rawDesc = []byte{
-	0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x95,
+	0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc0,
 	0x05, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73,
 	0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73,
 	0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x64, 0x61, 0x74,
@@ -292,10 +303,13 @@ var file_config_proto_rawDesc = []byte{
 	0x64, 0x62, 0x50, 0x61, 0x74, 0x68, 0x12, 0x2e, 0x0a, 0x13, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f,
 	0x74, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0f, 0x20,
 	0x01, 0x28, 0x09, 0x52, 0x11, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x43, 0x6c, 0x69, 0x65,
-	0x6e, 0x74, 0x50, 0x61, 0x74, 0x68, 0x42, 0x2c, 0x5a, 0x2a, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72,
-	0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73,
-	0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f,
-	0x6e, 0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x6e, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x29, 0x0a, 0x10, 0x68, 0x61, 0x70, 0x72, 0x6f, 0x78,
+	0x79, 0x5f, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x0f, 0x68, 0x61, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e,
+	0x67, 0x42, 0x2c, 0x5a, 0x2a, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x62,
+	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/internal/config/config.proto b/internal/config/config.proto
index 3c1bb39..54ecf95 100644
--- a/internal/config/config.proto
+++ b/internal/config/config.proto
@@ -95,4 +95,9 @@ message Config {
 	// is not, we will try to autodetect it.
 	// Example: /var/run/dovecot/auth-client
 	string dovecot_client_path = 15;
+
+	// Expect incoming SMTP connections to use the HAProxy protocol.
+	// This allows deploying chasquid behind a HAProxy server, as the
+	// address information is preserved.
+	bool haproxy_incoming = 16;
 }
diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go
new file mode 100644
index 0000000..6351bb0
--- /dev/null
+++ b/internal/haproxy/haproxy.go
@@ -0,0 +1,76 @@
+// Package haproxy implements the handshake for the HAProxy client protocol
+// version 1, as described in
+// https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt.
+package haproxy
+
+import (
+	"bufio"
+	"errors"
+	"net"
+	"strconv"
+	"strings"
+)
+
+var (
+	errInvalidProtoID = errors.New("invalid protocol identifier")
+	errUnkProtocol    = errors.New("unknown protocol")
+	errInvalidFields  = errors.New("invalid number of fields")
+	errInvalidSrcIP   = errors.New("invalid src ip")
+	errInvalidDstIP   = errors.New("invalid dst ip")
+	errInvalidSrcPort = errors.New("invalid src port")
+	errInvalidDstPort = errors.New("invalid dst port")
+)
+
+// Handshake performs the HAProxy protocol v1 handshake on the given reader,
+// which is expected to be backed by a network connection.
+// It returns the source and destination addresses, or an error if the
+// handshake could not complete.
+// Note that any timeouts or limits must be set by the caller on the
+// underlying connection, this is helper only to perform the handshake.
+func Handshake(r *bufio.Reader) (src, dst net.Addr, err error) {
+	line, err := r.ReadString('\n')
+	if err != nil {
+		return nil, nil, err
+	}
+
+	fields := strings.Fields(line)
+
+	if len(fields) < 2 || fields[0] != "PROXY" {
+		return nil, nil, errInvalidProtoID
+	}
+
+	switch fields[1] {
+	case "TCP4", "TCP6":
+		// Allowed to continue, nothing to do.
+	default:
+		return nil, nil, errUnkProtocol
+	}
+
+	if len(fields) != 6 {
+		return nil, nil, errInvalidFields
+	}
+
+	srcIP := net.ParseIP(fields[2])
+	if srcIP == nil {
+		return nil, nil, errInvalidSrcIP
+	}
+
+	dstIP := net.ParseIP(fields[3])
+	if dstIP == nil {
+		return nil, nil, errInvalidDstIP
+	}
+
+	srcPort, err := strconv.ParseUint(fields[4], 10, 16)
+	if err != nil {
+		return nil, nil, errInvalidSrcPort
+	}
+
+	dstPort, err := strconv.ParseUint(fields[5], 10, 16)
+	if err != nil {
+		return nil, nil, errInvalidDstPort
+	}
+
+	src = &net.TCPAddr{IP: srcIP, Port: int(srcPort)}
+	dst = &net.TCPAddr{IP: dstIP, Port: int(dstPort)}
+	return src, dst, nil
+}
diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go
new file mode 100644
index 0000000..dd63a90
--- /dev/null
+++ b/internal/haproxy/haproxy_test.go
@@ -0,0 +1,97 @@
+package haproxy
+
+import (
+	"bufio"
+	"io"
+	"net"
+	"strings"
+	"testing"
+)
+
+func TestNoNewline(t *testing.T) {
+	r := bufio.NewReader(strings.NewReader("PROXY "))
+	_, _, err := Handshake(r)
+	if err != io.EOF {
+		t.Errorf("expected EOF, got %v", err)
+	}
+}
+
+func TestBasic(t *testing.T) {
+	var (
+		src4, _ = net.ResolveTCPAddr("tcp", "1.1.1.1:3333")
+		dst4, _ = net.ResolveTCPAddr("tcp", "2.2.2.2:4444")
+		src6, _ = net.ResolveTCPAddr("tcp", "[5::5]:7777")
+		dst6, _ = net.ResolveTCPAddr("tcp", "[6::6]:8888")
+	)
+
+	cases := []struct {
+		str      string
+		src, dst net.Addr
+		err      error
+	}{
+		// Early line errors.
+		{"", nil, nil, errInvalidProtoID},
+		{"lalala", nil, nil, errInvalidProtoID},
+		{"PROXY", nil, nil, errInvalidProtoID},
+		{"PROXY lalala", nil, nil, errUnkProtocol},
+		{"PROXY UNKNOWN", nil, nil, errUnkProtocol},
+
+		// Number of field errors.
+		{"PROXY TCP4", nil, nil, errInvalidFields},
+		{"PROXY TCP4 a", nil, nil, errInvalidFields},
+		{"PROXY TCP4 a b", nil, nil, errInvalidFields},
+		{"PROXY TCP4 a b c", nil, nil, errInvalidFields},
+
+		// Parsing of ipv4 addresses.
+		{"PROXY TCP4 a b c d", nil, nil, errInvalidSrcIP},
+		{"PROXY TCP4 1.1.1.1 b c d",
+			nil, nil, errInvalidDstIP},
+		{"PROXY TCP4 1.1.1.1 2.2.2.2 c d",
+			nil, nil, errInvalidSrcPort},
+		{"PROXY TCP4 1.1.1.1 2.2.2.2 3333 d",
+			nil, nil, errInvalidDstPort},
+		{"PROXY TCP4 1.1.1.1 2.2.2.2 3333 4444",
+			src4, dst4, nil},
+
+		// Parsing of ipv6 addresses.
+		{"PROXY TCP6 a b c d", nil, nil, errInvalidSrcIP},
+		{"PROXY TCP6 5::5 b c d",
+			nil, nil, errInvalidDstIP},
+		{"PROXY TCP6 5::5 6::6 c d",
+			nil, nil, errInvalidSrcPort},
+		{"PROXY TCP6 5::5 6::6 7777 d",
+			nil, nil, errInvalidDstPort},
+		{"PROXY TCP6 5::5 6::6 7777 8888",
+			src6, dst6, nil},
+	}
+
+	for i, c := range cases {
+		t.Logf("testing %d: %v", i, c.str)
+
+		src, dst, err := Handshake(newR(c.str))
+
+		if !addrEq(src, c.src) {
+			t.Errorf("%d: got src %v, expected %v", i, src, c.src)
+		}
+		if !addrEq(dst, c.dst) {
+			t.Errorf("%d: got dst %v, expected %v", i, dst, c.dst)
+		}
+		if err != c.err {
+			t.Errorf("%d: got error %v, expected %v", i, err, c.err)
+		}
+	}
+}
+
+func newR(s string) *bufio.Reader {
+	return bufio.NewReader(strings.NewReader(s + "\r\n"))
+}
+
+func addrEq(a, b net.Addr) bool {
+	if a == nil || b == nil {
+		return a == nil && b == nil
+	}
+
+	ta := a.(*net.TCPAddr)
+	tb := b.(*net.TCPAddr)
+	return ta.IP.Equal(tb.IP) && ta.Port == tb.Port
+}
diff --git a/internal/smtpsrv/conn.go b/internal/smtpsrv/conn.go
index 4ad7617..31d754d 100644
--- a/internal/smtpsrv/conn.go
+++ b/internal/smtpsrv/conn.go
@@ -25,6 +25,7 @@ import (
 	"blitiri.com.ar/go/chasquid/internal/domaininfo"
 	"blitiri.com.ar/go/chasquid/internal/envelope"
 	"blitiri.com.ar/go/chasquid/internal/expvarom"
+	"blitiri.com.ar/go/chasquid/internal/haproxy"
 	"blitiri.com.ar/go/chasquid/internal/maillog"
 	"blitiri.com.ar/go/chasquid/internal/normalize"
 	"blitiri.com.ar/go/chasquid/internal/queue"
@@ -104,6 +105,7 @@ type Conn struct {
 	conn         net.Conn
 	mode         SocketMode
 	tlsConnState *tls.ConnectionState
+	remoteAddr   net.Addr
 
 	// Reader and text writer, so we can control limits.
 	reader *bufio.Reader
@@ -158,6 +160,9 @@ type Conn struct {
 
 	// Time we wait for network operations.
 	commandTimeout time.Duration
+
+	// Enable HAProxy on incoming connections.
+	haproxyEnabled bool
 }
 
 // Close the connection.
@@ -199,6 +204,17 @@ func (c *Conn) Handle() {
 	c.reader = bufio.NewReader(c.conn)
 	c.writer = bufio.NewWriter(c.conn)
 
+	c.remoteAddr = c.conn.RemoteAddr()
+	if c.haproxyEnabled {
+		src, dst, err := haproxy.Handshake(c.reader)
+		if err != nil {
+			c.tr.Errorf("error in haproxy handshake: %v", err)
+			return
+		}
+		c.remoteAddr = src
+		c.tr.Debugf("haproxy handshake: %v -> %v", src, dst)
+	}
+
 	c.printfLine("220 %s ESMTP chasquid", c.hostname)
 
 	var cmd, params string
@@ -428,21 +444,21 @@ func (c *Conn) MAIL(params string) (code int, msg string) {
 		c.spfResult, c.spfError = c.checkSPF(addr)
 		if c.spfResult == spf.Fail {
 			// https://tools.ietf.org/html/rfc7208#section-8.4
-			maillog.Rejected(c.conn.RemoteAddr(), addr, nil,
+			maillog.Rejected(c.remoteAddr, addr, nil,
 				fmt.Sprintf("failed SPF: %v", c.spfError))
 			return 550, fmt.Sprintf(
 				"5.7.23 SPF check failed: %v", c.spfError)
 		}
 
 		if !c.secLevelCheck(addr) {
-			maillog.Rejected(c.conn.RemoteAddr(), addr, nil,
+			maillog.Rejected(c.remoteAddr, addr, nil,
 				"security level check failed")
 			return 550, "5.7.3 Security level check failed"
 		}
 
 		addr, err = normalize.DomainToUnicode(addr)
 		if err != nil {
-			maillog.Rejected(c.conn.RemoteAddr(), addr, nil,
+			maillog.Rejected(c.remoteAddr, addr, nil,
 				fmt.Sprintf("malformed address: %v", err))
 			return 501, "5.1.8 Malformed sender domain (IDNA conversion failed)"
 		}
@@ -463,7 +479,7 @@ func (c *Conn) checkSPF(addr string) (spf.Result, error) {
 		return "", nil
 	}
 
-	if tcp, ok := c.conn.RemoteAddr().(*net.TCPAddr); ok {
+	if tcp, ok := c.remoteAddr.(*net.TCPAddr); ok {
 		res, err := spf.CheckHostWithSender(
 			tcp.IP, envelope.DomainOf(addr), addr)
 
@@ -549,7 +565,7 @@ func (c *Conn) RCPT(params string) (code int, msg string) {
 
 	localDst := envelope.DomainIn(addr, c.localDomains)
 	if !localDst && !c.completedAuth {
-		maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr},
+		maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr},
 			"relay not allowed")
 		return 503, "5.7.1 Relay not allowed"
 	}
@@ -557,13 +573,13 @@ func (c *Conn) RCPT(params string) (code int, msg string) {
 	if localDst {
 		addr, err = normalize.Addr(addr)
 		if err != nil {
-			maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr},
+			maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr},
 				fmt.Sprintf("invalid address: %v", err))
 			return 550, "5.1.3 Destination address is invalid"
 		}
 
 		if !c.userExists(addr) {
-			maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr},
+			maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr},
 				"local user does not exist")
 			return 550, "5.1.1 Destination address is unknown (user does not exist)"
 		}
@@ -621,7 +637,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
 	c.tr.Debugf("-> ... %d bytes of data", len(c.data))
 
 	if err := checkData(c.data); err != nil {
-		maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, err.Error())
+		maillog.Rejected(c.remoteAddr, c.mailFrom, c.rcptTo, err.Error())
 		return 554, err.Error()
 	}
 
@@ -629,7 +645,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
 
 	hookOut, permanent, err := c.runPostDataHook(c.data)
 	if err != nil {
-		maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, err.Error())
+		maillog.Rejected(c.remoteAddr, c.mailFrom, c.rcptTo, err.Error())
 		if permanent {
 			return 554, err.Error()
 		}
@@ -646,7 +662,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
 	}
 
 	c.tr.Printf("Queued from %s to %s - %s", c.mailFrom, c.rcptTo, msgID)
-	maillog.Queued(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, msgID)
+	maillog.Queued(c.remoteAddr, c.mailFrom, c.rcptTo, msgID)
 
 	// It is very important that we reset the envelope before returning,
 	// so clients can send other emails right away without needing to RSET.
@@ -677,7 +693,7 @@ func (c *Conn) addReceivedHeader() {
 		// and then the given EHLO domain for convenience and
 		// troubleshooting.
 		v += fmt.Sprintf("from [%s] (%s)\n",
-			addrLiteral(c.conn.RemoteAddr()), c.ehloDomain)
+			addrLiteral(c.remoteAddr), c.ehloDomain)
 	}
 
 	v += fmt.Sprintf("by %s (chasquid) ", c.hostname)
@@ -800,7 +816,7 @@ func (c *Conn) runPostDataHook(data []byte) ([]byte, bool, error) {
 		hookResults.Add("post-data:skip", 1)
 		return nil, false, nil
 	}
-	tr := trace.New("Hook.Post-DATA", c.conn.RemoteAddr().String())
+	tr := trace.New("Hook.Post-DATA", c.remoteAddr.String())
 	defer tr.Finish()
 
 	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
@@ -813,7 +829,7 @@ func (c *Conn) runPostDataHook(data []byte) ([]byte, bool, error) {
 	for _, v := range strings.Fields("USER PWD SHELL PATH") {
 		cmd.Env = append(cmd.Env, v+"="+os.Getenv(v))
 	}
-	cmd.Env = append(cmd.Env, "REMOTE_ADDR="+c.conn.RemoteAddr().String())
+	cmd.Env = append(cmd.Env, "REMOTE_ADDR="+c.remoteAddr.String())
 	cmd.Env = append(cmd.Env, "EHLO_DOMAIN="+sanitizeEHLODomain(c.ehloDomain))
 	cmd.Env = append(cmd.Env, "EHLO_DOMAIN_RAW="+c.ehloDomain)
 	cmd.Env = append(cmd.Env, "MAIL_FROM="+c.mailFrom)
@@ -1042,18 +1058,18 @@ func (c *Conn) AUTH(params string) (code int, msg string) {
 	authOk, err := c.authr.Authenticate(user, domain, passwd)
 	if err != nil {
 		c.tr.Errorf("error authenticating %q@%q: %v", user, domain, err)
-		maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, false)
+		maillog.Auth(c.remoteAddr, user+"@"+domain, false)
 		return 454, "4.7.0 Temporary authentication failure"
 	}
 	if authOk {
 		c.authUser = user
 		c.authDomain = domain
 		c.completedAuth = true
-		maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, true)
+		maillog.Auth(c.remoteAddr, user+"@"+domain, true)
 		return 235, "2.7.0 Authentication successful"
 	}
 
-	maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, false)
+	maillog.Auth(c.remoteAddr, user+"@"+domain, false)
 	return 535, "5.7.8 Incorrect user or password"
 }
 
diff --git a/internal/smtpsrv/server.go b/internal/smtpsrv/server.go
index bd66140..d19fa0f 100644
--- a/internal/smtpsrv/server.go
+++ b/internal/smtpsrv/server.go
@@ -45,6 +45,9 @@ type Server struct {
 	// TLS config (including loaded certificates).
 	tlsConfig *tls.Config
 
+	// Use HAProxy on incoming connections.
+	HAProxyEnabled bool
+
 	// Local domains.
 	localDomains *set.String
 
@@ -257,6 +260,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
 			conn:           conn,
 			mode:           mode,
 			tlsConfig:      s.tlsConfig,
+			haproxyEnabled: s.HAProxyEnabled,
 			onTLS:          mode.TLS,
 			authr:          s.authr,
 			aliasesR:       s.aliasesR,
diff --git a/test/Dockerfile b/test/Dockerfile
index 122808f..352966a 100644
--- a/test/Dockerfile
+++ b/test/Dockerfile
@@ -25,7 +25,8 @@ RUN apt-get install -y -q python3 msmtp
 # Install the optional packages for the integration tests.
 RUN apt-get install -y -q \
 	gettext-base dovecot-imapd \
-	exim4-daemon-light
+	exim4-daemon-light \
+	haproxy
 
 # Install sudo, needed for the docker entrypoint.
 RUN apt-get install -y -q sudo
diff --git a/test/README.md b/test/README.md
index 0617e14..74d9e7b 100644
--- a/test/README.md
+++ b/test/README.md
@@ -40,6 +40,8 @@ if the dependencies are not found:
 - `t-15-driusan_dkim` DKIM integration tests:
     - The `dkimsign dkimverify dkimkeygen` binaries, from
       [driusan/dkim](https://github.com/driusan/dkim) (no Debian package yet).
+- `t-18-haproxy` HAProxy integration tests:
+    - `haproxy`
 
 For some tests, python >= 3.5 is required; they will be skipped if it's not
 available.
diff --git a/test/t-18-haproxy/config/chasquid.conf b/test/t-18-haproxy/config/chasquid.conf
new file mode 100644
index 0000000..377a5ef
--- /dev/null
+++ b/test/t-18-haproxy/config/chasquid.conf
@@ -0,0 +1,12 @@
+smtp_address: ":2025"
+submission_address: ":2587"
+submission_over_tls_address: ":2465"
+monitoring_address: ":2099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data"
+mail_log_path: "../.logs/mail_log"
+
+haproxy_incoming: true
diff --git a/test/t-18-haproxy/content b/test/t-18-haproxy/content
new file mode 100644
index 0000000..76a8b16
--- /dev/null
+++ b/test/t-18-haproxy/content
@@ -0,0 +1,4 @@
+Subject: Prueba desde el test
+
+Crece desde el test el futuro
+Crece desde el test
diff --git a/test/t-18-haproxy/haproxy.cfg b/test/t-18-haproxy/haproxy.cfg
new file mode 100644
index 0000000..90dafad
--- /dev/null
+++ b/test/t-18-haproxy/haproxy.cfg
@@ -0,0 +1,7 @@
+global
+	debug
+
+listen smtp-in
+	mode tcp
+	bind *:1025
+	server srv1 localhost:2025 send-proxy
diff --git a/test/t-18-haproxy/hosts b/test/t-18-haproxy/hosts
new file mode 100644
index 0000000..2b9b623
--- /dev/null
+++ b/test/t-18-haproxy/hosts
@@ -0,0 +1 @@
+testserver localhost
diff --git a/test/t-18-haproxy/msmtprc b/test/t-18-haproxy/msmtprc
new file mode 100644
index 0000000..e39a7fc
--- /dev/null
+++ b/test/t-18-haproxy/msmtprc
@@ -0,0 +1,14 @@
+account default
+
+host testserver
+port 1025
+
+tls on
+tls_trust_file config/certs/testserver/fullchain.pem
+
+from user@testserver
+
+auth on
+user user@testserver
+password secretpassword
+
diff --git a/test/t-18-haproxy/run.sh b/test/t-18-haproxy/run.sh
new file mode 100755
index 0000000..2bb7351
--- /dev/null
+++ b/test/t-18-haproxy/run.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+
+set -e
+. $(dirname ${0})/../util/lib.sh
+
+init
+
+mkdir -p .logs
+
+if ! haproxy -v > /dev/null; then
+	skip "haproxy binary not found"
+	exit 0
+fi
+
+# Set a 2m timeout: if there are issues with haproxy, the wait tends to hang
+# indefinitely, so an explicit timeout helps with test automation.
+timeout 2m
+
+# Launch haproxy in the background, checking config first to fail fast in that
+# case.
+haproxy -f haproxy.cfg -c
+haproxy -f haproxy.cfg > .logs/haproxy.log 2>&1 &
+
+generate_certs_for testserver
+add_user user@testserver secretpassword
+add_user someone@testserver secretpassword
+
+chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config &
+
+wait_until_ready 1025 # haproxy
+wait_until_ready 2025 # chasquid
+
+run_msmtp someone@testserver < content
+
+wait_for_file .mail/someone@testserver
+
+mail_diff content .mail/someone@testserver
+
+success
diff --git a/test/util/lib.sh b/test/util/lib.sh
index f637655..b0bf3fe 100644
--- a/test/util/lib.sh
+++ b/test/util/lib.sh
@@ -123,6 +123,15 @@ function fexp() {
 	${UTILDIR}/fexp "$@"
 }
 
+function timeout() {
+	MYPID=$$
+	(
+		sleep $1
+		echo "timed out after $1, killing test"
+		kill -9 $MYPID
+	) &
+}
+
 function success() {
 	echo success
 }