From ab304970060b22d7232aba3ac575007b01cca854 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Thu, 9 Apr 2026 00:41:58 +0200 Subject: [PATCH] prep(v5.3.0): fix IPv6 endpoint classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v5.3.0 prep for the planned 2026-04-09 release. Not cut here — PR stays open for user review. Review finding P3 (user-reported): - classifyEndpoint now handles IPv6 private ranges and expanded loopback forms that previously fell through to REMOTE: - 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) → localhost - IPv6 unspecified (::) → localhost - Matches Python and Go SDK behavior. Implementation: new expandIPv6(addr) helper expands :: compression into a full 8-hextet form for prefix comparison. ULA detection checks first hextet starts with 'fc' or 'fd'. Link-local detection checks first hextet in [fe80..febf]. Deprecated site-local fec0::/10 stays remote. Tests: 26/26 pass (up from 20 in v5.2.0). New cases for IPv6 ULA, link- local, expanded loopback, unspecified, public IPv6, deprecated site-local. Manifest bumps: - pom.xml: 5.2.0 → 5.3.0 - v5.2.0 changelog entry date corrected 2026-04-09 → 2026-04-08 --- CHANGELOG.md | 16 ++- pom.xml | 2 +- .../sdk/telemetry/TelemetryReporter.java | 103 +++++++++++++++++- .../telemetry/TelemetryEndpointTypeTest.java | 47 ++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) 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 @@ com.getaxonflow axonflow-sdk - 5.2.0 + 5.3.0 jar AxonFlow Java SDK diff --git a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java index e9a3be6..6e503c4 100644 --- a/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java +++ b/src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java @@ -270,7 +270,6 @@ public static String classifyEndpoint(String url) { if ("localhost".equals(host) || "0.0.0.0".equals(host) - || "::1".equals(host) || host.endsWith(".localhost")) { return EndpointType.LOCALHOST; } @@ -295,10 +294,112 @@ public static String classifyEndpoint(String url) { return EndpointType.REMOTE; } + // IPv6 classification. + // + // v5.3.0 fix (review finding P3): previously only the literal "::1" + // was recognized; ULA, link-local, and expanded loopback forms fell + // through to REMOTE. Python and Go SDKs classify them correctly via + // stdlib helpers — this hand-rolled version matches that behavior. + if (host.indexOf(':') >= 0) { + String expanded = expandIPv6(host); + if ("0000:0000:0000:0000:0000:0000:0000:0001".equals(expanded)) { + return EndpointType.LOCALHOST; // ::1 and all equivalent forms + } + if ("0000:0000:0000:0000:0000:0000:0000:0000".equals(expanded)) { + return EndpointType.LOCALHOST; // :: listen-all (symmetric with 0.0.0.0) + } + if (expanded.length() >= 4) { + String firstHextet = expanded.substring(0, 4); + // ULA fc00::/7 — first hex pair is fc or fd + if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) { + return EndpointType.PRIVATE_NETWORK; + } + // Link-local fe80::/10 — first hextet in [fe80..febf] + if (firstHextet.compareTo("fe80") >= 0 && firstHextet.compareTo("febf") <= 0) { + return EndpointType.PRIVATE_NETWORK; + } + } + return EndpointType.REMOTE; + } + // Public hostname (not an IP, not a known private suffix). return EndpointType.REMOTE; } + /** + * Expand an IPv6 address to its full 8-hextet form with every hextet + * zero-padded to 4 hex digits. Returns the input unchanged on parse failure. + * + *

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() {