Skip to content

Set ALPN for direct SSL connect - #1332

Merged
arp242 merged 1 commit into
lib:masterfrom
danielmitterdorfer:ssl-alpn
Jul 27, 2026
Merged

Set ALPN for direct SSL connect#1332
arp242 merged 1 commit into
lib:masterfrom
danielmitterdorfer:ssl-alpn

Conversation

@danielmitterdorfer

Copy link
Copy Markdown
Contributor

When sslnegotiation=direct is set, clients receive EOF. This happens because Postgres expects the ALPN postgresql to be set but the client's TLS configuration doesn't specify it. With this commit we explicitly set the expected ALPN, allowing connections to succeed.

Closes #1331

@danielmitterdorfer

Copy link
Copy Markdown
Contributor Author

In https://github.com/lib/pq/actions/runs/29415703853/job/87371180560, all tests for Postgres before 17.0 were failing. This happened because we are testing a feature that has been introduced with 17.0. I searched the code base for established practices and it seems there are skip helpers but none of them based on versions. I have pushed 8b7f21d to introduce a smaller helper to ensure that these tests only run on Postgres 17+. Please let me know if

  • the chosen approach is fine for you
  • whether it's ok to keep it in this PR (on the other hand it probably doesn't make a lot of sense to introduce these helpers without using them)

@arp242

arp242 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Thanks; seems good.

The tests can probably be a bit simpler? I think just establishing an actual connection with sslnegotiation=direct should be enough? I'm not sure if any of the other tests you added are really needed? That is:

diff --git i/internal/pqtest/pqtest.go w/internal/pqtest/pqtest.go
index 8a174b9..7d853a8 100644
--- i/internal/pqtest/pqtest.go
+++ w/internal/pqtest/pqtest.go
@@ -35,6 +35,16 @@ func SkipCockroach(t testing.TB) {
 	}
 }
 
+// SkipBeforeVersion skips the test unless the connected server reports at least
+// the given major version (e.g. 16, 17, etc.)
+func SkipBeforeVersion(t testing.TB, want int) {
+	t.Helper()
+	have := QueryRow[int](t, MustDB(t), `show server_version_num`)["server_version_num"] / 10000
+	if want > have {
+		t.Skipf("skipped for PostgreSQL %d (want %d)", have, want)
+	}
+}
+
 func ForceBinaryParameters() bool {
 	v, ok := os.LookupEnv("PQTEST_BINARY_PARAMETERS")
 	if !ok {
diff --git i/internal/proto/proto.go w/internal/proto/proto.go
index 5aaa516..32c1ec9 100644
--- i/internal/proto/proto.go
+++ w/internal/proto/proto.go
@@ -14,6 +14,7 @@ const (
 	CancelRequestCode = (1234 << 16) | 5678
 	NegotiateSSLCode  = (1234 << 16) | 5679
 	NegotiateGSSCode  = (1234 << 16) | 5680
+	ALPNProtocol      = "postgresql"
 )
 
 // Constants from fe-protocol3.c
diff --git i/ssl.go w/ssl.go
index 081aa9d..0b7603a 100644
--- i/ssl.go
+++ w/ssl.go
@@ -15,6 +15,7 @@ import (
 	"sync"
 
 	"github.com/lib/pq/internal/pqutil"
+	"github.com/lib/pq/internal/proto"
 )
 
 // Registry for custom tls.Configs
@@ -121,6 +122,12 @@ func ssl(cfg Config, mode SSLMode) (func(net.Conn) (net.Conn, error), error) {
 	tlsConf.MinVersion = cfg.SSLMinProtocolVersion.tlsconf()
 	tlsConf.MaxVersion = cfg.SSLMaxProtocolVersion.tlsconf()
 
+	// ALPN is mandatory with direct SSL connections; PostgreSQL only supports
+	// one protocol.
+	if cfg.SSLNegotiation == SSLNegotiationDirect {
+		tlsConf.NextProtos = []string{proto.ALPNProtocol}
+	}
+
 	// RFC 6066 asks to not set SNI if the host is a literal IP address (IPv4 or
 	// IPv6). This check is coded already crypto.tls.hostnameInSNI, so just
 	// always set ServerName here and let crypto/tls do the filtering.
diff --git i/ssl_test.go w/ssl_test.go
index 8b8641b..c8ff68c 100644
--- i/ssl_test.go
+++ w/ssl_test.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"strings"
 	"testing"
 	"time"
 
@@ -92,7 +93,9 @@ func TestSSLMode(t *testing.T) {
 		// sslmode=disable
 		{"sslmode=disable user=pqgossl", "or:no encryption|login rejected (08P01)|authentication rejected by configuration (28000)"},
 
-		// sslnegotiation=direct should fail if ssl isn't required, like libpq:
+		// sslnegotiation=direct
+		{"sslmode=require sslnegotiation=direct", ""},
+		// should fail if ssl isn't required, like libpq:
 		// psql: error: weak sslmode "allow" may not be used with sslnegotiation=direct (use "require", "verify-ca", or "verify-full")
 		{"sslmode=disable sslnegotiation=direct", "weak sslmode"},
 		{"sslmode=allow sslnegotiation=direct", "weak sslmode"},
@@ -103,6 +106,10 @@ func TestSSLMode(t *testing.T) {
 		t.Run("", func(t *testing.T) {
 			t.Parallel()
 
+			if strings.Contains(tt.connect, "sslnegotiation=direct") {
+				pqtest.SkipBeforeVersion(t, 17) // Only supported on PostgreSQL≥17.
+			}
+
 			_, err := pqtest.DB(t, tt.connect)
 			switch {
 			case tt.wantErr == "" && err != nil:

@danielmitterdorfer

Copy link
Copy Markdown
Contributor Author

Thanks for your feedback. I have applied all your suggestions now in d763c69.

When sslnegotiation=direct is set, clients receive EOF. This happens
because PostgreSQL expects the ALPN "postgresql" to be set, but the
client's TLS configuration doesn't specify it.

This adds that, identical to what libpq does.

Fixes lib#1331

Co-authored-by: Martin Tournoij <martin@arp242.net>
@arp242
arp242 merged commit 10237a6 into lib:master Jul 27, 2026
16 checks passed
@arp242

arp242 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Cheers, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server rejects connection when sslnegotiation=direct is set

2 participants