1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-19 14:57:04 +00:00

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!
This commit is contained in:
Alberto Bertogli
2020-11-12 22:00:46 +00:00
parent c9d3ba0ca0
commit e79586a014
22 changed files with 389 additions and 24 deletions

View File

@@ -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)
}

View File

@@ -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 (

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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,