From 3c4fe105cda6d222bd17f143b7829ffd73dfcd35 Mon Sep 17 00:00:00 2001 From: Aleksei Zhukov Date: Wed, 9 Mar 2022 18:49:53 -0800 Subject: [PATCH] Proxy protocol support for SMTP and IMAP --- docs/reference/endpoints/imap.md | 19 +++++ docs/reference/endpoints/smtp.md | 15 ++++ go.mod | 1 + go.sum | 2 + internal/endpoint/imap/imap.go | 15 +++- internal/endpoint/smtp/smtp.go | 23 ++++-- internal/proxy_protocol/proxy_protocol.go | 86 ++++++++++++++++++++++ tests/smtp_test.go | 89 +++++++++++++++++++++++ 8 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 internal/proxy_protocol/proxy_protocol.go diff --git a/docs/reference/endpoints/imap.md b/docs/reference/endpoints/imap.md index 06247c3..943291a 100644 --- a/docs/reference/endpoints/imap.md +++ b/docs/reference/endpoints/imap.md @@ -40,6 +40,25 @@ tls cert.crt key.key { See [TLS configuration / Server](/reference/tls/#server-side) for details. +**Syntax**: proxy_protocol _trusted ips..._ { ... }
+**Default**: not enabled + +Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols. +If a list of trusted IP addresses or subnets is provided, only connections +from those will be trusted. + +TLS for the channel between the proxies and maddy can be configured +using a 'tls' directive: +``` +proxy_protocol { + trust 127.0.0.1 ::1 192.168.0.1/24 + tls &proxy_tls +} +``` +Note that the top-level 'tls' directive is not inherited here. If you +need TLS on top of the PROXY protocol, securing the protocol header, +you must declare TLS explicitly. + **Syntax**: io\_debug _boolean_
**Default**: no diff --git a/docs/reference/endpoints/smtp.md b/docs/reference/endpoints/smtp.md index cd99df9..8849d25 100644 --- a/docs/reference/endpoints/smtp.md +++ b/docs/reference/endpoints/smtp.md @@ -58,6 +58,21 @@ tls cert.crt key.key { See [TLS configuration / Server](/reference/tls/#server-side) for details. +**Syntax**: proxy_protocol _trusted ips..._ { ... }
+**Default**: not enabled + +Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols. +If a list of trusted IP addresses or subnets is provided, only connections +from those will be trusted. + +TLS for the channel between the proxies and maddy can be configured +using a 'tls' directive: +``` +proxy_protocol { + trust 127.0.0.1 ::1 192.168.0.1/24 + tls &proxy_tls +} +``` **Syntax**: io\_debug _boolean_
**Default**: no diff --git a/go.mod b/go.mod index 2b3f9a1..1aaad67 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect github.com/aws/smithy-go v1.13.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/c0va23/go-proxyprotocol v0.9.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/digitalocean/godo v1.96.0 // indirect diff --git a/go.sum b/go.sum index ddf6797..868d84d 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,8 @@ github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLj github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/c0va23/go-proxyprotocol v0.9.1 h1:5BCkp0fDJOhzzH1lhjUgHhmZz9VvRMMif1U2D31hb34= +github.com/c0va23/go-proxyprotocol v0.9.1/go.mod h1:TNjUV+llvk8TvWJxlPYAeAYZgSzT/iicNr3nWBWX320= github.com/caddyserver/certmagic v0.17.2 h1:o30seC1T/dBqBCNNGNHWwj2i5/I/FMjBbTAhjADP3nE= github.com/caddyserver/certmagic v0.17.2/go.mod h1:ouWUuC490GOLJzkyN35eXfV8bSbwMwSf4bdhkIxtdQE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/internal/endpoint/imap/imap.go b/internal/endpoint/imap/imap.go index c7aac5e..d047e88 100644 --- a/internal/endpoint/imap/imap.go +++ b/internal/endpoint/imap/imap.go @@ -44,14 +44,16 @@ import ( "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/auth" "github.com/foxcpp/maddy/internal/authz" + "github.com/foxcpp/maddy/internal/proxy_protocol" "github.com/foxcpp/maddy/internal/updatepipe" ) type Endpoint struct { - addrs []string - serv *imapserver.Server - listeners []net.Listener - Store module.Storage + addrs []string + serv *imapserver.Server + listeners []net.Listener + proxyProtocol *proxy_protocol.ProxyProtocol + Store module.Storage tlsConfig *tls.Config listenersWg sync.WaitGroup @@ -90,6 +92,7 @@ func (endp *Endpoint) Init(cfg *config.Map) error { }) cfg.Custom("storage", false, true, nil, modconfig.StorageDirective, &endp.Store) cfg.Custom("tls", true, true, nil, tls2.TLSDirective, &endp.tlsConfig) + cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol) cfg.Bool("insecure_auth", false, false, &insecureAuth) cfg.Bool("io_debug", false, false, &ioDebug) cfg.Bool("io_errors", false, false, &ioErrors) @@ -167,6 +170,10 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { l = tls.NewListener(l, endp.tlsConfig) } + if endp.proxyProtocol != nil { + l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.Log) + } + endp.listeners = append(endp.listeners, l) endp.listenersWg.Add(1) diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go index a322b6d..e01ae57 100644 --- a/internal/endpoint/smtp/smtp.go +++ b/internal/endpoint/smtp/smtp.go @@ -46,18 +46,20 @@ import ( "github.com/foxcpp/maddy/internal/authz" "github.com/foxcpp/maddy/internal/limits" "github.com/foxcpp/maddy/internal/msgpipeline" + "github.com/foxcpp/maddy/internal/proxy_protocol" "golang.org/x/net/idna" ) type Endpoint struct { - saslAuth auth.SASLAuth - serv *smtp.Server - name string - addrs []string - listeners []net.Listener - pipeline *msgpipeline.MsgPipeline - resolver dns.Resolver - limits *limits.Group + saslAuth auth.SASLAuth + serv *smtp.Server + name string + addrs []string + listeners []net.Listener + proxyProtocol *proxy_protocol.ProxyProtocol + pipeline *msgpipeline.MsgPipeline + resolver dns.Resolver + limits *limits.Group buffer func(r io.Reader) (buffer.Buffer, error) @@ -263,6 +265,7 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error { return autoBufferMode(1*1024*1024 /* 1 MiB */, path), nil }, bufferModeDirective, &endp.buffer) cfg.Custom("tls", true, endp.name != "lmtp", nil, tls2.TLSDirective, &endp.serv.TLSConfig) + cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol) cfg.Bool("insecure_auth", endp.name == "lmtp", false, &endp.serv.AllowInsecureAuth) cfg.Int("smtp_max_line_length", false, false, 4000, &endp.serv.MaxLineLength) cfg.Bool("io_debug", false, false, &ioDebug) @@ -350,6 +353,10 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { l = tls.NewListener(l, endp.serv.TLSConfig) } + if endp.proxyProtocol != nil { + l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.Log) + } + endp.listeners = append(endp.listeners, l) endp.listenersWg.Add(1) diff --git a/internal/proxy_protocol/proxy_protocol.go b/internal/proxy_protocol/proxy_protocol.go new file mode 100644 index 0000000..1a3a787 --- /dev/null +++ b/internal/proxy_protocol/proxy_protocol.go @@ -0,0 +1,86 @@ +package proxy_protocol + +import ( + "crypto/tls" + "net" + "strings" + + "github.com/c0va23/go-proxyprotocol" + "github.com/foxcpp/maddy/framework/config" + tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/log" +) + +type ProxyProtocol struct { + trust []net.IPNet + tlsConfig *tls.Config +} + +func ProxyProtocolDirective(_ *config.Map, node config.Node) (interface{}, error) { + p := ProxyProtocol{} + + childM := config.NewMap(nil, node) + var trustList []string + + childM.StringList("trust", false, false, nil, &trustList) + childM.Custom("tls", true, false, nil, tls2.TLSDirective, &p.tlsConfig) + + if _, err := childM.Process(); err != nil { + return nil, err + } + + if len(node.Args) > 0 { + if trustList == nil { + trustList = make([]string, 0) + } + trustList = append(trustList, node.Args...) + } + + for _, trust := range trustList { + if !strings.Contains(trust, "/") { + trust += "/32" + } + _, ipNet, err := net.ParseCIDR(trust) + if err != nil { + return nil, err + } + p.trust = append(p.trust, *ipNet) + } + + return &p, nil +} + +func NewListener(inner net.Listener, p *ProxyProtocol, logger log.Logger) net.Listener { + var listener net.Listener + + sourceChecker := func(upstream net.Addr) (bool, error) { + if tcpAddr, ok := upstream.(*net.TCPAddr); ok { + if len(p.trust) == 0 { + return true, nil + } + for _, trusted := range p.trust { + if trusted.Contains(tcpAddr.IP) { + return true, nil + } + } + } else if _, ok := upstream.(*net.UnixAddr); ok { + // UNIX local socket connection, always trusted + return true, nil + } + + logger.Printf("proxy_protocol: connection from untrusted source %s", upstream) + return false, nil + } + + listener = proxyprotocol.NewDefaultListener(inner). + WithLogger(proxyprotocol.LoggerFunc(func(format string, v ...interface{}) { + logger.Debugf("proxy_protocol: "+format, v...) + })). + WithSourceChecker(sourceChecker) + + if p.tlsConfig != nil { + listener = tls.NewListener(listener, p.tlsConfig) + } + + return listener +} diff --git a/tests/smtp_test.go b/tests/smtp_test.go index 85a5173..7e5eda1 100644 --- a/tests/smtp_test.go +++ b/tests/smtp_test.go @@ -23,6 +23,7 @@ package tests_test import ( "errors" + "fmt" "io/ioutil" "path/filepath" "strings" @@ -68,6 +69,94 @@ func TestCheckRequireTLS(tt *testing.T) { conn.ExpectPattern("221 *") } +func TestProxyProtocolTrustedSource(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(map[string]mockdns.Zone{ + "one.maddy.test.": { + TXT: []string{"v=spf1 ip4:127.0.0.17 -all"}, + }, + }) + t.Port("smtp") + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname mx.maddy.test + tls off + + proxy_protocol { + trust ` + tests.DefaultSourceIP.String() + ` ::1/128 + tls off + } + + defer_sender_reject no + + check { + spf { + enforce_early yes + fail_action reject + } + } + + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + conn := t.Conn("smtp") + defer conn.Close() + conn.Writeln(fmt.Sprintf("PROXY TCP4 127.0.0.17 %s 12345 %d", tests.DefaultSourceIP.String(), t.Port("smtp"))) + conn.SMTPNegotation("localhost", nil, nil) + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("250 *") + conn.Writeln("QUIT") + conn.ExpectPattern("221 *") +} + +func TestProxyProtocolUntrustedSource(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(map[string]mockdns.Zone{ + "one.maddy.test.": { + TXT: []string{"v=spf1 ip4:127.0.0.17 -all"}, + }, + }) + t.Port("smtp") + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname mx.maddy.test + tls off + + proxy_protocol { + trust fe80::bad/128 + tls off + } + + defer_sender_reject no + + check { + spf { + enforce_early yes + fail_action reject + } + } + + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + conn := t.Conn("smtp") + defer conn.Close() + conn.Writeln(fmt.Sprintf("PROXY TCP4 127.0.0.17 %s 12345 %d", tests.DefaultSourceIP.String(), t.Port("smtp"))) + conn.SMTPNegotation("localhost", nil, nil) + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("550 *") + conn.Writeln("QUIT") + conn.ExpectPattern("221 *") +} + func TestCheckSPF(tt *testing.T) { tt.Parallel() t := tests.NewT(tt)