diff --git a/core/common/src/types/configuration/auth_config/connection_string.rs b/core/common/src/types/configuration/auth_config/connection_string.rs index 82691812f0..8ff787bfac 100644 --- a/core/common/src/types/configuration/auth_config/connection_string.rs +++ b/core/common/src/types/configuration/auth_config/connection_string.rs @@ -158,10 +158,213 @@ impl ConnectionStringUtils { #[cfg(test)] mod tests { use super::*; - use crate::NonZeroIggyDuration; - use crate::TcpConnectionStringOptions; + use crate::{ + IggyDuration, NonZeroIggyDuration, QuicClientConfig, QuicConnectionStringOptions, + TcpClientConfig, TcpConnectionStringOptions, WebSocketClientConfig, + WebSocketConnectionStringOptions, + }; use secrecy::ExposeSecret; + #[test] + fn should_parse_tcp_canonical_options_and_legacy_aliases() { + for (case, query, expected) in [ + ( + "canonical keys", + "reconnection_max_retries=3&reestablish_after=7s&tls_validate_certificate=true", + Some(3), + ), + ( + "deprecated aliases", + "reconnection_retries=3&reestablish_after=7s&tls_validate_certificate=true", + Some(3), + ), + ] { + let value = format!("iggy+tcp://user:secret@localhost:8090?{query}"); + let config = TcpClientConfig::from( + ConnectionString::::new(&value) + .unwrap_or_else(|error| panic!("{case}: {error}")), + ); + assert_eq!(config.reconnection.max_retries, expected, "{case}"); + assert_eq!( + config.reconnection.reestablish_after, + IggyDuration::from_str("7s").unwrap(), + "{case}" + ); + assert!(config.tls_validate_certificate, "{case}"); + } + } + + #[test] + fn should_use_tcp_defaults_without_options() { + let value = "iggy+tcp://user:secret@localhost:8090"; + let config = TcpClientConfig::from( + ConnectionString::::new(value).unwrap(), + ); + assert_eq!(config.reconnection.max_retries, None); + assert_eq!( + config.reconnection.reestablish_after, + IggyDuration::from_str("5s").unwrap() + ); + assert!(config.tls_validate_certificate); + } + + #[test] + fn should_parse_tcp_disabled_certificate_validation() { + let value = "iggy+tcp://user:secret@localhost:8090?tls_validate_certificate=false"; + let config = TcpClientConfig::from( + ConnectionString::::new(value).unwrap(), + ); + assert!(!config.tls_validate_certificate); + } + + #[test] + fn should_parse_quic_canonical_options_and_legacy_aliases() { + for (case, query, expected) in [ + ( + "canonical keys", + "reconnection_max_retries=3&reestablish_after=7s&tls_validate_certificate=true", + Some(3), + ), + ( + "deprecated aliases", + "reconnection_max_retries=3&reconnection_reestablish_after=7s&validate_certificate=true", + Some(3), + ), + ] { + let value = format!("iggy+quic://user:secret@localhost:8090?{query}"); + let config = QuicClientConfig::from( + ConnectionString::::new(&value) + .unwrap_or_else(|error| panic!("{case}: {error}")), + ); + assert_eq!(config.reconnection.max_retries, expected, "{case}"); + assert_eq!( + config.reconnection.reestablish_after, + IggyDuration::from_str("7s").unwrap(), + "{case}" + ); + assert!(config.validate_certificate, "{case}"); + } + } + + #[test] + fn should_parse_websocket_canonical_options_and_legacy_aliases() { + for (case, query, expected) in [ + ( + "canonical keys", + "reconnection_max_retries=3&reestablish_after=7s&tls_validate_certificate=true", + Some(3), + ), + ( + "deprecated aliases", + "reconnection_retries=3&reestablish_after=7s&tls_validate_certificate=true", + Some(3), + ), + ] { + let value = format!("iggy+ws://user:secret@localhost:8090?{query}"); + let config = WebSocketClientConfig::from( + ConnectionString::::new(&value) + .unwrap_or_else(|error| panic!("{case}: {error}")), + ); + assert_eq!(config.reconnection.max_retries, expected, "{case}"); + assert_eq!( + config.reconnection.reestablish_after, + IggyDuration::from_str("7s").unwrap(), + "{case}" + ); + assert!(config.tls_validate_certificate, "{case}"); + } + } + + #[test] + fn should_use_last_connection_option_including_aliases() { + for (case, query, expected) in [ + ( + "canonical retries last", + "reconnection_retries=2&reconnection_max_retries=3", + Some(3), + ), + ( + "deprecated retries last", + "reconnection_max_retries=3&reconnection_retries=2", + Some(2), + ), + ( + "canonical unlimited retries last", + "reconnection_retries=3&reconnection_max_retries=unlimited", + None, + ), + ( + "deprecated unlimited retries last", + "reconnection_max_retries=3&reconnection_retries=unlimited", + None, + ), + ] { + assert_eq!( + TcpConnectionStringOptions::parse_options(query) + .unwrap_or_else(|error| panic!("{case}: {error}")) + .retries(), + expected, + "{case}" + ); + assert_eq!( + WebSocketConnectionStringOptions::parse_options(query) + .unwrap_or_else(|error| panic!("{case}: {error}")) + .retries(), + expected, + "{case}" + ); + } + for (case, query, expected) in [ + ( + "canonical cooldown last", + "reconnection_reestablish_after=2s&reestablish_after=3s", + "3s", + ), + ( + "deprecated cooldown last", + "reestablish_after=3s&reconnection_reestablish_after=2s", + "2s", + ), + ] { + let options = QuicConnectionStringOptions::parse_options(query) + .unwrap_or_else(|error| panic!("{case}: {error}")); + assert_eq!( + options.reconnection().reestablish_after, + IggyDuration::from_str(expected).unwrap(), + "{case}" + ); + } + for (case, query, expected) in [ + ( + "canonical certificate validation last", + "validate_certificate=false&tls_validate_certificate=true", + true, + ), + ( + "deprecated certificate validation last", + "tls_validate_certificate=true&validate_certificate=false", + false, + ), + ] { + assert_eq!( + QuicConnectionStringOptions::parse_options(query) + .unwrap_or_else(|error| panic!("{case}: {error}")) + .validate_certificate(), + expected, + "{case}" + ); + } + } + + #[test] + fn should_reject_invalid_tcp_certificate_validation() { + assert_eq!( + TcpConnectionStringOptions::parse_options("tls_validate_certificate=invalid") + .unwrap_err(), + IggyError::InvalidConnectionString + ); + } + #[test] fn should_fail_without_username() { let server_address = "127.0.0.1"; diff --git a/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs b/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs index 43f401661e..0dcaee0934 100644 --- a/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs +++ b/core/common/src/types/configuration/quic_config/quic_connection_string_options.rs @@ -175,7 +175,13 @@ impl ConnectionStringOptions for QuicConnectionStringOptions { return Err(IggyError::InvalidConnectionString); } }, - "validate_certificate" => { + "tls_validate_certificate" | "validate_certificate" => { + // TODO: Remove the deprecated `validate_certificate` alias after the compatibility release. + if option_parts[0] == "validate_certificate" { + tracing::warn!( + "Connection string option 'validate_certificate' is deprecated; use 'tls_validate_certificate'" + ); + } validate_certificate = option_parts[1] == "true"; } "heartbeat_interval" => { @@ -187,7 +193,13 @@ impl ConnectionStringOptions for QuicConnectionStringOptions { "reconnection_interval" => { reconnection_interval = option_parts[1].to_string(); } - "reconnection_reestablish_after" => { + "reestablish_after" | "reconnection_reestablish_after" => { + // TODO: Remove the deprecated `reconnection_reestablish_after` alias after the compatibility release. + if option_parts[0] == "reconnection_reestablish_after" { + tracing::warn!( + "Connection string option 'reconnection_reestablish_after' is deprecated; use 'reestablish_after'" + ); + } reconnection_reestablish_after = option_parts[1].to_string(); } _ => { diff --git a/core/common/src/types/configuration/tcp_config/tcp_client_config.rs b/core/common/src/types/configuration/tcp_config/tcp_client_config.rs index 9c3431ebde..fa06892950 100644 --- a/core/common/src/types/configuration/tcp_config/tcp_client_config.rs +++ b/core/common/src/types/configuration/tcp_config/tcp_client_config.rs @@ -69,8 +69,7 @@ impl From> for TcpClientConfig { tls_enabled: connection_string.options().tls_enabled(), tls_domain: connection_string.options().tls_domain().into(), tls_ca_file: connection_string.options().tls_ca_file().to_owned(), - // Require certificate verification, including when trusting a private CA. - tls_validate_certificate: true, + tls_validate_certificate: connection_string.options().tls_validate_certificate(), reconnection: connection_string.options().reconnection().to_owned(), heartbeat_interval: connection_string.options().heartbeat_interval(), nodelay: connection_string.options().nodelay(), diff --git a/core/common/src/types/configuration/tcp_config/tcp_connection_string_options.rs b/core/common/src/types/configuration/tcp_config/tcp_connection_string_options.rs index 1c957f1c37..f22c5fdd5f 100644 --- a/core/common/src/types/configuration/tcp_config/tcp_connection_string_options.rs +++ b/core/common/src/types/configuration/tcp_config/tcp_connection_string_options.rs @@ -26,6 +26,7 @@ pub struct TcpConnectionStringOptions { tls_enabled: bool, tls_domain: String, tls_ca_file: Option, + tls_validate_certificate: bool, reconnection: TcpClientReconnectionConfig, heartbeat_interval: NonZeroIggyDuration, nodelay: bool, @@ -44,6 +45,10 @@ impl TcpConnectionStringOptions { &self.tls_ca_file } + pub fn tls_validate_certificate(&self) -> bool { + self.tls_validate_certificate + } + pub fn reconnection(&self) -> &TcpClientReconnectionConfig { &self.reconnection } @@ -67,6 +72,7 @@ impl ConnectionStringOptions for TcpConnectionStringOptions { let mut tls_enabled = false; let mut tls_domain = "".to_string(); let mut tls_ca_file = None; + let mut tls_validate_certificate = true; let mut reconnection_retries = "unlimited".to_owned(); let mut reconnection_interval = "1s".to_owned(); let mut reestablish_after = "5s".to_owned(); @@ -88,7 +94,19 @@ impl ConnectionStringOptions for TcpConnectionStringOptions { "tls_ca_file" => { tls_ca_file = Some(option_parts[1].to_string()); } + "tls_validate_certificate" => { + tls_validate_certificate = option_parts[1] + .parse() + .map_err(|_| IggyError::InvalidConnectionString)?; + } + "reconnection_max_retries" => { + reconnection_retries = option_parts[1].to_string(); + } + // TODO: Remove the deprecated `reconnection_retries` alias after the compatibility release. "reconnection_retries" => { + tracing::warn!( + "Connection string option 'reconnection_retries' is deprecated; use 'reconnection_max_retries'" + ); reconnection_retries = option_parts[1].to_string(); } "reconnection_interval" => { @@ -128,7 +146,7 @@ impl ConnectionStringOptions for TcpConnectionStringOptions { let heartbeat_interval = NonZeroIggyDuration::from_str(heartbeat_interval.as_str()) .map_err(|_| IggyError::InvalidConnectionString)?; - let connection_string_options = TcpConnectionStringOptions::new( + let mut connection_string_options = TcpConnectionStringOptions::new( tls_enabled, tls_domain, tls_ca_file, @@ -137,6 +155,7 @@ impl ConnectionStringOptions for TcpConnectionStringOptions { nodelay, ); + connection_string_options.tls_validate_certificate = tls_validate_certificate; Ok(connection_string_options) } } @@ -154,6 +173,7 @@ impl TcpConnectionStringOptions { tls_enabled, tls_domain, tls_ca_file, + tls_validate_certificate: true, reconnection, heartbeat_interval, nodelay, @@ -167,6 +187,7 @@ impl Default for TcpConnectionStringOptions { tls_enabled: false, tls_domain: "".to_string(), tls_ca_file: None, + tls_validate_certificate: true, reconnection: Default::default(), heartbeat_interval: NonZeroIggyDuration::from_str("5s").unwrap(), nodelay: false, diff --git a/core/common/src/types/configuration/websocket_config/websocket_connection_string_options.rs b/core/common/src/types/configuration/websocket_config/websocket_connection_string_options.rs index 6bd0a4a331..4af9c138e6 100644 --- a/core/common/src/types/configuration/websocket_config/websocket_connection_string_options.rs +++ b/core/common/src/types/configuration/websocket_config/websocket_connection_string_options.rs @@ -117,7 +117,13 @@ impl ConnectionStringOptions for WebSocketConnectionStringOptions { parsed_options.heartbeat_interval = NonZeroIggyDuration::from_str(parts[1]) .map_err(|_| IggyError::InvalidConnectionString)?; } - "reconnection_retries" => { + "reconnection_max_retries" | "reconnection_retries" => { + // TODO: Remove the deprecated `reconnection_retries` alias after the compatibility release. + if parts[0] == "reconnection_retries" { + tracing::warn!( + "Connection string option 'reconnection_retries' is deprecated; use 'reconnection_max_retries'" + ); + } let retries = match parts[1] { "unlimited" => None, val => Some( diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs index 09b8beff4a..a58d1e1284 100644 --- a/core/sdk/src/clients/client.rs +++ b/core/sdk/src/clients/client.rs @@ -139,7 +139,7 @@ const SESSION_CONTROL_CODES: [u32; 5] = [ /// // Auto-logs in from the credentials in the string and retries forever on disconnect. /// let client = IggyClient::builder_from_connection_string( /// "iggy+tcp://user:secret@localhost:8090\ -/// ?reconnection_retries=unlimited&reconnection_interval=1s&heartbeat_interval=5s&nodelay=true", +/// ?reconnection_max_retries=unlimited&reconnection_interval=1s&heartbeat_interval=5s&nodelay=true", /// )? /// .build()?; /// client.connect().await?; @@ -281,6 +281,9 @@ impl IggyClient { /// - a bool use the literal `true` or `false`. /// - bytes and millisecond options provide a number such as `1024`. /// + /// When an option is repeated, including through a deprecated alias, the + /// last occurrence takes effect. + /// /// ## TCP /// /// The same options apply for `iggy://` and `iggy+tcp://`. @@ -288,7 +291,9 @@ impl IggyClient { /// - `tls`: bool. Enable/disable TLS. Default: `false`. /// - `tls_domain`: string. Server name to validate the certificate against. Default: unset. /// - `tls_ca_file`: filesystem path. PEM roots replacing the built-in roots. Default: unset. - /// - `reconnection_retries`: "unlimited" or u32. Retry passes after the initial endpoint pass. Default: `unlimited`. + /// - `tls_validate_certificate`: bool. Verify the server certificate. Default: `true`. + /// - `reconnection_max_retries`: "unlimited" or u32. Retry passes after the initial endpoint pass. Default: `unlimited`. + /// - `reconnection_retries`: Deprecated alias for `reconnection_max_retries`. Will be removed in a later release. /// - `reconnection_interval`: [`NonZeroIggyDuration`]. Wait between retry passes. Default: `1s`. /// - `reestablish_after`: [`IggyDuration`]. Cooldown measured from the last connection establishment. Default: `5s`. /// - `heartbeat_interval`: [`NonZeroIggyDuration`]. Client heartbeat period. Default: `5s`. @@ -301,7 +306,7 @@ impl IggyClient { /// let client = IggyClient::builder_from_connection_string( /// "iggy+tcp://user:secret@localhost:8090\ /// ?tls=true&tls_domain=localhost&tls_ca_file=/etc/iggy/ca.pem\ - /// &reconnection_retries=unlimited&reconnection_interval=1s&reestablish_after=5s\ + /// &reconnection_max_retries=unlimited&reconnection_interval=1s&reestablish_after=5s\ /// &heartbeat_interval=5s&nodelay=true", /// )? /// .build()?; @@ -311,11 +316,13 @@ impl IggyClient { /// /// ## QUIC /// - /// - `validate_certificate`: bool. Verify the server certificate. Default: `false`. + /// - `tls_validate_certificate`: bool. Verify the server certificate. Default: `false`. + /// - `validate_certificate`: Deprecated alias for `tls_validate_certificate`. Will be removed in a later release. /// - `heartbeat_interval`: [`NonZeroIggyDuration`]. Client heartbeat period. Default: `5s`. /// - `reconnection_max_retries`: "unlimited" or u32. Number of attempts to connect. Default: `unlimited`. /// - `reconnection_interval`: [`NonZeroIggyDuration`]. Wait between reconnection attempts. Default: `1s`. - /// - `reconnection_reestablish_after`: [`IggyDuration`]. Cooldown measured from the last connection establishment. Default: `5s`. + /// - `reestablish_after`: [`IggyDuration`]. Cooldown measured from the last connection establishment. Default: `5s`. + /// - `reconnection_reestablish_after`: Deprecated alias for `reestablish_after`. Will be removed in a later release. /// - `response_buffer_size`: u64. Number of bytes in the response receive buffer. Default: `10000000`. /// - `max_concurrent_bidi_streams`: u64. Number of concurrent bidirectional streams. Default: `10000`. /// - `datagram_send_buffer_size`: u64. Number of bytes in the datagram send buffer. Default: `100000`. @@ -331,8 +338,8 @@ impl IggyClient { /// # fn run() -> Result<(), IggyError> { /// let client = IggyClient::builder_from_connection_string( /// "iggy+quic://user:secret@localhost:8080\ - /// ?validate_certificate=true&heartbeat_interval=5s\ - /// &reconnection_max_retries=unlimited&reconnection_interval=1s&reconnection_reestablish_after=5s\ + /// ?tls_validate_certificate=true&heartbeat_interval=5s\ + /// &reconnection_max_retries=unlimited&reconnection_interval=1s&reestablish_after=5s\ /// &response_buffer_size=10000000&max_concurrent_bidi_streams=10000\ /// &datagram_send_buffer_size=100000&initial_mtu=1200\ /// &send_window=100000&receive_window=100000\ @@ -368,7 +375,8 @@ impl IggyClient { /// ## WebSocket /// /// - `heartbeat_interval`: [`NonZeroIggyDuration`]. Client heartbeat period. Default: `5s`. - /// - `reconnection_retries`: "unlimited" or u32. Number of attempts to connect. Default: `unlimited`. + /// - `reconnection_max_retries`: "unlimited" or u32. Number of attempts to connect. Default: `unlimited`. + /// - `reconnection_retries`: Deprecated alias for `reconnection_max_retries`. Will be removed in a later release. /// - `reconnection_interval`: [`NonZeroIggyDuration`]. Wait between reconnection attempts. Default: `1s`. /// - `reestablish_after`: [`IggyDuration`]. Cooldown measured from the last connection establishment. Default: `5s`. /// - `read_buffer_size`: usize. Size of the read buffer in bytes. Default: `131072`. @@ -388,7 +396,7 @@ impl IggyClient { /// # fn run() -> Result<(), IggyError> { /// let client = IggyClient::builder_from_connection_string( /// "iggy+ws://user:secret@localhost:8092\ - /// ?heartbeat_interval=5s&reconnection_retries=unlimited&reconnection_interval=1s&reestablish_after=5s\ + /// ?heartbeat_interval=5s&reconnection_max_retries=unlimited&reconnection_interval=1s&reestablish_after=5s\ /// &read_buffer_size=131072&write_buffer_size=131072\ /// &max_message_size=67108864&max_frame_size=16777216&accept_unmasked_frames=false\ /// &tls=true&tls_domain=localhost&tls_ca_file=/etc/iggy/ca.pem&tls_validate_certificate=true", diff --git a/core/sdk/src/tcp/tcp_client.rs b/core/sdk/src/tcp/tcp_client.rs index 8cf7154bf5..9fbb8e4da2 100644 --- a/core/sdk/src/tcp/tcp_client.rs +++ b/core/sdk/src/tcp/tcp_client.rs @@ -2893,9 +2893,9 @@ mod tests { let username = "user"; let password = "secret"; let heartbeat_interval = "10s"; - let reconnection_retries = "10"; + let reconnection_max_retries = "10"; let value = format!( - "{connection_string_prefix}{protocol}://{username}:{password}@{server_address}:{port}?heartbeat_interval={heartbeat_interval}&reconnection_retries={reconnection_retries}" + "{connection_string_prefix}{protocol}://{username}:{password}@{server_address}:{port}?heartbeat_interval={heartbeat_interval}&reconnection_max_retries={reconnection_max_retries}" ); let tcp_client = TcpClient::from_connection_string(&value); assert!(tcp_client.is_ok()); @@ -2924,7 +2924,7 @@ mod tests { assert!(tcp_client_config.reconnection.enabled); assert_eq!( tcp_client_config.reconnection.max_retries.unwrap(), - reconnection_retries.parse::().unwrap() + reconnection_max_retries.parse::().unwrap() ); assert_eq!( tcp_client_config.reconnection.interval, diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index 69ad1c4f0d..6b8bf37fdd 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -2064,7 +2064,8 @@ class IggyBlockingClient final { * - `tls=` * - `tls_domain=` * - `tls_ca_file=` - * - `reconnection_retries=` + * - `tls_validate_certificate=` (defaults to true) + * - `reconnection_max_retries=` * - `reconnection_interval=` * - `reestablish_after=` * - `heartbeat_interval=` @@ -2080,11 +2081,11 @@ class IggyBlockingClient final { * - `receive_window=` * - `keep_alive_interval=` * - `max_idle_timeout=` - * - `validate_certificate=` + * - `tls_validate_certificate=` * - `heartbeat_interval=` * - `reconnection_max_retries=` * - `reconnection_interval=` - * - `reconnection_reestablish_after=` + * - `reestablish_after=` * * HTTP accepts these query parameters: * @@ -2094,7 +2095,7 @@ class IggyBlockingClient final { * WebSocket accepts these query parameters: * * - `heartbeat_interval=` - * - `reconnection_retries=` + * - `reconnection_max_retries=` * - `reconnection_interval=` * - `reestablish_after=` * - `read_buffer_size=` @@ -2109,6 +2110,12 @@ class IggyBlockingClient final { * - `tls_validate_certificate=` * * Durations use Iggy duration syntax, such as `500ms`, `5s`, or `1min`. + * Deprecated aliases (will be removed in a later release): + * - TCP and WebSocket: `reconnection_retries` -> `reconnection_max_retries`. + * - QUIC: `reconnection_reestablish_after` -> `reestablish_after`. + * - QUIC: `validate_certificate` -> `tls_validate_certificate`. + * + * When an option is repeated, including through a (deprecated) alias, the last occurrence takes effect. * Boolean values are `true` or `false`. * * Credentials embedded in the connection string configure automatic login diff --git a/foreign/node/README.md b/foreign/node/README.md index 9d1779968c..96a870543e 100644 --- a/foreign/node/README.md +++ b/foreign/node/README.md @@ -121,10 +121,12 @@ const stats = await client.system.getStats(); Supported schemes are `iggy://` (TCP, default) and `iggy+tcp://`. Credentials are `username:password` or a single personal access token. Options mirror the -other SDKs: `tls`, `tls_domain`, `tls_ca_file`, `reconnection_retries`, +other SDKs: `tls`, `tls_domain`, `tls_ca_file`, `tls_validate_certificate`, `reconnection_max_retries`, `reconnection_interval`, `heartbeat_interval` and `nodelay`. `reestablish_after` is accepted for format compatibility but has no Node equivalent. +When options repeat, including through a deprecated alias, the last occurrence is chosen. + note: `SimpleClient` does not accept a connection string: it wraps an existing `RawClient` instance rather than building one from configuration. Pass the connection string to `Client`, `SingleClient` or `getRawClient` and hand the @@ -134,7 +136,9 @@ resulting raw client to `SimpleClient` if needed. | option | limit | | --- | --- | -| `reconnection_retries` | integer up to `4294967295` (u32 max); larger values are rejected like Rust's u32 overflow, and `unlimited` maps to this ceiling. Defaults to unlimited | +| `reconnection_max_retries` | integer up to `4294967295` (u32 max); larger values are rejected like Rust's u32 overflow, and `unlimited` maps to this ceiling. Defaults to unlimited | +| `reconnection_retries` (deprecated) | Alias for `reconnection_max_retries`; emits a deprecation warning | +| `tls_validate_certificate` | `true` or `false`; controls server certificate verification when TLS is enabled. Defaults to `true` | | `heartbeat_interval` | duration up to `2147483647ms` (Node's largest timer delay); `0` disables heartbeats | | `reconnection_interval` | positive duration (`ms`, `s`, `m`, `h`) up to `2147483647ms` (Node's largest timer delay); zero spellings are rejected. Defaults to `1s` | | port in the authority | decimal up to `65535` | diff --git a/foreign/node/src/client/client.connection-string.test.ts b/foreign/node/src/client/client.connection-string.test.ts index 09198e7813..27831def11 100644 --- a/foreign/node/src/client/client.connection-string.test.ts +++ b/foreign/node/src/client/client.connection-string.test.ts @@ -18,6 +18,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { MAX_U32 } from '../constant.js'; +import type { TlsOption } from './client.type.js'; import { parseConnectionString, parseDuration @@ -74,7 +75,7 @@ describe('parseConnectionString', () => { assert.deepEqual( parseConnectionString( 'iggy+tcp://iggy:secret@localhost:8090' + - '?reconnection_retries=3&reconnection_interval=5s&heartbeat_interval=10s' + '?reconnection_max_retries=3&reconnection_interval=5s&heartbeat_interval=10s' ), { transport: 'TCP', @@ -94,7 +95,7 @@ describe('parseConnectionString', () => { // retries alone keep the 1s interval; interval alone keeps unlimited. assert.deepEqual( parseConnectionString( - 'iggy://iggy:secret@localhost:8090?reconnection_retries=3' + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=3' ).reconnect, { enabled: true, interval: 1000, maxRetries: 3 } ); @@ -106,6 +107,63 @@ describe('parseConnectionString', () => { ); }); + it('preserves the deprecated retry alias and uses the last occurrence', (context) => { + const warning = context.mock.method(process, 'emitWarning', () => undefined); + for (const [query, expected] of [ + ['reconnection_retries=3', 3], + ['reconnection_retries=unlimited', MAX_U32], + ['reconnection_retries=2&reconnection_max_retries=3', 3], + ['reconnection_max_retries=3&reconnection_retries=2', 2], + ['reconnection_retries=3&reconnection_max_retries=unlimited', MAX_U32], + ['reconnection_max_retries=3&reconnection_retries=unlimited', MAX_U32] + ] as const) + assert.equal( + parseConnectionString(`iggy://iggy:secret@localhost:8090?${query}`) + .reconnect?.maxRetries, + expected, + `Unexpected retry count for query: ${query}` + ); + assert.equal(warning.mock.callCount(), 6); + for (const call of warning.mock.calls) + assert.deepEqual(call.arguments, [ + "Connection string option 'reconnection_retries' is deprecated; use 'reconnection_max_retries'", + 'DeprecationWarning' + ]); + }); + + it('does not warn for the canonical retry key', (context) => { + const warning = context.mock.method(process, 'emitWarning', () => undefined); + parseConnectionString( + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=3' + ); + assert.equal(warning.mock.callCount(), 0); + }); + + it('passes certificate validation through normalization to TLS options', () => { + for (const [query, expected] of [ + ['tls_validate_certificate=true', true], + ['tls_validate_certificate=false', false], + ['tls_validate_certificate=false&tls_validate_certificate=true', true], + ['tls_validate_certificate=true&tls_validate_certificate=false', false] + ] as const) { + const config = normalizeClientConfig( + `iggy://iggy:secret@localhost:8090?tls=true&${query}` + ); + assert.equal(config.transport, 'TLS'); + assert.equal((config.options as TlsOption).rejectUnauthorized, expected); + } + }); + + it('rejects invalid certificate validation values', () => { + for (const value of ['', 'invalid', 'TRUE', '1']) + assert.throws( + () => parseConnectionString( + `iggy://iggy:secret@localhost:8090?tls_validate_certificate=${value}` + ), + /must be true or false/ + ); + }); + it('maps nodelay to the socket option', () => { assert.equal( parseConnectionString('iggy://iggy:secret@localhost:8090?nodelay=true') @@ -117,7 +175,7 @@ describe('parseConnectionString', () => { it('maps unlimited retries to the u32 ceiling', () => { assert.equal( parseConnectionString( - 'iggy://iggy:secret@localhost:8090?reconnection_retries=unlimited' + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=unlimited' ).reconnect?.maxRetries, MAX_U32 ); @@ -126,13 +184,13 @@ describe('parseConnectionString', () => { it('accepts retry counts up to u32::MAX and rejects overflow', () => { assert.equal( parseConnectionString( - `iggy://iggy:secret@localhost:8090?reconnection_retries=${MAX_U32}` + `iggy://iggy:secret@localhost:8090?reconnection_max_retries=${MAX_U32}` ).reconnect?.maxRetries, MAX_U32 ); for (const value of [ - 'iggy://iggy:secret@localhost:8090?reconnection_retries=4294967296', - 'iggy://iggy:secret@localhost:8090?reconnection_retries=99999999999999' + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=4294967296', + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=99999999999999' ]) assert.throws(() => parseConnectionString(value), TypeError); }); @@ -195,7 +253,7 @@ describe('parseConnectionString', () => { 'iggy://iggy:secret@localhost:70000', 'iggy://iggy:secret@localhost:8090?unknown=value', 'iggy://iggy:secret@localhost:8090?tls=maybe', - 'iggy://iggy:secret@localhost:8090?reconnection_retries=three', + 'iggy://iggy:secret@localhost:8090?reconnection_max_retries=three', 'iggy://iggy:secret@[::1:8090', 'iggy://iggy:secret@[]:8090', 'iggy://iggy:secret@[::1]x:8090', @@ -215,7 +273,7 @@ describe('parseConnectionString', () => { `iggy+tcp://iggypat-1234567890abcdef@localhost`, 'iggy://iggy:hunter2@localhost:8090?unknown=value', 'iggy://iggy:hunter2@localhost:8090?tls=maybe', - 'iggy://iggy:hunter2@localhost:8090?reconnection_retries=three', + 'iggy://iggy:hunter2@localhost:8090?reconnection_max_retries=three', 'iggy://iggy:hunter2@localhost:70000' ]) { try { diff --git a/foreign/node/src/client/client.connection-string.ts b/foreign/node/src/client/client.connection-string.ts index 75d0b67e29..2bee006744 100644 --- a/foreign/node/src/client/client.connection-string.ts +++ b/foreign/node/src/client/client.connection-string.ts @@ -114,14 +114,23 @@ export const parseConnectionString = (connectionString: string): ClientConfig => tls: false, reconnect: { ...CONNECTION_STRING_RECONNECT } }; - const { tls, reconnect, heartbeatInterval, ...transportOptions } = options; + const { + tls, + tlsValidateCertificate, + reconnect, + heartbeatInterval, + ...transportOptions + } = options; const config: ClientConfig = { transport: tls ? 'TLS' : 'TCP', options: { host, port: Number(port), - ...transportOptions + ...transportOptions, + ...(tlsValidateCertificate === undefined + ? {} + : { rejectUnauthorized: tlsValidateCertificate }) }, credentials: tokenCredentials ? { token: username } @@ -139,6 +148,7 @@ export const parseConnectionString = (connectionString: string): ClientConfig => type ParsedConnectionOptions = { tls: boolean, noDelay?: boolean, + tlsValidateCertificate?: boolean, servername?: string, /** Path stored for connect-time reading, match Rust SDK. */ caFile?: string, @@ -162,6 +172,9 @@ const parseConnectionOptions = ( case 'tls': parsed.tls = parseBoolean(name, value); break; + case 'tls_validate_certificate': + parsed.tlsValidateCertificate = parseBoolean(name, value); + break; case 'nodelay': parsed.noDelay = parseBoolean(name, value); break; @@ -175,7 +188,14 @@ const parseConnectionOptions = ( parsed.caFile = value; break; + case 'reconnection_max_retries': + // TODO: Remove the deprecated `reconnection_retries` alias after the compatibility release. case 'reconnection_retries': { + if (name === 'reconnection_retries') + process.emitWarning( + "Connection string option 'reconnection_retries' is deprecated; use 'reconnection_max_retries'", + 'DeprecationWarning' + ); // Values above u32::MAX are rejected like the Rust SDK's u32 // overflow; otherwise they would act as a second, undocumented // spelling of "unlimited". diff --git a/foreign/node/src/client/client.connection.test.ts b/foreign/node/src/client/client.connection.test.ts index 1d671eb4cf..06a4391a50 100644 --- a/foreign/node/src/client/client.connection.test.ts +++ b/foreign/node/src/client/client.connection.test.ts @@ -31,6 +31,7 @@ import { import { describe, it, before, after } from 'node:test'; import { ProtocolFrameError } from './client.frame.js'; import { IggyConnection } from './client.connection.js'; +import { parseConnectionString } from './client.connection-string.js'; import type { ClientConfig } from './client.type.js'; import { Command, HEADER_SIZE, REPLY_OFFSET } from '../wire/vsr/header.js'; @@ -927,6 +928,22 @@ describe('IggyConnection', () => { } ); + it('disables certificate validation through the connection string', async () => { + const server = await startTlsServer(); + const port = (server.address() as AddressInfo).port; + const config = parseConnectionString( + `iggy://iggy:iggy@127.0.0.1:${port}?tls=true&tls_validate_certificate=false` + ); + config.reconnect = { enabled: false, interval: 0, maxRetries: 0 }; + const connection = new IggyConnection(config); + try { + await connection.connect(); + assert.equal(connection.connected, true); + } finally { + await closeConnection(connection, server); + } + }); + it('omits SNI for IP literal hosts', async () => { const server = await startTlsServer(); const secureConnection = diff --git a/foreign/node/src/e2e/tcp.connection-string.e2e.ts b/foreign/node/src/e2e/tcp.connection-string.e2e.ts index c42eac44a6..b50052ee0f 100644 --- a/foreign/node/src/e2e/tcp.connection-string.e2e.ts +++ b/foreign/node/src/e2e/tcp.connection-string.e2e.ts @@ -23,7 +23,7 @@ import { MAX_U32 } from '../constant.js'; import { getIggyAddress } from '../tcp.sm.utils.js'; const dummyOpt = 'nodelay=true' + - '&reconnection_retries=1' + + '&reconnection_max_retries=1' + '&reconnection_interval=1s' + '&heartbeat_interval=10s' + '&tls=false'; @@ -41,14 +41,14 @@ const optionCases: { }[] = [ { name: 'unlimited retries at the default interval', - query: 'reconnection_retries=unlimited', + query: 'reconnection_max_retries=unlimited', expect: { reconnect: { enabled: true, interval: 1000, maxRetries: MAX_U32 } } }, { name: 'bounded retries with a sub-second interval', - query: 'reconnection_retries=10&reconnection_interval=250ms', + query: 'reconnection_max_retries=10&reconnection_interval=250ms', expect: { reconnect: { enabled: true, interval: 250, maxRetries: 10 } } diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 7e80fe0659..8e5af33a5b 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -445,7 +445,7 @@ async def test_config_and_connection_string_both_authenticate(self, unique_name) ) from_string = IggyClient.from_connection_string( f"iggy+tcp://iggy:iggy@{host}:{port}" - "?reconnection_retries=3&reconnection_interval=1s" + "?reconnection_max_retries=3&reconnection_interval=1s" ) stream_name = unique_name() diff --git a/foreign/python/tests/test_connectivity.py b/foreign/python/tests/test_connectivity.py index 3b4230705d..4e0b48dd9e 100644 --- a/foreign/python/tests/test_connectivity.py +++ b/foreign/python/tests/test_connectivity.py @@ -35,11 +35,11 @@ async def test_client_not_none(self, iggy_client: IggyClient): [ "iggy://iggy:iggy@127.0.0.1:8090", "iggy+tcp://iggy:iggy@127.0.0.1:8090", - "iggy+tcp://iggy:iggy@127.0.0.1:8090?reconnection_retries=3&reconnection_interval=1s&reestablish_after=5s&heartbeat_interval=5s&nodelay=true&tls_domain=localhost&tls_ca_file=unused.pem&tls=false", + "iggy+tcp://iggy:iggy@127.0.0.1:8090?reconnection_max_retries=3&reconnection_interval=1s&reestablish_after=5s&heartbeat_interval=5s&nodelay=true&tls_domain=localhost&tls_ca_file=unused.pem&tls=false", "iggy+http://iggy:iggy@127.0.0.1:3000", "iggy+http://iggy:iggy@127.0.0.1:3000?heartbeat_interval=5s&retries=3", "iggy+ws://iggy:iggy@127.0.0.1:8092", - "iggy+ws://iggy:iggy@127.0.0.1:8092?heartbeat_interval=5s&reconnection_retries=3&reconnection_interval=1s&reestablish_after=5s&read_buffer_size=4096&write_buffer_size=4096&max_write_buffer_size=8192&max_message_size=16384&max_frame_size=16384&accept_unmasked_frames=false&tls_domain=localhost&tls_ca_file=unused.pem&tls_validate_certificate=false&tls=false", + "iggy+ws://iggy:iggy@127.0.0.1:8092?heartbeat_interval=5s&reconnection_max_retries=3&reconnection_interval=1s&reestablish_after=5s&read_buffer_size=4096&write_buffer_size=4096&max_write_buffer_size=8192&max_message_size=16384&max_frame_size=16384&accept_unmasked_frames=false&tls_domain=localhost&tls_ca_file=unused.pem&tls_validate_certificate=false&tls=false", "iggy+quic://iggy:iggy@127.0.0.1:8080?reconnection_max_retries=0", ], )