Repository: redpanda-data/connect (2025-06-27T13:55:40Z)

Issue:
Missing handshake_first as connection option in nats / nats_jetstream
We are unable to use RedPanda Connect to connect to NATS Jetstream. This is due to a missing option of setting handshake_first at the TLS level. The connection keeps timing out because of this.


Discussion:
Does using TLS scheme as described in https://docs.nats.io/using-nats/developer/connecting/tls#connecting-with-the-tls-protocol works? 



Patch:
diff --git a/docs/modules/components/pages/caches/nats_kv.adoc b/docs/modules/components/pages/caches/nats_kv.adoc
index 0dda7e4f70..00122befae 100644
--- a/docs/modules/components/pages/caches/nats_kv.adoc
+++ b/docs/modules/components/pages/caches/nats_kv.adoc
@@ -61,6 +61,7 @@ nats_kv:
     root_cas: ""
     root_cas_file: ""
     client_certs: []
+  tls_handshake_first: false
   auth:
     nkey_file: ./seed.nk # No default (optional)
     nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -314,6 +315,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/inputs/nats.adoc b/docs/modules/components/pages/inputs/nats.adoc
index 972863ae9c..502f7566ce 100644
--- a/docs/modules/components/pages/inputs/nats.adoc
+++ b/docs/modules/components/pages/inputs/nats.adoc
@@ -69,6 +69,7 @@ input:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -390,6 +391,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/inputs/nats_jetstream.adoc b/docs/modules/components/pages/inputs/nats_jetstream.adoc
index ddfc637089..9e92d1ddd8 100644
--- a/docs/modules/components/pages/inputs/nats_jetstream.adoc
+++ b/docs/modules/components/pages/inputs/nats_jetstream.adoc
@@ -75,6 +75,7 @@ input:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -437,6 +438,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/inputs/nats_kv.adoc b/docs/modules/components/pages/inputs/nats_kv.adoc
index 26012ed08a..ed7b08d6ac 100644
--- a/docs/modules/components/pages/inputs/nats_kv.adoc
+++ b/docs/modules/components/pages/inputs/nats_kv.adoc
@@ -70,6 +70,7 @@ input:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -393,6 +394,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/inputs/nats_stream.adoc b/docs/modules/components/pages/inputs/nats_stream.adoc
index 7b794bb15f..7752a5dc21 100644
--- a/docs/modules/components/pages/inputs/nats_stream.adoc
+++ b/docs/modules/components/pages/inputs/nats_stream.adoc
@@ -74,6 +74,7 @@ input:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -405,6 +406,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/outputs/nats.adoc b/docs/modules/components/pages/outputs/nats.adoc
index 230076a8ee..7834bd2b2a 100644
--- a/docs/modules/components/pages/outputs/nats.adoc
+++ b/docs/modules/components/pages/outputs/nats.adoc
@@ -71,6 +71,7 @@ output:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -405,6 +406,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/outputs/nats_jetstream.adoc b/docs/modules/components/pages/outputs/nats_jetstream.adoc
index 1a8d042260..ab54d1cca7 100644
--- a/docs/modules/components/pages/outputs/nats_jetstream.adoc
+++ b/docs/modules/components/pages/outputs/nats_jetstream.adoc
@@ -73,6 +73,7 @@ output:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -410,6 +411,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/outputs/nats_kv.adoc b/docs/modules/components/pages/outputs/nats_kv.adoc
index c3a6de8368..e0cc46a21d 100644
--- a/docs/modules/components/pages/outputs/nats_kv.adoc
+++ b/docs/modules/components/pages/outputs/nats_kv.adoc
@@ -67,6 +67,7 @@ output:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -352,6 +353,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/outputs/nats_stream.adoc b/docs/modules/components/pages/outputs/nats_stream.adoc
index 29e6553d5e..f47f4163d9 100644
--- a/docs/modules/components/pages/outputs/nats_stream.adoc
+++ b/docs/modules/components/pages/outputs/nats_stream.adoc
@@ -67,6 +67,7 @@ output:
       root_cas: ""
       root_cas_file: ""
       client_certs: []
+    tls_handshake_first: false
     auth:
       nkey_file: ./seed.nk # No default (optional)
       nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -343,6 +344,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/processors/nats_kv.adoc b/docs/modules/components/pages/processors/nats_kv.adoc
index d5da0e9f40..1c701bc906 100644
--- a/docs/modules/components/pages/processors/nats_kv.adoc
+++ b/docs/modules/components/pages/processors/nats_kv.adoc
@@ -67,6 +67,7 @@ nats_kv:
     root_cas: ""
     root_cas_file: ""
     client_certs: []
+  tls_handshake_first: false
   auth:
     nkey_file: ./seed.nk # No default (optional)
     nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -432,6 +433,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/docs/modules/components/pages/processors/nats_request_reply.adoc b/docs/modules/components/pages/processors/nats_request_reply.adoc
index b49439dbca..292739e105 100644
--- a/docs/modules/components/pages/processors/nats_request_reply.adoc
+++ b/docs/modules/components/pages/processors/nats_request_reply.adoc
@@ -72,6 +72,7 @@ nats_request_reply:
     root_cas: ""
     root_cas_file: ""
     client_certs: []
+  tls_handshake_first: false
   auth:
     nkey_file: ./seed.nk # No default (optional)
     nkey: '!!!SECRET_SCRUBBED!!!' # No default (optional)
@@ -437,6 +438,15 @@ password: foo
 password: ${KEY_PASSWORD}
 ```
 
+=== `tls_handshake_first`
+
+Perform a TLS handshake before sending the INFO protocol message.
+
+
+*Type*: `bool`
+
+*Default*: `false`
+
 === `auth`
 
 Optional configuration of NATS authentication parameters.
diff --git a/internal/impl/nats/connection.go b/internal/impl/nats/connection.go
index be46b08763..3803e58805 100644
--- a/internal/impl/nats/connection.go
+++ b/internal/impl/nats/connection.go
@@ -43,18 +43,23 @@ func connectionHeadFields() []*service.ConfigField {
 func connectionTailFields() []*service.ConfigField {
 	return []*service.ConfigField{
 		service.NewTLSToggledField("tls"),
+		service.NewBoolField("tls_handshake_first").
+			Description("Perform a TLS handshake before sending the INFO protocol message.").
+			Default(false).
+			Advanced(),
 		authFieldSpec(),
 	}
 }
 
 type connectionDetails struct {
-	label         string
-	logger        *service.Logger
-	tlsConf       *tls.Config
-	authConf      authConfig
-	fs            *service.FS
-	urls          string
-	maxReconnects *int
+	label             string
+	logger            *service.Logger
+	tlsConf           *tls.Config
+	authConf          authConfig
+	fs                *service.FS
+	urls              string
+	maxReconnects     *int
+	tlsHandshakeFirst bool
 }
 
 func connectionDetailsFromParsed(conf *service.ParsedConfig, mgr *service.Resources) (c connectionDetails, err error) {
@@ -76,6 +81,10 @@ func connectionDetailsFromParsed(conf *service.ParsedConfig, mgr *service.Resour
 		}
 	}
 
+	if c.tlsHandshakeFirst, err = conf.FieldBool("tls_handshake_first"); err != nil {
+		return c, err
+	}
+
 	var tlsEnabled bool
 	if c.tlsConf, tlsEnabled, err = conf.FieldTLSToggled("tls"); err != nil {
 		return
@@ -95,6 +104,9 @@ func (c *connectionDetails) get(_ context.Context, extraOpts ...nats.Option) (*n
 	if c.tlsConf != nil {
 		opts = append(opts, nats.Secure(c.tlsConf))
 	}
+	if c.tlsHandshakeFirst {
+		opts = append(opts, nats.TLSHandshakeFirst())
+	}
 	opts = append(opts, nats.Name(c.label))
 	opts = append(opts, errorHandlerOption(c.logger))
 	opts = append(opts, authConfToOptions(c.authConf, c.fs)...)


Test patch:
diff --git a/internal/impl/nats/input_jetstream_test.go b/internal/impl/nats/input_jetstream_test.go
index fecaea2a13..cc5cc4b804 100644
--- a/internal/impl/nats/input_jetstream_test.go
+++ b/internal/impl/nats/input_jetstream_test.go
@@ -37,6 +37,7 @@ auth:
   user_credentials_file: test auth user creds file
   user_jwt: test auth inline user JWT
   user_nkey_seed: test auth inline user NKey Seed
+tls_handshake_first: true
 `
 
 		conf, err := spec.ParseYAML(inputConfig, env)
@@ -52,6 +53,7 @@ auth:
 		assert.Equal(t, "test auth user creds file", e.connDetails.authConf.UserCredentialsFile)
 		assert.Equal(t, "test auth inline user JWT", e.connDetails.authConf.UserJWT)
 		assert.Equal(t, "test auth inline user NKey Seed", e.connDetails.authConf.UserNkeySeed)
+		assert.True(t, e.connDetails.tlsHandshakeFirst)
 	})
 
 	t.Run("Missing user_nkey_seed", func(t *testing.T) {
@@ -167,4 +169,25 @@ bind: false
 		_, err = newJetStreamReaderFromConfig(conf, service.MockResources())
 		require.Error(t, err)
 	})
+
+	t.Run("TLS handshake first empty", func(t *testing.T) {
+		inputConfig := `
+urls: [ url1, url2 ]
+subject: testsubject
+max_reconnects: -1
+auth:
+  nkey_file: test auth n key file
+  user_credentials_file: test auth user creds file
+  user_jwt: test auth inline user JWT
+  user_nkey_seed: test auth inline user NKey Seed
+`
+
+		conf, err := spec.ParseYAML(inputConfig, env)
+		require.NoError(t, err)
+
+		e, err := newJetStreamReaderFromConfig(conf, service.MockResources())
+		require.NoError(t, err)
+
+		assert.False(t, e.connDetails.tlsHandshakeFirst)
+	})
 }