This is an automated email from the ASF dual-hosted git repository.
hgruszecki pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new b05ffa398 fix(sdk): allow hostnames in server address configuration
(#2923)
b05ffa398 is described below
commit b05ffa3985e2a2f5cc4f2a06766426d767ec4af1
Author: Faisal Ahmed <[email protected]>
AuthorDate: Tue Apr 7 12:48:16 2026 +0530
fix(sdk): allow hostnames in server address configuration (#2923)
---
core/common/src/lib.rs | 1 +
.../tcp_config/tcp_client_config_builder.rs | 33 ++-
.../websocket_client_config_builder.rs | 19 +-
core/common/src/utils/mod.rs | 1 +
core/common/src/utils/net.rs | 310 +++++++++++++++++++++
examples/rust/src/getting-started/consumer/main.rs | 7 -
examples/rust/src/getting-started/producer/main.rs | 7 -
7 files changed, 335 insertions(+), 43 deletions(-)
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 3e9b20bdf..dc7fbba3a 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -127,6 +127,7 @@ pub use utils::crypto::*;
pub use utils::duration::{IggyDuration, SEC_IN_MICRO};
pub use utils::expiry::IggyExpiry;
pub use utils::hash::*;
+pub use utils::net::validate_server_address;
pub use utils::personal_access_token_expiry::PersonalAccessTokenExpiry;
pub use utils::random_id;
pub use utils::serde_secret;
diff --git
a/core/common/src/types/configuration/tcp_config/tcp_client_config_builder.rs
b/core/common/src/types/configuration/tcp_config/tcp_client_config_builder.rs
index a79ec2341..6c74acaf5 100644
---
a/core/common/src/types/configuration/tcp_config/tcp_client_config_builder.rs
+++
b/core/common/src/types/configuration/tcp_config/tcp_client_config_builder.rs
@@ -15,8 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use crate::{AutoLogin, IggyDuration, IggyError, TcpClientConfig};
-use std::net::SocketAddr;
+use crate::{AutoLogin, IggyDuration, IggyError, TcpClientConfig,
validate_server_address};
/// Builder for the TCP client configuration.
/// Allows configuring the TCP client with custom settings or using defaults:
@@ -103,13 +102,9 @@ impl TcpClientConfigBuilder {
}
/// Builds the TCP client configuration.
- pub fn build(self) -> Result<TcpClientConfig, IggyError> {
- let addr = self.config.server_address.trim();
-
- if addr.parse::<SocketAddr>().is_err() {
- let (ip, port) = addr.rsplit_once(':').unwrap_or((addr, ""));
- return Err(IggyError::InvalidIpAddress(ip.to_owned(),
port.to_owned()));
- }
+ pub fn build(mut self) -> Result<TcpClientConfig, IggyError> {
+ self.config.server_address =
self.config.server_address.trim().to_string();
+ validate_server_address(&self.config.server_address)?;
Ok(self.config)
}
@@ -148,12 +143,16 @@ mod tests {
}
#[test]
- fn invalid_ip_should_fail() {
+ fn valid_dns_name_should_succeed() {
+ let builder = builder_with_address("localhost:8080");
+ assert!(builder.build().is_ok());
+ }
+
+ #[test]
+ fn unresolvable_hostname_should_succeed() {
+ // Format is valid; DNS resolution is not attempted
let builder = builder_with_address("invalid.ip:8080");
- assert!(matches!(
- builder.build(),
- Err(IggyError::InvalidIpAddress(_, _))
- ));
+ assert!(builder.build().is_ok());
}
#[test]
@@ -182,4 +181,10 @@ mod tests {
Err(IggyError::InvalidIpAddress(_, _))
));
}
+
+ #[test]
+ fn docker_compose_service_name_should_succeed() {
+ let builder = builder_with_address("iggy-server:8090");
+ assert!(builder.build().is_ok());
+ }
}
diff --git
a/core/common/src/types/configuration/websocket_config/websocket_client_config_builder.rs
b/core/common/src/types/configuration/websocket_config/websocket_client_config_builder.rs
index 27e296f6c..62bfe03e5 100644
---
a/core/common/src/types/configuration/websocket_config/websocket_client_config_builder.rs
+++
b/core/common/src/types/configuration/websocket_config/websocket_client_config_builder.rs
@@ -16,8 +16,7 @@
* under the License.
*/
-use crate::{AutoLogin, IggyDuration, IggyError, WebSocketClientConfig};
-use std::net::SocketAddr;
+use crate::{AutoLogin, IggyDuration, IggyError, WebSocketClientConfig,
validate_server_address};
/// Builder for the WebSocket client configuration.
/// Allows configuring the WebSocket client with custom settings or using
defaults:
@@ -139,19 +138,9 @@ impl WebSocketClientConfigBuilder {
}
/// Builds the WebSocket client configuration.
- pub fn build(self) -> Result<WebSocketClientConfig, IggyError> {
- let addr = self.config.server_address.trim();
-
- // Check if it's a valid socket address or host:port format
- if addr.parse::<SocketAddr>().is_err() {
- let (host, port) = addr.rsplit_once(':').unwrap_or((addr, ""));
- if port.is_empty() || port.parse::<u16>().is_err() {
- return Err(IggyError::InvalidIpAddress(
- host.to_owned(),
- port.to_owned(),
- ));
- }
- }
+ pub fn build(mut self) -> Result<WebSocketClientConfig, IggyError> {
+ self.config.server_address =
self.config.server_address.trim().to_string();
+ validate_server_address(&self.config.server_address)?;
Ok(self.config)
}
diff --git a/core/common/src/utils/mod.rs b/core/common/src/utils/mod.rs
index 0a300136e..7f5ae76ff 100644
--- a/core/common/src/utils/mod.rs
+++ b/core/common/src/utils/mod.rs
@@ -23,6 +23,7 @@ pub(crate) mod crypto;
pub(crate) mod duration;
pub(crate) mod expiry;
pub(crate) mod hash;
+pub(crate) mod net;
pub(crate) mod personal_access_token_expiry;
pub mod random_id;
pub mod serde_secret;
diff --git a/core/common/src/utils/net.rs b/core/common/src/utils/net.rs
new file mode 100644
index 000000000..1720c2396
--- /dev/null
+++ b/core/common/src/utils/net.rs
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::IggyError;
+use std::net::{Ipv4Addr, Ipv6Addr};
+
+/// Validates that `addr` is syntactically a valid `host:port` string.
+/// Does NOT perform DNS resolution.
+///
+/// Accepted formats:
+/// - `hostname:port` (e.g. `iggy-server:8090`, `localhost:8090`)
+/// - `ipv4:port` (e.g. `127.0.0.1:8090`)
+/// - `[ipv6]:port` (e.g. `[::1]:8090`)
+///
+/// Rejected formats:
+/// - Bare IPv6 without brackets (e.g. `::1:8080`) — ambiguous due to colons
+/// - Missing port (e.g. `localhost`)
+/// - Invalid port (e.g. `localhost:abc`, `localhost:65536`)
+pub fn validate_server_address(addr: &str) -> Result<(), IggyError> {
+ if addr.starts_with('[') {
+ // Bracketed IPv6: "[::1]:port"
+ let close = addr.find(']').ok_or(IggyError::InvalidIpAddress(
+ addr.to_string(),
+ "<missing>".to_string(),
+ ))?;
+ let ipv6_str = &addr[1..close];
+ let port_str = addr[close + 1..]
+ .strip_prefix(':')
+ .ok_or(IggyError::InvalidIpAddress(
+ addr.to_string(),
+ "<missing>".to_string(),
+ ))?;
+
+ // Validate IPv6 address
+ ipv6_str
+ .parse::<Ipv6Addr>()
+ .map_err(|_| IggyError::InvalidIpAddress(ipv6_str.to_string(),
port_str.to_string()))?;
+
+ if !port_str.parse::<u16>().is_ok_and(|port| port != 0) {
+ return Err(IggyError::InvalidIpAddress(
+ ipv6_str.to_string(),
+ port_str.to_string(),
+ ));
+ }
+
+ return Ok(());
+ }
+ // hostname:port or IPv4:port — rsplit_once to split at last colon
+ let (host, port_str) =
addr.rsplit_once(':').ok_or(IggyError::InvalidIpAddress(
+ addr.to_string(),
+ "<missing>".to_string(),
+ ))?;
+
+ // Validate host (IPv4 or hostname with RFC 1123 compliance)
+ if !is_valid_host(host) {
+ return Err(IggyError::InvalidIpAddress(
+ host.to_string(),
+ port_str.to_string(),
+ ));
+ }
+
+ if !port_str.parse::<u16>().is_ok_and(|port| port != 0) {
+ return Err(IggyError::InvalidIpAddress(
+ host.to_string(),
+ port_str.to_string(),
+ ));
+ }
+
+ Ok(())
+}
+
+fn is_valid_hostname(host: &str) -> bool {
+ if host.is_empty() || host.len() > 253 || host.contains(':') {
+ return false;
+ }
+
+ host.split('.').all(|label| {
+ !label.is_empty()
+ && label.len() <= 63
+ && label
+ .chars()
+ .next()
+ .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
+ && label
+ .chars()
+ .last()
+ .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
+ && label
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
+ })
+}
+
+fn is_valid_host(host: &str) -> bool {
+ // Try to parse as IP first
+ if host.parse::<Ipv4Addr>().is_ok() {
+ return true;
+ }
+
+ // If it looks like an IP (all digits and dots), reject it
+ if host.chars().all(|c| c.is_ascii_digit() || c == '.') {
+ return false;
+ }
+
+ // Otherwise, validate as hostname
+ is_valid_hostname(host)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn valid_ipv4_with_port() {
+ assert!(validate_server_address("127.0.0.1:8090").is_ok());
+ assert!(validate_server_address("192.168.1.1:65535").is_ok());
+ }
+
+ #[test]
+ fn valid_ipv6_with_brackets() {
+ assert!(validate_server_address("[::1]:8090").is_ok());
+ assert!(validate_server_address("[2001:db8::1]:65535").is_ok());
+ }
+
+ #[test]
+ fn valid_hostname_with_port() {
+ assert!(validate_server_address("localhost:8090").is_ok());
+ assert!(validate_server_address("iggy-server:8090").is_ok());
+
assert!(validate_server_address("iggy.default.svc.cluster.local:8090").is_ok());
+ assert!(validate_server_address("example.com:80").is_ok());
+ }
+
+ #[test]
+ fn bare_ipv6_without_brackets_should_fail() {
+ // Ambiguous format, not supported
+ assert!(validate_server_address("::1:8080").is_err());
+ }
+
+ #[test]
+ fn unresolvable_hostname_should_succeed() {
+ // Format is valid, DNS is not attempted
+ assert!(validate_server_address("invalid.ip:8080").is_ok());
+ }
+
+ #[test]
+ fn missing_port_should_fail() {
+ assert!(validate_server_address("localhost").is_err());
+ assert!(validate_server_address("127.0.0.1").is_err());
+ }
+
+ #[test]
+ fn invalid_port_should_fail() {
+ assert!(validate_server_address("localhost:abc").is_err());
+ assert!(validate_server_address("127.0.0.1:invalid").is_err());
+ }
+
+ #[test]
+ fn port_out_of_range_should_fail() {
+ assert!(validate_server_address("localhost:65536").is_err());
+ assert!(validate_server_address("127.0.0.1:70000").is_err());
+ }
+
+ #[test]
+ fn port_65535_should_succeed() {
+ assert!(validate_server_address("localhost:65535").is_ok());
+ }
+
+ #[test]
+ fn port_0_should_fail() {
+ assert!(validate_server_address("localhost:0").is_err());
+ assert!(validate_server_address("127.0.0.1:0").is_err());
+ assert!(validate_server_address("[::1]:0").is_err());
+ }
+
+ #[test]
+ fn ipv6_missing_closing_bracket_should_fail() {
+ assert!(validate_server_address("[::1:8090").is_err());
+ }
+
+ #[test]
+ fn empty_host_should_fail() {
+ assert!(validate_server_address(":8090").is_err());
+ }
+
+ #[test]
+ fn empty_string_should_fail() {
+ assert!(validate_server_address("").is_err());
+ }
+
+ #[test]
+ fn valid_hostname_labels() {
+ assert!(is_valid_hostname("localhost"));
+ assert!(is_valid_hostname("example"));
+ assert!(is_valid_hostname("example-server"));
+ assert!(is_valid_hostname("my-server-01"));
+ assert!(is_valid_hostname("a"));
+ }
+
+ #[test]
+ fn valid_fqdn() {
+ assert!(is_valid_hostname("example.com"));
+ assert!(is_valid_hostname("sub.example.com"));
+ assert!(is_valid_hostname("my-server.prod.example.com"));
+ assert!(is_valid_hostname("iggy.default.svc.cluster.local"));
+ }
+
+ #[test]
+ fn valid_hostname_with_underscores() {
+ // Docker Compose style names
+ assert!(is_valid_hostname("my_project_redis"));
+ assert!(is_valid_hostname("docker_compose_service"));
+ // SRV records
+ assert!(is_valid_hostname("_svc._tcp.example.com"));
+ // Mixed
+ assert!(is_valid_hostname("my_server-01.prod.example.com"));
+ }
+
+ #[test]
+ fn valid_underscore_hostname_with_port() {
+ assert!(validate_server_address("my_project_redis:6379").is_ok());
+ assert!(validate_server_address("_svc._tcp.example.com:8090").is_ok());
+ }
+
+ #[test]
+ fn invalid_hostname_empty() {
+ assert!(!is_valid_hostname(""));
+ }
+
+ #[test]
+ fn invalid_hostname_too_long() {
+ let long = "a".repeat(254);
+ assert!(!is_valid_hostname(&long));
+ }
+
+ #[test]
+ fn invalid_hostname_label_too_long() {
+ let long_label = format!("{}.com", "a".repeat(64));
+ assert!(!is_valid_hostname(&long_label));
+ }
+
+ #[test]
+ fn invalid_hostname_empty_label() {
+ assert!(!is_valid_hostname("example..com"));
+ assert!(!is_valid_hostname(".example.com"));
+ assert!(!is_valid_hostname("example.com."));
+ }
+
+ #[test]
+ fn invalid_hostname_start_with_hyphen() {
+ assert!(!is_valid_hostname("-example"));
+ assert!(!is_valid_hostname("example.-com"));
+ }
+
+ #[test]
+ fn invalid_hostname_end_with_hyphen() {
+ assert!(!is_valid_hostname("example-"));
+ assert!(!is_valid_hostname("example.com-"));
+ }
+
+ #[test]
+ fn invalid_hostname_invalid_characters() {
+ assert!(is_valid_hostname("example_com"));
+ assert!(!is_valid_hostname("example@com"));
+ assert!(!is_valid_hostname("example com"));
+ assert!(!is_valid_hostname("example.c0m!"));
+ }
+
+ #[test]
+ fn validate_server_address_rejects_invalid_hostname() {
+ assert!(validate_server_address("example..com:8090").is_err());
+ assert!(validate_server_address("-invalid:8090").is_err());
+ assert!(validate_server_address("invalid-:8090").is_err());
+ assert!(validate_server_address("example_invalid:8090").is_ok());
+ }
+
+ #[test]
+ fn invalid_ipv6_in_brackets_should_fail() {
+ assert!(validate_server_address("[invalid]:8090").is_err());
+ assert!(validate_server_address("[::gggg]:8090").is_err());
+ assert!(validate_server_address("[192.168.1.1]:8090").is_err());
+ }
+
+ #[test]
+ fn valid_ipv4_address_should_succeed() {
+ assert!(validate_server_address("192.168.1.1:8090").is_ok());
+ assert!(validate_server_address("10.0.0.1:8090").is_ok());
+ }
+
+ #[test]
+ fn invalid_ipv4_address_should_fail() {
+ assert!(validate_server_address("256.1.1.1:8090").is_err());
+ assert!(validate_server_address("192.168.1:8090").is_err());
+ }
+}
diff --git a/examples/rust/src/getting-started/consumer/main.rs
b/examples/rust/src/getting-started/consumer/main.rs
index 4c138b427..bb5c174d5 100644
--- a/examples/rust/src/getting-started/consumer/main.rs
+++ b/examples/rust/src/getting-started/consumer/main.rs
@@ -128,13 +128,6 @@ fn get_tcp_server_addr() -> String {
);
}
let tcp_server_addr = tcp_server_addr.unwrap();
- if tcp_server_addr.parse::<std::net::SocketAddr>().is_err() {
- panic!(
- "Invalid server address {}! Usage: {} --tcp-server-address
<server-address>",
- tcp_server_addr,
- env::args().next().unwrap()
- );
- }
info!("Using server address: {}", tcp_server_addr);
tcp_server_addr
}
diff --git a/examples/rust/src/getting-started/producer/main.rs
b/examples/rust/src/getting-started/producer/main.rs
index 2e8ec22d4..4279a5f18 100644
--- a/examples/rust/src/getting-started/producer/main.rs
+++ b/examples/rust/src/getting-started/producer/main.rs
@@ -167,13 +167,6 @@ fn get_tcp_server_addr() -> String {
);
}
let tcp_server_addr = tcp_server_addr.unwrap();
- if tcp_server_addr.parse::<std::net::SocketAddr>().is_err() {
- panic!(
- "Invalid server address {}! Usage: {} --tcp-server-address
<server-address>",
- tcp_server_addr,
- env::args().next().unwrap()
- );
- }
info!("Using server address: {}", tcp_server_addr);
tcp_server_addr
}