diff --git a/README.md b/README.md index 6803a0f..2bb8994 100644 --- a/README.md +++ b/README.md @@ -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. - 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`. ## Build, configure, and run @@ -83,13 +83,15 @@ docker compose up --build ## 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. - **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. +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 is lazy: PawSQL creates or starts its `pawsql-` 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. diff --git a/cmd/pawsql/main.go b/cmd/pawsql/main.go index bec5b01..82f45a5 100644 --- a/cmd/pawsql/main.go +++ b/cmd/pawsql/main.go @@ -37,9 +37,15 @@ func run(args []string, logger *slog.Logger) error { if err := cfg.Validate(); err != nil { return fmt.Errorf("invalid configuration: %w", err) } - certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) - if err != nil { - return fmt.Errorf("load TLS certificate and key: %w", err) + var tlsConfig *tls.Config + if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" { + certificate, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if err != nil { + 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 { logger.Info("configuration is valid", "config", configPath) @@ -51,7 +57,7 @@ func run(args []string, logger *slog.Logger) error { return fmt.Errorf("build route resolver: %w", err) } 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 { return err } diff --git a/go.mod b/go.mod index 201c780..50c3ca5 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/barkstack/pawsql 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 diff --git a/go.sum b/go.sum index 4233a68..aaa2ede 100644 --- a/go.sum +++ b/go.sum @@ -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.0.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= +cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0 h1:ULdpvY1VC1d5M8VHx8SqacVLmk5SgvDYABDuzewebjc= +cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.1.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= diff --git a/internal/server/server.go b/internal/server/server.go index 7b37f73..4267611 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -36,22 +36,24 @@ type Server struct { 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) { - if tlsConfig == nil { - return nil, errors.New("TLS configuration is required") - } if resolver == nil { return nil, errors.New("backend resolver is required") } if logger == nil { logger = slog.Default() } - copy := tlsConfig.Clone() - if copy.MinVersion == 0 { - copy.MinVersion = tls.VersionTLS12 + if tlsConfig != nil { + copy := tlsConfig.Clone() + if copy.MinVersion == 0 { + copy.MinVersion = tls.VersionTLS12 + } + tlsConfig = copy } - return &Server{TLSConfig: copy, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil + return &Server{TLSConfig: tlsConfig, Resolver: resolver, Logger: logger, HandshakeTimeout: defaultHandshakeTimeout, ResolveTimeout: defaultResolveTimeout, DialTimeout: defaultDialTimeout}, nil } // Serve accepts connections until Shutdown closes its listener. @@ -114,26 +116,28 @@ func (s *Server) handle(connection net.Conn) { handshakeTimeout = defaultHandshakeTimeout } _ = connection.SetDeadline(time.Now().Add(handshakeTimeout)) - if err := pgwire.ReadSSLRequest(connection); err != nil { - s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err) - return - } - if _, err := connection.Write([]byte{'S'}); err != nil { - s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err) - return - } - - tlsConnection := tls.Server(connection, s.TLSConfig) - if err := tlsConnection.Handshake(); err != nil { - s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err) - return - } - _ = tlsConnection.SetDeadline(time.Time{}) - sni = normalizeHostname(tlsConnection.ConnectionState().ServerName) var ( - proxyClient net.Conn = tlsConnection + proxyClient net.Conn = connection err error ) + if s.TLSConfig != nil { + if err := pgwire.ReadSSLRequest(connection); err != nil { + s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_ssl_request", "error", err) + return + } + if _, err := connection.Write([]byte{'S'}); err != nil { + s.Logger.Warn("connection rejected", "remote_address", remote, "result", "ssl_response_failed", "error", err) + return + } + tlsConnection := tls.Server(connection, s.TLSConfig) + if err := tlsConnection.Handshake(); err != nil { + s.Logger.Warn("connection rejected", "remote_address", remote, "result", "tls_handshake_failed", "error", err) + return + } + _ = tlsConnection.SetDeadline(time.Time{}) + sni = normalizeHostname(tlsConnection.ConnectionState().ServerName) + proxyClient = tlsConnection + } resolveTimeout := s.ResolveTimeout if resolveTimeout <= 0 { resolveTimeout = defaultResolveTimeout @@ -148,7 +152,7 @@ func (s *Server) handle(connection net.Conn) { return } } else { - startup, startupErr := pgwire.ReadStartupMessage(tlsConnection) + startup, startupErr := pgwire.ReadStartupMessage(proxyClient) if startupErr != nil { s.Logger.Warn("connection rejected", "remote_address", remote, "result", "invalid_startup_message", "error", startupErr) 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) return } - proxyClient = pgwire.Replay(tlsConnection, startup.Bytes()) + proxyClient = pgwire.Replay(proxyClient, startup.Bytes()) } if leases, ok := s.Resolver.(router.ConnectionLeaseManager); ok { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 541dd0c..d87988f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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 { body := make([]byte, 4) binary.BigEndian.PutUint32(body, 196608)