Skip to content

Commit 5288bd1

Browse files
prep(v5.3.0): fix IPv6 endpoint classification (#126)
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
1 parent 06e8b90 commit 5288bd1

4 files changed

Lines changed: 165 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,21 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [5.2.0] - 2026-04-09
8+
## [5.3.0] - Release Pending (2026-04-09)
9+
10+
### Fixed
11+
12+
- **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:
13+
- IPv6 ULA (`fc00::/7`, RFC 4193) → `private_network`
14+
- IPv6 link-local (`fe80::/10`) → `private_network`
15+
- Expanded IPv6 loopback (`0:0:0:0:0:0:0:1`, zero-padded forms) → `localhost`
16+
- IPv6 unspecified (`::`) → `localhost` (symmetric with `0.0.0.0`)
17+
- Public IPv6 addresses (`2001::/3` space) → `remote`
18+
- 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.
19+
20+
---
21+
22+
## [5.2.0] - 2026-04-08
923

1024
### Added
1125

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.getaxonflow</groupId>
88
<artifactId>axonflow-sdk</artifactId>
9-
<version>5.2.0</version>
9+
<version>5.3.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AxonFlow Java SDK</name>

src/main/java/com/getaxonflow/sdk/telemetry/TelemetryReporter.java

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,6 @@ public static String classifyEndpoint(String url) {
270270

271271
if ("localhost".equals(host)
272272
|| "0.0.0.0".equals(host)
273-
|| "::1".equals(host)
274273
|| host.endsWith(".localhost")) {
275274
return EndpointType.LOCALHOST;
276275
}
@@ -295,10 +294,112 @@ public static String classifyEndpoint(String url) {
295294
return EndpointType.REMOTE;
296295
}
297296

297+
// IPv6 classification.
298+
//
299+
// v5.3.0 fix (review finding P3): previously only the literal "::1"
300+
// was recognized; ULA, link-local, and expanded loopback forms fell
301+
// through to REMOTE. Python and Go SDKs classify them correctly via
302+
// stdlib helpers — this hand-rolled version matches that behavior.
303+
if (host.indexOf(':') >= 0) {
304+
String expanded = expandIPv6(host);
305+
if ("0000:0000:0000:0000:0000:0000:0000:0001".equals(expanded)) {
306+
return EndpointType.LOCALHOST; // ::1 and all equivalent forms
307+
}
308+
if ("0000:0000:0000:0000:0000:0000:0000:0000".equals(expanded)) {
309+
return EndpointType.LOCALHOST; // :: listen-all (symmetric with 0.0.0.0)
310+
}
311+
if (expanded.length() >= 4) {
312+
String firstHextet = expanded.substring(0, 4);
313+
// ULA fc00::/7 — first hex pair is fc or fd
314+
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) {
315+
return EndpointType.PRIVATE_NETWORK;
316+
}
317+
// Link-local fe80::/10 — first hextet in [fe80..febf]
318+
if (firstHextet.compareTo("fe80") >= 0 && firstHextet.compareTo("febf") <= 0) {
319+
return EndpointType.PRIVATE_NETWORK;
320+
}
321+
}
322+
return EndpointType.REMOTE;
323+
}
324+
298325
// Public hostname (not an IP, not a known private suffix).
299326
return EndpointType.REMOTE;
300327
}
301328

329+
/**
330+
* Expand an IPv6 address to its full 8-hextet form with every hextet
331+
* zero-padded to 4 hex digits. Returns the input unchanged on parse failure.
332+
*
333+
* <p>Examples:
334+
*
335+
* <pre>
336+
* ::1 → 0000:0000:0000:0000:0000:0000:0000:0001
337+
* fd00::1 → fd00:0000:0000:0000:0000:0000:0000:0001
338+
* fe80::a → fe80:0000:0000:0000:0000:0000:0000:000a
339+
* </pre>
340+
*
341+
* <p>This is NOT a general-purpose IPv6 parser — it assumes the input came
342+
* from URI.getHost() after brackets are stripped.
343+
*/
344+
static String expandIPv6(String addr) {
345+
String[] head;
346+
String[] tail;
347+
int doubleColon = addr.indexOf("::");
348+
if (doubleColon >= 0) {
349+
String headStr = addr.substring(0, doubleColon);
350+
String tailStr = addr.substring(doubleColon + 2);
351+
if (headStr.indexOf("::") >= 0 || tailStr.indexOf("::") >= 0) {
352+
return addr; // more than one "::" — invalid
353+
}
354+
head = headStr.isEmpty() ? new String[0] : headStr.split(":");
355+
tail = tailStr.isEmpty() ? new String[0] : tailStr.split(":");
356+
} else {
357+
head = addr.split(":");
358+
tail = new String[0];
359+
}
360+
int missing = 8 - head.length - tail.length;
361+
if (missing < 0) {
362+
return addr;
363+
}
364+
StringBuilder sb = new StringBuilder();
365+
boolean first = true;
366+
for (String h : head) {
367+
if (!first) sb.append(':');
368+
sb.append(padHextet(h));
369+
first = false;
370+
}
371+
for (int i = 0; i < missing; i++) {
372+
if (!first) sb.append(':');
373+
sb.append("0000");
374+
first = false;
375+
}
376+
for (String h : tail) {
377+
if (!first) sb.append(':');
378+
sb.append(padHextet(h));
379+
first = false;
380+
}
381+
String result = sb.toString();
382+
// Must end up with exactly 8 hextets (7 colons).
383+
int colonCount = 0;
384+
for (int i = 0; i < result.length(); i++) {
385+
if (result.charAt(i) == ':') colonCount++;
386+
}
387+
if (colonCount != 7) {
388+
return addr;
389+
}
390+
return result;
391+
}
392+
393+
private static String padHextet(String h) {
394+
if (h.length() >= 4) return h;
395+
StringBuilder sb = new StringBuilder(4);
396+
for (int i = h.length(); i < 4; i++) {
397+
sb.append('0');
398+
}
399+
sb.append(h);
400+
return sb.toString();
401+
}
402+
302403
/**
303404
* Detect platform version by calling the agent's /health endpoint. Returns null on any failure.
304405
*/

src/test/java/com/getaxonflow/sdk/telemetry/TelemetryEndpointTypeTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ void localhostIPv6() {
4343
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::1]:8080"));
4444
}
4545

46+
@Test
47+
@DisplayName("localhost: expanded IPv6 loopback 0:0:0:0:0:0:0:1")
48+
void localhostExpandedIPv6() {
49+
// v5.3.0 fix: alternate loopback forms must match Python/Go SDK behavior.
50+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[0:0:0:0:0:0:0:1]"));
51+
assertEquals(
52+
"localhost",
53+
TelemetryReporter.classifyEndpoint("http://[0000:0000:0000:0000:0000:0000:0000:0001]"));
54+
}
55+
56+
@Test
57+
@DisplayName("localhost: IPv6 unspecified ::")
58+
void localhostIPv6Unspecified() {
59+
assertEquals("localhost", TelemetryReporter.classifyEndpoint("http://[::]:8080"));
60+
}
61+
4662
@Test
4763
@DisplayName("localhost: 0.0.0.0")
4864
void localhostZero() {
@@ -96,6 +112,37 @@ void privateLinkLocal() {
96112
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://169.254.169.254"));
97113
}
98114

115+
@Test
116+
@DisplayName("private: IPv6 ULA fc00::/7")
117+
void privateIPv6ULA() {
118+
// v5.3.0 fix (review finding P3): IPv6 ULA used to fall through to remote.
119+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fd00::1]:8080"));
120+
assertEquals(
121+
"private_network", TelemetryReporter.classifyEndpoint("http://[fd12:3456:789a::1]"));
122+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fc00::1]"));
123+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fcff:ffff::]"));
124+
}
125+
126+
@Test
127+
@DisplayName("private: IPv6 link-local fe80::/10")
128+
void privateIPv6LinkLocal() {
129+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[fe80::1]"));
130+
assertEquals("private_network", TelemetryReporter.classifyEndpoint("http://[febf::1]"));
131+
}
132+
133+
@Test
134+
@DisplayName("remote: deprecated fec0::/10 site-local")
135+
void remoteDeprecatedSiteLocal() {
136+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[fec0::1]"));
137+
}
138+
139+
@Test
140+
@DisplayName("remote: public IPv6 addresses")
141+
void remotePublicIPv6() {
142+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[2001:4860:4860::8888]"));
143+
assertEquals("remote", TelemetryReporter.classifyEndpoint("http://[2606:4700:4700::1111]"));
144+
}
145+
99146
@Test
100147
@DisplayName("private: hostname suffixes")
101148
void privateHostnameSuffixes() {

0 commit comments

Comments
 (0)