Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>5.2.0</version>
<version>5.3.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
103 changes: 102 additions & 1 deletion src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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.
*
* <p>Examples:
*
* <pre>
* ::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
* </pre>
*
* <p>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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
Loading