Support SNI routing with Postgres STARTTLS connections
Co-authored-by: Michael Kuhnt <michael.kuhnt@daimler.com> Co-authored-by: Julien Salleyron <julien@containo.us> Co-authored-by: Mathieu Lonjaret <mathieu.lonjaret@gmail.com>
This commit is contained in:
parent
fadee5e87b
commit
630de7481e
4 changed files with 295 additions and 13 deletions
|
@ -233,18 +233,18 @@ If the rule is verified, the router becomes active, calls middlewares, and then
|
||||||
|
|
||||||
The table below lists all the available matchers:
|
The table below lists all the available matchers:
|
||||||
|
|
||||||
| Rule | Description |
|
| Rule | Description |
|
||||||
|--------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
|
|------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||||
| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` |
|
| ```Headers(`key`, `value`)``` | Check if there is a key `key`defined in the headers, with the value `value` |
|
||||||
| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` |
|
| ```HeadersRegexp(`key`, `regexp`)``` | Check if there is a key `key`defined in the headers, with a value that matches the regular expression `regexp` |
|
||||||
| ```Host(`example.com`, ...)``` | Check if the request domain (host header value) targets one of the given `domains`. |
|
| ```Host(`example.com`, ...)``` | Check if the request domain (host header value) targets one of the given `domains`. |
|
||||||
| ```HostHeader(`example.com`, ...)``` | Same as `Host`, only exists for historical reasons. |
|
| ```HostHeader(`example.com`, ...)``` | Same as `Host`, only exists for historical reasons. |
|
||||||
| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Match the request domain. See "Regexp Syntax" below. |
|
| ```HostRegexp(`example.com`, `{subdomain:[a-z]+}.example.com`, ...)``` | Match the request domain. See "Regexp Syntax" below. |
|
||||||
| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`) |
|
| ```Method(`GET`, ...)``` | Check if the request method is one of the given `methods` (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`) |
|
||||||
| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. See "Regexp Syntax" below. |
|
| ```Path(`/path`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`, ...)``` | Match exact request path. See "Regexp Syntax" below. |
|
||||||
| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. See "Regexp Syntax" below. |
|
| ```PathPrefix(`/products/`, `/articles/{cat:[a-z]+}/{id:[0-9]+}`)``` | Match request prefix path. See "Regexp Syntax" below. |
|
||||||
| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. |
|
| ```Query(`foo=bar`, `bar=baz`)``` | Match Query String parameters. It accepts a sequence of key=value pairs. |
|
||||||
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Match if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. |
|
| ```ClientIP(`10.0.0.0/16`, `::1`)``` | Match if the request client IP is one of the given IP/CIDR. It accepts IPv4, IPv6 and CIDR formats. |
|
||||||
|
|
||||||
!!! important "Non-ASCII Domain Names"
|
!!! important "Non-ASCII Domain Names"
|
||||||
|
|
||||||
|
@ -1041,6 +1041,30 @@ By default, a router with a TLS section will terminate the TLS connections, mean
|
||||||
[tcp.routers.Router-1.tls]
|
[tcp.routers.Router-1.tls]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
??? info "Postgres STARTTLS"
|
||||||
|
|
||||||
|
Traefik supports the Postgres STARTTLS protocol,
|
||||||
|
which allows TLS routing for Postgres connections.
|
||||||
|
|
||||||
|
To do so, Traefik reads the first bytes sent by a Postgres client,
|
||||||
|
identifies if they correspond to the message of a STARTTLS negotiation,
|
||||||
|
and, if so, acknowledges and signals the client that it can start the TLS handshake.
|
||||||
|
|
||||||
|
Please note/remember that there are subtleties inherent to STARTTLS in whether
|
||||||
|
the connection ends up being a TLS one or not. These subtleties depend on the
|
||||||
|
`sslmode` value in the client configuration (and on the server authentication
|
||||||
|
rules). Therefore, it is recommended to use the `require` value for the
|
||||||
|
`sslmode`.
|
||||||
|
|
||||||
|
Afterwards, the TLS handshake, and routing based on TLS, can proceed as expected.
|
||||||
|
|
||||||
|
!!! warning "Postgres STARTTLS with TCP TLS PassThrough routers"
|
||||||
|
|
||||||
|
As mentioned above, the `sslmode` configuration parameter does have an impact on
|
||||||
|
whether a STARTTLS session will succeed. In particular in the context of TCP TLS
|
||||||
|
PassThrough, some of the values (such as `allow`) do not even make sense. Which
|
||||||
|
is why, once more it is recommended to use the `require` value.
|
||||||
|
|
||||||
#### `passthrough`
|
#### `passthrough`
|
||||||
|
|
||||||
As seen above, a TLS router will terminate the TLS connection by default.
|
As seen above, a TLS router will terminate the TLS connection by default.
|
||||||
|
|
161
pkg/server/router/tcp/postgres.go
Normal file
161
pkg/server/router/tcp/postgres.go
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
package tcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/traefik/traefik/v2/pkg/log"
|
||||||
|
tcpmuxer "github.com/traefik/traefik/v2/pkg/muxer/tcp"
|
||||||
|
"github.com/traefik/traefik/v2/pkg/tcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
PostgresStartTLSMsg = []byte{0, 0, 0, 8, 4, 210, 22, 47} // int32(8) + int32(80877103)
|
||||||
|
PostgresStartTLSReply = []byte{83} // S
|
||||||
|
)
|
||||||
|
|
||||||
|
// isPostgres determines whether the buffer contains the Postgres STARTTLS message.
|
||||||
|
func isPostgres(br *bufio.Reader) (bool, error) {
|
||||||
|
// Peek the first 8 bytes individually to prevent blocking on peek
|
||||||
|
// if the underlying conn does not send enough bytes.
|
||||||
|
// It could happen if a protocol start by sending less than 8 bytes,
|
||||||
|
// and expect a response before proceeding.
|
||||||
|
for i := 1; i < len(PostgresStartTLSMsg)+1; i++ {
|
||||||
|
peeked, err := br.Peek(i)
|
||||||
|
if err != nil {
|
||||||
|
log.WithoutContext().Errorf("Error while Peeking first bytes: %s", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(peeked, PostgresStartTLSMsg[:i]) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// servePostgres serves a connection with a Postgres client negotiating a STARTTLS session.
|
||||||
|
// It handles TCP TLS routing, after accepting to start the STARTTLS session.
|
||||||
|
func (r *Router) servePostgres(conn tcp.WriteCloser) {
|
||||||
|
_, err := conn.Write(PostgresStartTLSReply)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
b := make([]byte, len(PostgresStartTLSMsg))
|
||||||
|
_, err = br.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hello, err := clientHelloInfo(br)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hello.isTLS {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connData, err := tcpmuxer.NewConnData(hello.serverName, conn, hello.protos)
|
||||||
|
if err != nil {
|
||||||
|
log.WithoutContext().Errorf("Error while reading TCP connection data: %v", err)
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains also TCP TLS passthrough routes.
|
||||||
|
handlerTCPTLS, _ := r.muxerTCPTLS.Match(connData)
|
||||||
|
if handlerTCPTLS == nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are in TLS mode and if the handler is not TLSHandler, we are in passthrough.
|
||||||
|
proxiedConn := r.GetConn(conn, hello.peeked)
|
||||||
|
if _, ok := handlerTCPTLS.(*tcp.TLSHandler); !ok {
|
||||||
|
proxiedConn = &postgresConn{WriteCloser: proxiedConn}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerTCPTLS.ServeTCP(proxiedConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postgresConn is a tcp.WriteCloser that will negotiate a TLS session (STARTTLS),
|
||||||
|
// before exchanging any data.
|
||||||
|
// It enforces that the STARTTLS negotiation with the peer is successful.
|
||||||
|
type postgresConn struct {
|
||||||
|
tcp.WriteCloser
|
||||||
|
|
||||||
|
starttlsMsgSent bool // whether we have already sent the STARTTLS handshake to the backend.
|
||||||
|
starttlsReplyReceived bool // whether we have already received the STARTTLS handshake reply from the backend.
|
||||||
|
|
||||||
|
// errChan makes sure that an error is returned if the first operation to ever
|
||||||
|
// happen on a postgresConn is a Write (because it should instead be a Read).
|
||||||
|
errChanMu sync.Mutex
|
||||||
|
errChan chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads bytes from the underlying connection (tcp.WriteCloser).
|
||||||
|
// On first call, it actually only injects the PostgresStartTLSMsg,
|
||||||
|
// in order to behave as a Postgres TLS client that initiates a STARTTLS handshake.
|
||||||
|
// Read does not support concurrent calls.
|
||||||
|
func (c *postgresConn) Read(p []byte) (n int, err error) {
|
||||||
|
if c.starttlsMsgSent {
|
||||||
|
if err := <-c.errChan; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.WriteCloser.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.starttlsMsgSent = true
|
||||||
|
c.errChanMu.Lock()
|
||||||
|
c.errChan = make(chan error)
|
||||||
|
c.errChanMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
copy(p, PostgresStartTLSMsg)
|
||||||
|
return len(PostgresStartTLSMsg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes bytes to the underlying connection (tcp.WriteCloser).
|
||||||
|
// On first call, it checks that the bytes to write (the ones provided by the backend)
|
||||||
|
// match the PostgresStartTLSReply, and if yes it drops them (as the STARTTLS
|
||||||
|
// handshake between the client and traefik has already taken place). Otherwise, an
|
||||||
|
// error is transmitted through c.errChan, so that the second Read call gets it and
|
||||||
|
// returns it up the stack.
|
||||||
|
// Write does not support concurrent calls.
|
||||||
|
func (c *postgresConn) Write(p []byte) (n int, err error) {
|
||||||
|
if c.starttlsReplyReceived {
|
||||||
|
return c.WriteCloser.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.errChanMu.Lock()
|
||||||
|
if c.errChan == nil {
|
||||||
|
c.errChanMu.Unlock()
|
||||||
|
return 0, errors.New("initial read never happened")
|
||||||
|
}
|
||||||
|
c.errChanMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.starttlsReplyReceived = true
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(p) != 1 || p[0] != PostgresStartTLSReply[0] {
|
||||||
|
c.errChan <- errors.New("invalid response from Postgres server")
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
close(c.errChan)
|
||||||
|
|
||||||
|
return 1, nil
|
||||||
|
}
|
|
@ -108,6 +108,17 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
||||||
|
|
||||||
// TODO -- Check if ProxyProtocol changes the first bytes of the request
|
// TODO -- Check if ProxyProtocol changes the first bytes of the request
|
||||||
br := bufio.NewReader(conn)
|
br := bufio.NewReader(conn)
|
||||||
|
postgres, err := isPostgres(br)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if postgres {
|
||||||
|
r.servePostgres(r.GetConn(conn, getPeeked(br)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hello, err := clientHelloInfo(br)
|
hello, err := clientHelloInfo(br)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
@ -277,7 +288,7 @@ func (r *Router) SetHTTPSHandler(handler http.Handler, config *tls.Config) {
|
||||||
type Conn struct {
|
type Conn struct {
|
||||||
// Peeked are the bytes that have been read from Conn for the
|
// Peeked are the bytes that have been read from Conn for the
|
||||||
// purposes of route matching, but have not yet been consumed
|
// purposes of route matching, but have not yet been consumed
|
||||||
// by Read calls. It set to nil by Read when fully consumed.
|
// by Read calls. It is set to nil by Read when fully consumed.
|
||||||
Peeked []byte
|
Peeked []byte
|
||||||
|
|
||||||
// Conn is the underlying connection.
|
// Conn is the underlying connection.
|
||||||
|
|
|
@ -922,3 +922,89 @@ func checkHTTPSTLS10(addr string, timeout time.Duration) error {
|
||||||
func checkHTTPSTLS12(addr string, timeout time.Duration) error {
|
func checkHTTPSTLS12(addr string, timeout time.Duration) error {
|
||||||
return checkHTTPS(addr, timeout, tls.VersionTLS12)
|
return checkHTTPS(addr, timeout, tls.VersionTLS12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostgres(t *testing.T) {
|
||||||
|
router, err := NewRouter()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// This test requires to have a TLS route, but does not actually check the
|
||||||
|
// content of the handler. It would require to code a TLS handshake to
|
||||||
|
// check the SNI and content of the handlerFunc.
|
||||||
|
err = router.AddRouteTLS("HostSNI(`test.localhost`)", 0, nil, &tls.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = router.AddRoute("HostSNI(`*`)", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) {
|
||||||
|
_, _ = conn.Write([]byte("OK"))
|
||||||
|
_ = conn.Close()
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockConn := NewMockConn()
|
||||||
|
go router.ServeTCP(mockConn)
|
||||||
|
|
||||||
|
mockConn.dataRead <- PostgresStartTLSMsg
|
||||||
|
b := <-mockConn.dataWrite
|
||||||
|
require.Equal(t, PostgresStartTLSReply, b)
|
||||||
|
|
||||||
|
mockConn = NewMockConn()
|
||||||
|
go router.ServeTCP(mockConn)
|
||||||
|
|
||||||
|
mockConn.dataRead <- []byte("HTTP")
|
||||||
|
b = <-mockConn.dataWrite
|
||||||
|
require.Equal(t, []byte("OK"), b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockConn() *MockConn {
|
||||||
|
return &MockConn{
|
||||||
|
dataRead: make(chan []byte),
|
||||||
|
dataWrite: make(chan []byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockConn struct {
|
||||||
|
dataRead chan []byte
|
||||||
|
dataWrite chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) Read(b []byte) (n int, err error) {
|
||||||
|
temp := <-m.dataRead
|
||||||
|
copy(b, temp)
|
||||||
|
return len(temp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) Write(b []byte) (n int, err error) {
|
||||||
|
m.dataWrite <- b
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) Close() error {
|
||||||
|
close(m.dataRead)
|
||||||
|
close(m.dataWrite)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) LocalAddr() net.Addr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) RemoteAddr() net.Addr {
|
||||||
|
return &net.TCPAddr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) SetDeadline(t time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) SetReadDeadline(t time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) SetWriteDeadline(t time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockConn) CloseWrite() error {
|
||||||
|
close(m.dataRead)
|
||||||
|
close(m.dataWrite)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue