diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6800f75..1ec83e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,21 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [5.2.0] - 2026-04-09
+## [5.3.0] - Release Pending (2026-04-09)
+
+### Fixed
+
+- **IPv6 endpoint classification.** `classifyEndpoint` now handles IPv6 private ranges and expanded loopback forms that previously fell through to `REMOTE`, matching the Python and Go SDK implementations:
+ - IPv6 ULA (`fc00::/7`, RFC 4193) → `private_network`
+ - IPv6 link-local (`fe80::/10`) → `private_network`
+ - Expanded IPv6 loopback (`0:0:0:0:0:0:0:1`, zero-padded forms) → `localhost`
+ - IPv6 unspecified (`::`) → `localhost` (symmetric with `0.0.0.0`)
+ - Public IPv6 addresses (`2001::/3` space) → `remote`
+- A new `expandIPv6(addr)` helper expands `::` compression into a full 8-hextet form for prefix comparison. Not a general-purpose parser — assumes input came from `URI.getHost()` after brackets are stripped.
+
+---
+
+## [5.2.0] - 2026-04-08
### Added
diff --git a/pom.xml b/pom.xml
index 75e71db..54899b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
Examples: + * + *
+ * ::1 → 0000:0000:0000:0000:0000:0000:0000:0001 + * fd00::1 → fd00:0000:0000:0000:0000:0000:0000:0001 + * fe80::a → fe80:0000:0000:0000:0000:0000:0000:000a + *+ * + *
This is NOT a general-purpose IPv6 parser — it assumes the input came + * from URI.getHost() after brackets are stripped. + */ + static String expandIPv6(String addr) { + String[] head; + String[] tail; + int doubleColon = addr.indexOf("::"); + if (doubleColon >= 0) { + String headStr = addr.substring(0, doubleColon); + String tailStr = addr.substring(doubleColon + 2); + if (headStr.indexOf("::") >= 0 || tailStr.indexOf("::") >= 0) { + return addr; // more than one "::" — invalid + } + head = headStr.isEmpty() ? new String[0] : headStr.split(":"); + tail = tailStr.isEmpty() ? new String[0] : tailStr.split(":"); + } else { + head = addr.split(":"); + tail = new String[0]; + } + int missing = 8 - head.length - tail.length; + if (missing < 0) { + return addr; + } + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (String h : head) { + if (!first) sb.append(':'); + sb.append(padHextet(h)); + first = false; + } + for (int i = 0; i < missing; i++) { + if (!first) sb.append(':'); + sb.append("0000"); + first = false; + } + for (String h : tail) { + if (!first) sb.append(':'); + sb.append(padHextet(h)); + first = false; + } + String result = sb.toString(); + // Must end up with exactly 8 hextets (7 colons). + int colonCount = 0; + for (int i = 0; i < result.length(); i++) { + if (result.charAt(i) == ':') colonCount++; + } + if (colonCount != 7) { + return addr; + } + return result; + } + + private static String padHextet(String h) { + if (h.length() >= 4) return h; + StringBuilder sb = new StringBuilder(4); + for (int i = h.length(); i < 4; i++) { + sb.append('0'); + } + sb.append(h); + return sb.toString(); + } + /** * Detect platform version by calling the agent's /health endpoint. Returns null on any failure. */ diff --git a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java index 164f7fe..3b3346d 100644 --- a/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java +++ b/src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java @@ -43,6 +43,22 @@ void localhostIPv6() { assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]:8080")); } + @Test + @DisplayName("localhost: expanded IPv6 loopback 0:0:0:0:0:0:0:1") + void localhostExpandedIPv6() { + // v5.3.0 fix: alternate loopback forms must match Python/Go SDK behavior. + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[0:0:0:0:0:0:0:1]")); + assertEquals( + "localhost", + TelemetryReporter.classifyEndpoint("http://[0000:0000:0000:0000:0000:0000:0000:0001]")); + } + + @Test + @DisplayName("localhost: IPv6 unspecified ::") + void localhostIPv6Unspecified() { + assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::]:8080")); + } + @Test @DisplayName("localhost: 0.0.0.0") void localhostZero() { @@ -96,6 +112,37 @@ void privateLinkLocal() { assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://169.254.169.254")); } + @Test + @DisplayName("private: IPv6 ULA fc00::/7") + void privateIPv6ULA() { + // v5.3.0 fix (review finding P3): IPv6 ULA used to fall through to remote. + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fd00::1]:8080")); + assertEquals( + "private_network", TelemetryReporter.classifyEndpoint("http://[fd12:3456:789a::1]")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fc00::1]")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fcff:ffff::]")); + } + + @Test + @DisplayName("private: IPv6 link-local fe80::/10") + void privateIPv6LinkLocal() { + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fe80::1]")); + assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[febf::1]")); + } + + @Test + @DisplayName("remote: deprecated fec0::/10 site-local") + void remoteDeprecatedSiteLocal() { + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[fec0::1]")); + } + + @Test + @DisplayName("remote: public IPv6 addresses") + void remotePublicIPv6() { + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[2001:4860:4860::8888]")); + assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[2606:4700:4700::1111]")); + } + @Test @DisplayName("private: hostname suffixes") void privateHostnameSuffixes() {