feat: serve plaintext PostgreSQL when TLS is not configured
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 5m3s
Test and Release PawSQL / test (push) Successful in 43s
Test and Release PawSQL / release (push) Successful in 7s

This commit is contained in:
2026-09-15 22:03:58 -04:00
parent 4e305d67e3
commit e67698f3c2
6 changed files with 110 additions and 36 deletions

View File

@@ -6,7 +6,7 @@ PawSQL is a TLS-terminating PostgreSQL router. It accepts PostgreSQL clients on
- Go 1.24 or later to build and run PawSQL natively. - Go 1.24 or later to build and run PawSQL natively.
- Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers. - Docker Engine and a usable `docker` CLI to build the PawSQL image. PawSQL also needs them in its own execution environment when it manages PostgreSQL containers.
- A TLS certificate and private key readable by PawSQL. The certificate must cover every hostname clients use for SNI routing. - Optional: a TLS certificate and private key readable by PawSQL. Omit the `tls` block to serve plaintext PostgreSQL; with TLS, the certificate must cover every hostname clients use for SNI routing.
- Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`. - Docker Engine access for each `postgres` route. Managed database images are limited to `postgres:16`, `postgres:17`, and `postgres:18`.
## Build, configure, and run ## Build, configure, and run
@@ -83,13 +83,15 @@ docker compose up --build
## Routing and TLS ## Routing and TLS
PawSQL requires PostgreSQL's SSL negotiation and terminates client TLS before proxying PostgreSQL bytes to the selected upstream. When the Barkfile configures `tls`, PawSQL handles PostgreSQL's SSL negotiation and terminates client TLS before proxying PostgreSQL bytes to the selected upstream:
- **With SNI:** PawSQL uses the TLS server name to select an exact configured `hostname` match. Hostname matching is case-insensitive and ignores a trailing dot. An unknown SNI name is rejected; PawSQL does not fall back to a database-name route when SNI is present. - **With SNI:** PawSQL uses the TLS server name to select an exact configured `hostname` match. Hostname matching is case-insensitive and ignores a trailing dot. An unknown SNI name is rejected; PawSQL does not fall back to a database-name route when SNI is present.
- **Without SNI:** After TLS is established, PawSQL reads the PostgreSQL startup message and selects the route whose `database` name exactly matches the requested PostgreSQL database. This makes a route without `hostname` usable by non-SNI clients. - **Without SNI:** After TLS is established, PawSQL reads the PostgreSQL startup message and selects the route whose `database` name exactly matches the requested PostgreSQL database. This makes a route without `hostname` usable by non-SNI clients.
Use a certificate trusted by clients and containing the SNI hostname they present. Clients that do not send SNI must request the configured database route name. Use a certificate trusted by clients and containing the SNI hostname they present. Clients that do not send SNI must request the configured database route name.
Without a `tls` block, PawSQL serves plaintext PostgreSQL: clients connect without SSL negotiation, and routes are selected only by database name. Hostname routing is unavailable because it relies on TLS SNI.
## Managed PostgreSQL lifecycle ## Managed PostgreSQL lifecycle
Managed PostgreSQL is lazy: PawSQL creates or starts its `pawsql-<database>` container only when a client selects that route, waits for PostgreSQL to accept connections, then proxies the session. PawSQL stops managed containers but does not remove their data volumes. Managed PostgreSQL is lazy: PawSQL creates or starts its `pawsql-<database>` container only when a client selects that route, waits for PostgreSQL to accept connections, then proxies the session. PawSQL stops managed containers but does not remove their data volumes.

View File

@@ -37,10 +37,16 @@ func run(args []string, logger *slog.Logger) error {
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid configuration: %w", err) return fmt.Errorf("invalid configuration: %w", err)
} }
var tlsConfig *tls.Config
if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" {
certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
if err != nil { if err != nil {
return fmt.Errorf("load TLS certificate and key: %w", err) return fmt.Errorf("load TLS certificate and key: %w", err)
} }
tlsConfig = &tls.Config{Certificates: []tls.Certificate{certificate}}
} else {
logger.Info("TLS is not configured; clients connect without SSL and routes are selected by database name")
}
if validateOnly { if validateOnly {
logger.Info("configuration is valid", "config", configPath) logger.Info("configuration is valid", "config", configPath)
return nil return nil
@@ -51,7 +57,7 @@ func run(args []string, logger *slog.Logger) error {
return fmt.Errorf("build route resolver: %w", err) return fmt.Errorf("build route resolver: %w", err)
} }
resolver := postgres.NewResolver(staticResolver, cfg.Databases, postgres.NewProvisioner(logger)) resolver := postgres.NewResolver(staticResolver, cfg.Databases, postgres.NewProvisioner(logger))
routingServer, err := server.New(&tls.Config{Certificates: []tls.Certificate{certificate}}, resolver, logger) routingServer, err := server.New(tlsConfig, resolver, logger)
if err != nil { if err != nil {
return err return err
} }

2
go.mod
View File

@@ -2,4 +2,4 @@ module github.com/barkstack/pawsql
go 1.24 go 1.24
require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0 require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0

4
go.sum
View File

@@ -1,2 +1,2 @@
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0 h1:81V2fr9ln2WNqA2JHb40fvav0aW+5OQAOsnuhMkuRes= cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 h1:ULdpvY1VC1d5M8VHx8SqacVLmk5SgvDYABDuzewebjc=
cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4=

View File

@@ -36,22 +36,24 @@ type Server struct {
handlers sync.WaitGroup handlers sync.WaitGroup
} }
// New validates server dependencies and applies safe protocol defaults. // New validates server dependencies and applies safe protocol defaults. A nil
// TLS configuration selects plaintext mode: PostgreSQL SSL negotiation is not
// offered and routes are selected by database name only.
func New(tlsConfig *tls.Config, resolver router.BackendResolver, logger *slog.Logger) (*Server, error) { func New(tlsConfig *tls.Config, resolver router.BackendResolver, logger *slog.Logger) (*Server, error) {
if tlsConfig == nil {
return nil, errors.New("TLS configuration is required")
}
if resolver == nil { if resolver == nil {
return nil, errors.New("backend resolver is required") return nil, errors.New("backend resolver is required")
} }
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
if tlsConfig != nil {
copy := tlsConfig.Clone() copy := tlsConfig.Clone()
if copy.MinVersion == 0 { if copy.MinVersion == 0 {
copy.MinVersion = tls.VersionTLS12 copy.MinVersion = tls.VersionTLS12
} }
return &Server{TLSConfig: copy, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil tlsConfig = copy
}
return &Server{TLSConfig: tlsConfig, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil
} }
// Serve accepts connections until Shutdown closes its listener. // Serve accepts connections until Shutdown closes its listener.
@@ -114,6 +116,11 @@ func (s *Server) handle(connection net.Conn) {
handshakeTimeout = defaultHandshakeTimeout handshakeTimeout = defaultHandshakeTimeout
} }
_ = connection.SetDeadline(time.Now().Add(handshakeTimeout)) _ = connection.SetDeadline(time.Now().Add(handshakeTimeout))
var (
proxyClient net.Conn = connection
err error
)
if s.TLSConfig != nil {
if err := pgwire.ReadSSLRequest(connection); err != nil { if err := pgwire.ReadSSLRequest(connection); err != nil {
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err) s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err)
return return
@@ -122,7 +129,6 @@ func (s *Server) handle(connection net.Conn) {
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err) s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err)
return return
} }
tlsConnection := tls.Server(connection, s.TLSConfig) tlsConnection := tls.Server(connection, s.TLSConfig)
if err := tlsConnection.Handshake(); err != nil { if err := tlsConnection.Handshake(); err != nil {
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err) s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err)
@@ -130,10 +136,8 @@ func (s *Server) handle(connection net.Conn) {
} }
_ = tlsConnection.SetDeadline(time.Time{}) _ = tlsConnection.SetDeadline(time.Time{})
sni = normalizeHostname(tlsConnection.ConnectionState().ServerName) sni = normalizeHostname(tlsConnection.ConnectionState().ServerName)
var ( proxyClient = tlsConnection
proxyClient net.Conn = tlsConnection }
err error
)
resolveTimeout := s.ResolveTimeout resolveTimeout := s.ResolveTimeout
if resolveTimeout <= 0 { if resolveTimeout <= 0 {
resolveTimeout = defaultResolveTimeout resolveTimeout = defaultResolveTimeout
@@ -148,7 +152,7 @@ func (s *Server) handle(connection net.Conn) {
return return
} }
} else { } else {
startup, startupErr := pgwire.ReadStartupMessage(tlsConnection) startup, startupErr := pgwire.ReadStartupMessage(proxyClient)
if startupErr != nil { if startupErr != nil {
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_startup_message", "error", startupErr) s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_startup_message", "error", startupErr)
return return
@@ -158,7 +162,7 @@ func (s *Server) handle(connection net.Conn) {
s.Logger.Warn("connection rejected", "remote_address", remote, "result", "unknown_database", "error", err) s.Logger.Warn("connection rejected", "remote_address", remote, "result", "unknown_database", "error", err)
return return
} }
proxyClient = pgwire.Replay(tlsConnection, startup.Bytes()) proxyClient = pgwire.Replay(proxyClient, startup.Bytes())
} }
if leases, ok := s.Resolver.(router.ConnectionLeaseManager); ok { if leases, ok := s.Resolver.(router.ConnectionLeaseManager); ok {

View File

@@ -195,6 +195,68 @@ func TestServerRoutesNoSNIByConfiguredDatabase(t *testing.T) {
} }
} }
func TestServerRoutesPlaintextConnectionsByDatabase(t *testing.T) {
backendListener := listen(t)
defer backendListener.Close()
backendDatabase := make(chan string, 1)
go func() {
connection, err := backendListener.Accept()
if err != nil {
return
}
defer connection.Close()
startup, err := pgwire.ReadStartupMessage(connection)
if err != nil {
return
}
backendDatabase <- startup.Database
_, _ = connection.Write([]byte("backend"))
}()
staticResolver, err := router.NewStaticResolver([]config.DatabaseConfig{{
Name: "analytics",
Upstream: backendListener.Addr().String(),
}})
if err != nil {
t.Fatal(err)
}
routingServer, err := New(nil, staticResolver, nil)
if err != nil {
t.Fatal(err)
}
listener := listen(t)
defer func() {
_ = routingServer.Shutdown()
routingServer.Wait()
_ = listener.Close()
}()
go func() { _ = routingServer.Serve(listener) }()
connection, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer connection.Close()
if _, err := connection.Write(startupMessage("analytics")); err != nil {
t.Fatal(err)
}
response := make([]byte, len("backend"))
if _, err := io.ReadFull(connection, response); err != nil {
t.Fatal(err)
}
if string(response) != "backend" {
t.Fatalf("proxied response = %q", response)
}
select {
case database := <-backendDatabase:
if database != "analytics" {
t.Errorf("backend database = %q, want analytics", database)
}
case <-time.After(time.Second):
t.Fatal("backend did not receive the plaintext StartupMessage")
}
}
func startupMessage(database string) []byte { func startupMessage(database string) []byte {
body := make([]byte, 4) body := make([]byte, 4)
binary.BigEndian.PutUint32(body, 196608) binary.BigEndian.PutUint32(body, 196608)