feat(relay): canonicalize IPv6 relay urls and support overlay meshes

Closes the four gaps the previous commit characterized for relays on an
Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served
over plain ws:// (no DNS, no CA-issuable certificate).

New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical
formatting and range classification. No java.net, so it works on every KMP
target.

- Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets
  one address be spelled many ways and the RFC 3986 pass only folded hex case,
  so two spellings survived as two NormalizedRelayUrl values for one host —
  and that value keys the connection pool, the relay-list sets, the NIP-11
  cache and the per-relay stats, so the app dialed one relay twice. The
  canonical form matches what OkHttp renders when it dials; the tests assert
  that agreement differentially. Relay lists rehydrate through normalizeOrNull,
  so stored entries fold on load and no migration is needed.

- Add isOverlayNetwork() for 0200::/7 and default those relays to ws://:
  nothing can issue a certificate for the range, so wss:// could only fail its
  handshake, and the overlay already encrypts end to end.

- Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1,
  fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of
  published relay lists, as its IPv4 equivalent already did.

- Never route an overlay relay through Tor: the range is unroutable there, so
  proxying guaranteed failure rather than privacy. TorRelayEvaluation covers
  both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers
  non-relay HTTP.

- Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints),
  but only when the whole string parses as an address, so host:port and
  addressable pointers still fall through. RelayUrlEditField now shows an error
  instead of no-opping, fixing the silent Add button for all invalid input.

Mesh relays are still published in NIP-65 and offered by the outbox model; the
plan doc explains why that is left as a maintainer's call, and records that no
live socket test was possible here (the container has no IPv6 stack).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
This commit is contained in:
Claude
2026-08-04 22:28:07 +00:00
parent 129401bdaf
commit 067d68b89c
11 changed files with 696 additions and 125 deletions
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
normalizedUrl: String,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
// interface — Tor cannot route the range, so proxying only breaks the fetch.
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
true
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
isOnionRelaysActive
@@ -170,6 +170,7 @@ fun RelayUrlEditField(
nav: INav,
) {
var url by remember { mutableStateOf("") }
var isInvalid by remember { mutableStateOf(false) }
fun submitRelay() {
if (url.isNotBlank()) {
@@ -177,7 +178,13 @@ fun RelayUrlEditField(
if (relay != null) {
onNewRelay(relay)
url = ""
isInvalid = false
relaySuggestions.reset()
} else {
// Without this the Add button is a silent no-op, which reads as a broken button.
// Bare IPv6 literals are the common way to land here: an overlay-mesh address
// pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port.
isInvalid = true
}
}
}
@@ -189,8 +196,18 @@ fun RelayUrlEditField(
value = url,
onValueChange = {
url = it
isInvalid = false
relaySuggestions.processInput(it)
},
isError = isInvalid,
supportingText = {
if (isInvalid) {
Text(
text = stringRes(R.string.relay_url_not_valid),
color = MaterialTheme.colorScheme.error,
)
}
},
placeholder = {
Text(
text = "server.com",
+1
View File
@@ -214,6 +214,7 @@
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
<string name="search_and_add_a_user">Search and add user</string>
<string name="add_a_relay">Add a Relay</string>
<string name="relay_url_not_valid">Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080).</string>
<string name="my_name">My @tag name</string>
<string name="display_name">Display Name</string>
<string name="my_display_name">My display name</string>
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.tor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork
class TorRelayEvaluation(
val torSettings: TorRelaySettings,
@@ -36,6 +37,11 @@ class TorRelayEvaluation(
} else {
if (relay.isLocalHost()) {
false
} else if (relay.isOverlayNetwork()) {
// An overlay-mesh relay (0200::/7, e.g. Yggdrasil) is reachable only through the
// local mesh interface: Tor cannot route the range at all, so proxying it would
// guarantee failure rather than privacy. The overlay already encrypts end to end.
false
} else if (relay.isOnion()) {
// .onion is only reachable over Tor regardless of any other classification.
torSettings.onionRelaysViaTor
@@ -26,15 +26,16 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* GAP 4 — an Yggdrasil relay is classified as a plain clearnet relay, so with Tor on it is
* dialed through the SOCKS proxy. Tor cannot route `0200::/7`: the connection can only fail.
*
* Compare `ws://192.168.1.100:8080/`, which [TorRelayEvaluation] correctly keeps off Tor
* because `isLocalHost()` recognizes the LAN prefix. Yggdrasil has no such recognition.
* An overlay-mesh relay (`0200::/7`, e.g. Yggdrasil) must never be dialed through the Tor SOCKS
* proxy: Tor cannot route the range, so proxying guarantees failure rather than privacy. The
* overlay already encrypts end to end and authenticates the peer by its key-derived address.
*/
class YggdrasilTorRoutingTest {
private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/")
private val yggdrasilSubnetRelay = NormalizedRelayUrl("ws://[300:1b5d:d0e9:ba58::1]:4848/")
private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/")
private val ulaRelay = NormalizedRelayUrl("ws://[fd12:3456::1]:8080/")
private val clearnetIpv6Relay = NormalizedRelayUrl("wss://[2001:db8::1]:8080/")
private fun evaluation(newViaTor: Boolean) =
TorRelayEvaluation(
@@ -52,14 +53,18 @@ class YggdrasilTorRoutingTest {
)
@Test
fun yggdrasilRelayIsSentThroughTorWhileLanRelayIsNot() {
fun overlayRelaysAreNeverTorifiedEvenWhenNewRelaysViaTorIsOn() {
val eval = evaluation(newViaTor = true)
assertTrue(eval.useTor(yggdrasilRelay), "Yggdrasil relay is routed via Tor, which cannot reach 0200::/7")
assertFalse(eval.useTor(lanRelay), "LAN relay is correctly kept off Tor")
assertFalse(eval.useTor(yggdrasilRelay), "0200::/8 node address must not be proxied")
assertFalse(eval.useTor(yggdrasilSubnetRelay), "0300::/8 subnet address must not be proxied")
assertFalse(eval.useTor(lanRelay), "LAN relay stays off Tor")
assertFalse(eval.useTor(ulaRelay), "IPv6 unique local address stays off Tor")
}
@Test
fun yggdrasilRelayWorksOnlyWhenNewRelaysViaTorIsOff() {
assertFalse(evaluation(newViaTor = false).useTor(yggdrasilRelay))
fun clearnetIpv6RelaysStillFollowTheTorSetting() {
// The overlay exemption must not leak into ordinary IPv6 relays.
assertTrue(evaluation(newViaTor = true).useTor(clearnetIpv6Relay))
assertFalse(evaluation(newViaTor = false).useTor(clearnetIpv6Relay))
}
}
@@ -1,103 +1,114 @@
# Amethyst over Yggdrasil (IPv6 overlay) — compatibility assessment
# Amethyst over Yggdrasil (IPv6 overlay)
Status: **analysis only** — no behavior changed. Characterization tests landed alongside
this doc pin the current behavior so a fix has a baseline to diff against.
Status: **fixed** — the four gaps found in the original assessment are closed. The last
section records what was deliberately left alone.
## What Yggdrasil looks like to the app
Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its
public key inside `0200::/7`, and hands out `0300::/8` subnets. Consequences that matter here:
public key inside `0200::/7` (nodes in `0200::/8`, subnets in `0300::/8`). Consequences:
- **No DNS.** A relay on the mesh is addressed as a bracketed IPv6 literal, always.
- **No certificates.** No CA issues for `0200::/7` literals, so relays run plain `ws://`.
This is not a downgrade — the overlay already provides end-to-end encryption and
authenticates the peer by its address.
- **No DNS.** A relay on the mesh is addressed as an IPv6 literal, always.
- **No certificates.** No CA issues for `0200::/7`, so relays run plain `ws://`. Not a
downgrade — the overlay already encrypts end to end and authenticates the peer by an
address derived from its public key.
- **On Android it is a `VpnService`**, so the app's default network becomes the VPN network.
- The address is a **stable node identifier**, so publishing it is equivalent to publishing
a long-lived pseudonymous handle for the device.
- `0200::/7` is deprecated NSAP space, so nothing else routes there. An address in the range
is reachable *only* through a running mesh interface — which is what makes it safe to key
behavior off the prefix.
## Verdict
## What was wrong, and what fixed it
A hand-typed `ws://[…]:port` relay works end to end: it normalizes, survives the RFC 3986
pass, and OkHttp parses and dials it. Nothing in the stack is IPv4-only, `TcpNoDelaySocketFactory`
is family-agnostic, and `network_security_config.xml` permits cleartext globally, so the
`ws://` requirement is already satisfied.
### 1. One relay, two identities
Everything around that happy path is where it degrades. Four gaps, in severity order.
`RelayUrlNormalizer` folded hex case but not zero-compression, so
`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` stayed two distinct
`NormalizedRelayUrl`s for one host — while OkHttp collapsed both to the same host when
dialing. Since that value keys the connection pool, the relay-list sets, the NIP-11 cache and
the per-relay stat maps, the app opened two sockets to one relay and counted it twice.
### GAP 1 — one relay, two identities (correctness)
**Fix:** new `Ipv6` util (`quartz/utils/Ipv6.kt`) — pure-Kotlin parse, RFC 5952 canonical
format and range classification, no `java.net`, so it works on every KMP target.
`RelayUrlNormalizer.norm()` now canonicalizes the bracketed host. The canonical form is
byte-for-byte what OkHttp renders, so the key the app stores is the host it actually dials —
asserted differentially against OkHttp in `YggdrasilCompatCharacterizationTest`.
`RelayUrlNormalizer` folds hex case but does **not** canonicalize zero-compression or
leading zeros:
Affects every IPv6 relay, not just mesh ones; it only bit Yggdrasil users because on the mesh
a literal is the *only* way to name a relay. No migration needed: relay lists are rehydrated
from event tags through `normalizeOrNull`, so stored entries fold on load.
| input | `NormalizedRelayUrl` | OkHttp host |
|---|---|---|
| `ws://[201:d0e:9ba5:8bbc::1]:8080` | `ws://[201:d0e:9ba5:8bbc::1]:8080/` | `201:d0e:9ba5:8bbc::1` |
| `ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080` | `ws://[201:0d0e:…:0001]:8080/` | `201:d0e:9ba5:8bbc::1` |
### 2. Schemeless entry defaulted to `wss://`
OkHttp collapses both to one host; the app does not. `NormalizedRelayUrl` is the key of the
connection pool (`PoolRequests`, `RelayPool`), every relay-list set, the NIP-11 cache and the
per-relay stat maps — so the same relay written two ways gets **two sockets, two REQ sets and
doubled traffic**, and appears twice in the relay UI. This affects all IPv6 literals, but it
only bites Yggdrasil users in practice, because on the mesh a literal is the *only* way to
name a relay. Fix: canonicalize the bracketed literal to RFC 5952 inside `fix()`.
`isLocalHost()` knew `127.0.0.1` / `localhost` / `//umbrel:` / `192.168.` / `.local`, so a
mesh address fell through to the clearnet default and produced a `wss://` url whose TLS
handshake could never succeed.
### GAP 2 — schemeless entry defaults to `wss://` (dead end)
**Fix:** new `RelayUrlNormalizer.isOverlayNetwork()` recognizes `0200::/7` and joins
`isOnion` / `isLocalHost` in choosing `ws://`. Clearnet IPv6 (`2001:db8::1`) still gets
`wss://`.
`RelayUrlNormalizer.isLocalHost()` recognizes `127.0.0.1`, `localhost`, `//umbrel:`,
`192.168.`, `.local:` / `.local/`. An Yggdrasil address matches none of them, so a schemeless
`[201:…]:8080` falls through to the clearnet default and becomes `wss://[201:…]:8080/` — a
URL whose TLS handshake can never succeed. The user must know to type `ws://` themselves.
Fix: teach `isLocalHost()` (or a sibling `isOverlayNetwork()`) the `0200::/7` prefix.
`isLocalHost()` separately grew the IPv6 twins of the literals it already knew — `::1`
(loopback), `fc00::/7` (unique local, the 192.168. analogue) and `fe80::/10` (link-local).
Those are the same question every caller is asking, so a relay on one now correctly skips TLS
and Tor and stays out of published relay lists.
### GAP 3 — unbracketed literal is silently rejected (UX)
### 3. Unbracketed literal silently rejected
`yggdrasilctl getSelf` prints the address **unbracketed**, which is exactly what a user
copies into the "add a relay" box. `isBareHostAndPath()` rejects it (correctly — it is
ambiguous with a scheme), so `normalizeOrNull` returns null. But
`RelayUrlEditField.submitRelay()` has no else branch: the Add button just does nothing, with
no error. Fix: either auto-bracket a candidate that parses as an IPv6 address, or surface a
validation message instead of a silent no-op.
`yggdrasilctl getSelf` prints the address unbracketed — exactly what gets pasted into "add a
relay". Normalization returned null (correctly: it is ambiguous with a scheme) and
`RelayUrlEditField.submitRelay` had no else branch, so the Add button did nothing at all.
### GAP 4 — Tor routing sends mesh traffic into the SOCKS proxy (breaks the relay)
**Fix, two halves:**
- `fix()` brackets a bare literal automatically, but only when the whole string parses as an
IPv6 address — so `31990:hex:dtag` (addressable pointer), `abcd:1234` (host:port) and
`relay.example.com:8080` still fall through untouched.
- The edit field now sets `isError` and shows `relay_url_not_valid` instead of no-opping.
That fixes the dead button for *all* invalid input, not just IPv6.
`TorRelayEvaluation` classifies relays as localhost / onion / dm / trusted / new. Yggdrasil
lands in **new**, so with Tor on and the default "new relays via Tor", the relay is dialed
through the Tor SOCKS proxy — which cannot route `0200::/7`. The connection can only fail.
Compare `ws://192.168.1.100:8080/`, which is correctly kept off Tor because `isLocalHost()`
knows the LAN prefix. Today the only workaround is turning "new relays via Tor" off, which
weakens the setting for every genuine clearnet relay. The same gap exists on desktop
(`DesktopHttpClient`) and for non-relay HTTP (`RoleBasedHttpClientBuilder`). Fixing GAP 2's
prefix check fixes this one too, since both read the same predicate.
### 4. Tor routing broke mesh relays
## Propagation / privacy note (not a bug, a decision)
`TorRelayEvaluation` classified mesh relays as "new", so with Tor on and the default "new
relays via Tor" they were dialed through the SOCKS proxy — which cannot route `0200::/7`.
Guaranteed failure, not privacy.
An Yggdrasil relay is not filtered out of NIP-65 publishing (`AdvertisedRelayInfoTag` only
rejects localhost) nor out of the outbox model
(`RelayListRecommendationProcessor.filterValidRelays`). So a mesh relay in your relay list is
**published to public relays and recommended to other users**. Two effects:
**Fix:** `useTor()` returns false for `isOverlayNetwork()`, checked right after the localhost
branch. Both the Android and desktop relay paths delegate here (`TorRelayState`,
`DesktopHttpClient`), so one change covers both. `RoleBasedHttpClientBuilder` got the same
treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep
following the Tor setting — asserted in `YggdrasilTorRoutingTest`.
## Deliberately not changed
**Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and
`RelayListRecommendationProcessor.filterValidRelays` only exclude localhost, so a mesh relay
in your relay list is still published to public relays and offered to other users via the
outbox model. Two consequences worth a maintainer's decision:
- Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host.
- Your Yggdrasil address — a stable, key-derived node identifier — becomes public.
Onion relays get special handling here (`hasOnionConnection` gates whether they are even
considered). An overlay-network classification would let Yggdrasil be treated the same way.
Onion relays already have precedent for both readings: they *are* published, but
`filterValidRelays` gates them behind `hasOnionConnection`. The equivalent for overlay relays
would be a `hasMeshConnection` gate. That is a product call about whether mesh relays are
meant to be discoverable, so it is flagged rather than decided here.
## Not covered
## Not verified here
- **No live socket test.** The analysis container has no IPv6 stack at all
(`AF_INET6` `EAFNOSUPPORT`), so everything above is verified below the socket: URL
normalization, OkHttp URL/host parsing, and the Tor routing decision. An on-device run
against a real mesh relay is still needed to confirm the happy path end to end.
- **No live socket test.** The analysis container has no IPv6 stack at all (`AF_INET6`
`EAFNOSUPPORT`), so everything is verified below the socket: normalization, OkHttp URL/host
agreement, and the Tor routing decision. An on-device run against a real mesh relay is
still needed to confirm the happy path end to end.
- **Android VPN interaction untested.** `ConnectivityFlow` uses
`registerDefaultNetworkCallback`, so it follows the app's default network into the VPN.
Whether `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends
on whether that app declares underlying networks; worth checking on device before assuming
`registerDefaultNetworkCallback`, so it follows the app into the VPN network. Whether
`isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends on
whether that app declares underlying networks worth checking on device before assuming
data-saving mode behaves.
- Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised.
## Tests
- `quartz/src/jvmAndroidTest/…/relay/YggdrasilCompatCharacterizationTest.kt` — GAPs 13 plus
the working happy path.
- `commons/src/commonTest/…/tor/YggdrasilTorRoutingTest.kt` — GAP 4.
- `quartz/…/utils/Ipv6Test.kt` — parser, RFC 5952 formatting, range classification.
- `quartz/…/relay/YggdrasilCompatCharacterizationTest.kt` — normalization end to end, plus
the differential assertions that our identity matches the host OkHttp dials.
- `commons/…/tor/YggdrasilTorRoutingTest.kt` — overlay relays never Torified, clearnet IPv6
still follows the setting.
@@ -47,3 +47,6 @@ fun NormalizedRelayUrl.toHttp() =
fun NormalizedRelayUrl.isOnion() = url.contains(".onion/")
fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url)
/** True for a relay inside an encrypted IPv6 overlay mesh. See [RelayUrlNormalizer.isOverlayNetwork]. */
fun NormalizedRelayUrl.isOverlayNetwork() = RelayUrlNormalizer.isOverlayNetwork(this.url)
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.collection.LruCache
import com.vitorpamplona.quartz.utils.Ipv6
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.CancellationException
@@ -44,7 +45,51 @@ class RelayUrlNormalizer {
url.contains("//umbrel:") ||
url.contains("192.168.") ||
url.contains(".local:") ||
url.contains(".local/")
url.contains(".local/") ||
isPrivateIpv6(url)
/**
* The IPv6 twins of the literals above: `::1` (127.0.0.1), `fc00::/7` unique local
* addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that
* only exists on this machine or this LAN, which is what every caller of [isLocalHost]
* means by the question — so a relay on one must not be Torified, must not need TLS,
* and must not be advertised to the network.
*/
private fun isPrivateIpv6(url: String): Boolean {
val bytes = ipv6HostOf(url) ?: return false
return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes)
}
/**
* True for a relay inside an encrypted IPv6 overlay mesh — today `0200::/7`, the range
* Yggdrasil derives node addresses and subnets from.
*
* Unlike [isLocalHost] this is not a private address: it is reachable from anywhere on
* the mesh. But it is unreachable *off* the mesh, which has two consequences the relay
* stack has to honour — it can never be dialed through a SOCKS/Tor proxy, and it can
* never present a CA-issued certificate, so it speaks plain `ws://`. Both are safe:
* the overlay already encrypts end to end and authenticates the peer by its address,
* which is derived from the peer's public key.
*/
fun isOverlayNetwork(url: String): Boolean {
val bytes = ipv6HostOf(url) ?: return false
return Ipv6.isOverlayMesh(bytes)
}
/**
* Extracts the bracketed IPv6 host of [url] as raw bytes, dropping any `%zone` suffix.
* Returns null — cheaply, on a single `indexOf` — for the overwhelmingly common case of
* a url with a DNS host.
*/
private fun ipv6HostOf(url: String): ByteArray? {
val open = url.indexOf('[')
if (open < 0) return null
val close = url.indexOf(']', open + 1)
if (close <= open + 1) return null
val zone = url.indexOf('%', open + 1)
val end = if (zone in (open + 1) until close) zone else close
return Ipv6.parse(url.substring(open + 1, end))
}
fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/")
@@ -82,7 +127,31 @@ class RelayUrlNormalizer {
return false
}
private fun norm(url: String) = NormalizedRelayUrl(Rfc3986.normalize(url))
private fun norm(url: String) = NormalizedRelayUrl(canonicalizeIpv6Host(Rfc3986.normalize(url)))
/**
* Rewrites a bracketed IPv6 host into its RFC 5952 canonical form.
*
* RFC 4291 lets one address be spelled many ways, and the RFC 3986 pass only folds hex
* case — so `[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]`
* survive as two different [NormalizedRelayUrl]s for one host. That value keys the
* connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the
* app would dial the same relay twice and count it twice. OkHttp canonicalizes to this
* exact form when it dials, so folding here makes the stored key the host on the wire.
*
* Returns [url] itself — no allocation — when there is no literal or it is already
* canonical, which is every url with a DNS host.
*/
private fun canonicalizeIpv6Host(url: String): String {
val open = url.indexOf('[')
if (open < 0) return url
val close = url.indexOf(']', open + 1)
if (close <= open + 1) return url
val inner = url.substring(open + 1, close)
val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url
if (canonical == inner) return url
return url.substring(0, open + 1) + canonical + url.substring(close)
}
private fun isInvisible(c: Char) = c == '\u200B' || c == '\u200C' || c == '\u200D' || c == '\u2060' || c == '\uFEFF'
@@ -267,15 +336,29 @@ class RelayUrlNormalizer {
}
// protocol-relative urls (`//host/`) are just missing the scheme
val bare = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed
if (bare.length < 4) return null
val protocolRelative = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed
if (protocolRelative.length < 4) return null
// A bare IPv6 literal is missing its brackets, not malformed. This is the shape a
// user actually has in hand — `yggdrasilctl getSelf` prints the address unbracketed
// — and without the brackets `isBareHostAndPath` rejects it below as a host with too
// many colons. Only a string that parses as a whole address is bracketed, so an
// addressable-event pointer (`31990:hex:dtag`) or a `host:port` still falls through.
val bare =
if (protocolRelative[0] != '[' && Ipv6.isLiteral(protocolRelative)) {
"[$protocolRelative]"
} else {
protocolRelative
}
if (!isBareHostAndPath(bare)) {
Log.d("RelayUrlNormalizer") { "Rejected $url" }
return null
}
return if (isOnion(bare) || isLocalHost(bare)) {
// Overlay and localhost relays cannot hold a certificate, so wss:// could only ever
// fail its handshake. Both carry their own encryption, so ws:// is not a downgrade.
return if (isOnion(bare) || isLocalHost(bare) || isOverlayNetwork(bare)) {
"ws://$bare"
} else {
"wss://$bare"
@@ -0,0 +1,264 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
/**
* Pure-Kotlin IPv6 literal parsing, RFC 5952 canonical formatting and address
* classification. No `java.net`, so it works on every KMP target.
*
* Exists because relay identity is a *string*: [com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl]
* is the key of the connection pool, the relay-list sets, the NIP-11 cache and every
* per-relay stat map. RFC 4291 lets one address be written many ways
* (`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` are the same
* host), and without folding them the app treats one relay as two — two sockets, two REQ
* sets, two rows in the UI. The canonical form here matches what OkHttp renders, so the
* key the app stores is the host it actually dials.
*/
object Ipv6 {
/** Longest legal literal is 45 chars (`::ffff:` + dotted quad is shorter than 8 full groups). */
private const val MAX_LITERAL = 45
/**
* Parses a bracket-less, zone-less IPv6 literal into its 16 bytes, or null when [address]
* is not a valid literal. Accepts `::` compression and a trailing dotted quad
* (`::ffff:192.168.1.1`).
*/
fun parse(address: String): ByteArray? {
val len = address.length
if (len < 2 || len > MAX_LITERAL) return null
val out = ByteArray(16)
// Bytes written so far, counting from the left. When a `::` is present the bytes after
// it are written contiguously here and shifted to the right end at the very end.
var fill = 0
var gapAt = -1
var i = 0
if (address[0] == ':') {
if (address[1] != ':') return null
gapAt = 0
i = 2
if (i == len) return out
}
while (true) {
val groupStart = i
var value = 0
var digits = 0
while (i < len) {
val digit = hexDigit(address[i])
if (digit < 0) break
if (digits == 4) return null
value = (value shl 4) or digit
digits++
i++
}
if (i < len && address[i] == '.') {
// Trailing dotted quad: occupies the last four bytes, so nothing may follow it.
if (fill > 12) return null
if (!parseIpv4Into(address, groupStart, len, out, fill)) return null
fill += 4
i = len
break
}
if (digits == 0) return null
if (fill + 2 > 16) return null
out[fill++] = (value ushr 8).toByte()
out[fill++] = value.toByte()
if (i == len) break
if (address[i] != ':') return null
i++
if (i == len) return null // a single trailing ':' is not a valid literal
if (address[i] == ':') {
if (gapAt >= 0) return null // only one `::` allowed
gapAt = fill
i++
if (i == len) break
}
}
if (gapAt < 0) {
if (fill != 16) return null
} else {
// `::` must stand for at least one omitted group.
if (fill == 16) return null
val tail = fill - gapAt
for (k in tail - 1 downTo 0) {
out[16 - tail + k] = out[gapAt + k]
out[gapAt + k] = 0
}
}
return out
}
/**
* RFC 5952 text form: lowercase hex, no leading zeros, and the longest run of two or more
* zero groups replaced by `::` (leftmost run wins a tie). IPv4-mapped addresses keep their
* dotted tail. This is byte-for-byte what OkHttp prints for the same address.
*/
fun format(bytes: ByteArray): String {
require(bytes.size == 16) { "An IPv6 address is 16 bytes, got ${bytes.size}" }
var bestStart = -1
var bestLen = 0
var i = 0
while (i < 16) {
if (bytes[i] == ZERO && bytes[i + 1] == ZERO) {
val runStart = i
var j = i
while (j < 16 && bytes[j] == ZERO && bytes[j + 1] == ZERO) j += 2
if (j - runStart > bestLen) {
bestLen = j - runStart
bestStart = runStart
}
i = j
} else {
i += 2
}
}
// A single zero group is written as `0`, never as `::`.
if (bestLen < 4) {
bestStart = -1
bestLen = 0
}
val out = StringBuilder(39)
// ::ffff:a.b.c.d — IPv4-mapped addresses read as IPv4 everywhere else, so keep them that way.
if (bestStart == 0 && bestLen == 10 && bytes[10] == ALL_ONES && bytes[11] == ALL_ONES) {
out.append("::ffff:")
appendIpv4(out, bytes, 12)
return out.toString()
}
i = 0
while (i < 16) {
if (i == bestStart) {
out.append(':')
i += bestLen
if (i == 16) out.append(':')
} else {
if (i > 0) out.append(':')
out.append(group(bytes, i).toString(16))
i += 2
}
}
return out.toString()
}
/**
* Canonicalizes a bracket-less literal, preserving any `%zone` suffix verbatim (in URLs the
* zone arrives percent-encoded, e.g. `fe80::1%25wlan0`). Returns null when [address] is not
* a valid literal.
*/
fun canonicalizeOrNull(address: String): String? {
val zoneAt = address.indexOf('%')
if (zoneAt < 0) return parse(address)?.let(::format)
val bytes = parse(address.substring(0, zoneAt)) ?: return null
return format(bytes) + address.substring(zoneAt)
}
/** True when [address] is a valid bracket-less literal that names more than one group. */
fun isLiteral(address: String): Boolean = address.indexOf(':') >= 0 && parse(address) != null
/** `::1` — the IPv6 loopback, twin of 127.0.0.1. */
fun isLoopback(bytes: ByteArray): Boolean {
for (i in 0 until 15) if (bytes[i] != ZERO) return false
return bytes[15] == ONE
}
/** `fe80::/10` — link-local, only meaningful on the interface it came from. */
fun isLinkLocal(bytes: ByteArray): Boolean = bytes[0] == FE.toByte() && (bytes[1].toInt() and 0xC0) == 0x80
/** `fc00::/7` — unique local addresses, the IPv6 twin of 192.168.0.0/16. */
fun isUniqueLocal(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0xFC
/**
* `0200::/7` — the range Yggdrasil derives node addresses (`0200::/8`) and subnets
* (`0300::/8`) from. Formally deprecated NSAP space, so nothing else routes here: an
* address in this range is reachable only through a running mesh interface, is already
* end-to-end encrypted by the overlay, and can never hold a CA-issued certificate.
*/
fun isOverlayMesh(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0x02
private fun group(
bytes: ByteArray,
at: Int,
) = ((bytes[at].toInt() and 0xFF) shl 8) or (bytes[at + 1].toInt() and 0xFF)
private fun appendIpv4(
out: StringBuilder,
bytes: ByteArray,
from: Int,
) {
for (k in 0 until 4) {
if (k > 0) out.append('.')
out.append(bytes[from + k].toInt() and 0xFF)
}
}
/**
* Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected —
* they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers.
*/
private fun parseIpv4Into(
text: String,
from: Int,
to: Int,
out: ByteArray,
at: Int,
): Boolean {
var i = from
for (octet in 0 until 4) {
if (octet > 0) {
if (i >= to || text[i] != '.') return false
i++
}
var value = 0
var digits = 0
while (i < to && text[i] in '0'..'9') {
if (digits == 3) return false
if (digits == 1 && value == 0) return false // leading zero
value = value * 10 + (text[i] - '0')
digits++
i++
}
if (digits == 0 || value > 255) return false
out[at + octet] = value.toByte()
}
return i == to
}
private fun hexDigit(c: Char): Int =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
private const val FE = 0xFE
private const val ZERO = 0.toByte()
private const val ONE = 1.toByte()
private const val ALL_ONES = 0xFF.toByte()
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Ipv6Test {
private fun canonical(address: String) = Ipv6.canonicalizeOrNull(address)
@Test
fun rfc5952CanonicalForm() {
// leading zeros suppressed, hex lowercased
assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:0d0e:9ba5:8bbc:0000:0000:0000:0001"))
assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:D0E:9BA5:8BBC::1"))
assertEquals("2001:db8::1", canonical("2001:0DB8:0000:0000:0000:0000:0000:0001"))
// already canonical stays put
assertEquals("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5", canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5"))
assertEquals("::", canonical("::"))
assertEquals("::1", canonical("0:0:0:0:0:0:0:1"))
}
@Test
fun singleZeroGroupIsNotCompressed() {
// RFC 5952 §4.2.2: `::` must not stand for a single group.
assertEquals("2001:db8:0:1:1:1:1:1", canonical("2001:db8:0:1:1:1:1:1"))
}
@Test
fun longestZeroRunWinsAndTiesGoLeft() {
assertEquals("2001:0:0:1::1", canonical("2001:0:0:1:0:0:0:1"))
// equal runs of two groups: the leftmost is the one compressed
assertEquals("2001::1:1:0:0:1", canonical("2001:0:0:1:1:0:0:1"))
}
@Test
fun ipv4MappedKeepsDottedTail() {
assertEquals("::ffff:192.168.1.1", canonical("::ffff:192.168.1.1"))
assertEquals("::ffff:127.0.0.1", canonical("::FFFF:127.0.0.1"))
// an embedded quad that is not ipv4-mapped collapses to plain hex
assertEquals("::c0a8:101", canonical("::192.168.1.1"))
}
@Test
fun zoneIdIsPreservedVerbatim() {
// In URLs the zone arrives percent-encoded.
assertEquals("fe80::1%25wlan0", canonical("fe80:0000:0000:0000:0000:0000:0000:0001%25wlan0"))
}
@Test
fun rejectsMalformedLiterals() {
assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2")) // too few groups
assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:1234")) // too many
assertNull(canonical("201::9ba5::1")) // two `::`
assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:")) // trailing colon
assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:gggg")) // non-hex
assertNull(canonical("201:00d0e:9ba5:8bbc::1")) // five-digit group
assertNull(canonical("192.168.1.1")) // ipv4
assertNull(canonical("localhost"))
assertNull(canonical("::ffff:192.168.1")) // short quad
assertNull(canonical("::ffff:010.1.1.1")) // leading zero in quad
assertNull(canonical("0:0:0:0:0:0:0:0:0"))
}
@Test
fun compressionMustCoverAtLeastOneGroup() {
// A `::` that stands for nothing is not a legal literal.
assertNull(canonical("1:2:3:4:5:6:7::8"))
}
@Test
fun classifiesYggdrasilAndPrivateRanges() {
assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("201:d0e:9ba5:8bbc::1")!!), "0200::/8 node address")
assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("300:1b5d:d0e9:ba58::1")!!), "0300::/8 subnet address")
assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("2ff::1")!!))
assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("2001:db8::1")!!), "documentation range is clearnet")
assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("400::1")!!), "just past 0200::/7")
assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("::1")!!))
assertTrue(Ipv6.isLoopback(Ipv6.parse("::1")!!))
assertFalse(Ipv6.isLoopback(Ipv6.parse("::2")!!))
assertFalse(Ipv6.isLoopback(Ipv6.parse("::")!!))
assertTrue(Ipv6.isLinkLocal(Ipv6.parse("fe80::1")!!))
assertTrue(Ipv6.isLinkLocal(Ipv6.parse("febf::1")!!))
assertFalse(Ipv6.isLinkLocal(Ipv6.parse("fec0::1")!!))
assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fd00::1")!!))
assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fc00::1")!!))
assertFalse(Ipv6.isUniqueLocal(Ipv6.parse("fe00::1")!!))
}
@Test
fun isLiteralDiscriminatesAgainstNonAddresses() {
assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5"))
assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc::1"))
// Things a relay-url field realistically receives, none of which may pass as an address.
assertFalse(Ipv6.isLiteral("relay.example.com:8080"))
assertFalse(Ipv6.isLiteral("wss:"))
assertFalse(Ipv6.isLiteral("localhost:4869"))
assertFalse(Ipv6.isLiteral("31990:abcdef:mydtag"), "addressable event pointer")
assertFalse(Ipv6.isLiteral("abcd:1234"))
assertFalse(Ipv6.isLiteral("nos.lol"))
}
@Test
fun roundTripsEveryFormOfTheSameAddress() {
val forms =
listOf(
"201:d0e:9ba5:8bbc:0:0:0:1",
"201:0d0e:9ba5:8bbc:0000:0000:0000:0001",
"201:d0e:9ba5:8bbc::1",
"201:D0E:9BA5:8BBC::0001",
)
val canonicalForms = forms.map { canonical(it) }.toSet()
assertEquals(setOf("201:d0e:9ba5:8bbc::1"), canonicalForms)
}
}
@@ -22,29 +22,30 @@ package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import okhttp3.Request
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Characterization of how relay URLs on an Yggdrasil overlay behave today.
* Relay URLs on an Yggdrasil overlay, end to end through the normalizer.
*
* Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes) and hands out
* `0300::/8` subnets, with no DNS and no CA-issuable certificate. A relay on the mesh is
* therefore always reached as a **bracketed IPv6 literal over plain `ws://`** — a shape
* the relay stack only partially handles.
* Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes in `0200::/8`, subnets in
* `0300::/8`), with no DNS and no CA-issuable certificate. A relay on the mesh is therefore
* always a **bracketed IPv6 literal over plain `ws://`**.
*
* These tests document the CURRENT behavior (including the gaps) so a later fix has a
* baseline to diff against. Each gap is marked GAP with what a user sees.
* The differential assertions against OkHttp are the point of this file living in
* `jvmAndroidTest`: OkHttp is what actually dials the socket, so a normalized url that
* disagrees with OkHttp's own canonical host is a relay the app tracks under a name it does
* not connect to.
*/
class YggdrasilCompatCharacterizationTest {
// Same node, three legal RFC 4291 spellings of one address.
// Same node, several legal RFC 4291 spellings of one address.
private val canonical = "ws://[201:d0e:9ba5:8bbc::1]:8080"
private val expanded = "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080"
private val uppercase = "ws://[201:D0E:9BA5:8BBC::1]:8080"
@@ -66,51 +67,90 @@ class YggdrasilCompatCharacterizationTest {
}
@Test
fun yggdrasilSubnetAddressesAndUppercaseHexWork() {
fun yggdrasilSubnetAddressesWork() {
assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url)
// Hex case IS folded, so the uppercase spelling collapses onto the canonical one.
assertEquals(canonical.normalizeRelayUrl(), uppercase.normalizeRelayUrl())
}
/**
* GAP 1 — zero-compression is NOT canonicalized, so one relay gets two identities.
*
* `NormalizedRelayUrl` is the key of the connection pool, the relay-list sets, the NIP-11
* cache and every per-relay stat map. OkHttp collapses both spellings to one host (below),
* so the app opens two sockets to the same relay and counts it twice everywhere.
* Every legal spelling of one address collapses to one [NormalizedRelayUrl] — the key of the
* connection pool, the relay-list sets, the NIP-11 cache and the per-relay stat maps. Without
* this the app dials one relay twice and shows it twice.
*/
@Test
fun gapZeroCompressionSplitsOneRelayIntoTwoIdentities() {
assertNotEquals(canonical.normalizeRelayUrl(), expanded.normalizeRelayUrl())
// ...even though they are literally the same host on the wire:
assertEquals(host(canonical), host(expanded))
fun everySpellingOfOneAddressIsOneRelay() {
val identities = listOf(canonical, expanded, uppercase).map { it.normalizeRelayUrl() }.toSet()
assertEquals(setOf("ws://[201:d0e:9ba5:8bbc::1]:8080/"), identities.map { it.url }.toSet())
// ...and that one identity is the host OkHttp dials for all of them.
assertEquals(setOf("201:d0e:9ba5:8bbc::1"), listOf(canonical, expanded, uppercase).map { host(it) }.toSet())
}
@Test
fun normalizedIdentityAlwaysMatchesTheHostOkHttpDials() {
listOf(
"ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080",
"ws://[300:1b5d:d0e9:ba58:0:0:0:1]:4848",
"ws://[2001:0DB8:0000:0000:0000:0000:0000:0001]:7777",
"ws://[::1]:4869",
).forEach { raw ->
val normalized = raw.normalizeRelayUrl().url
assertEquals(host(normalized), host(raw), "identity for $raw disagrees with the dialed host")
}
}
/**
* GAP 2 — a schemeless IPv6 literal defaults to `wss://`.
*
* `isLocalHost()` only knows 127.0.0.1 / localhost / umbrel / 192.168. / .local, so an
* Yggdrasil address falls through to the clearnet default. No CA issues certificates for
* `0200::/7` literals, so the resulting wss:// url can only ever fail its TLS handshake.
* A schemeless overlay address defaults to `ws://`: no CA issues certificates for
* `0200::/7`, so `wss://` could only ever fail its handshake. The mesh already encrypts
* end to end, so this is not a downgrade.
*/
@Test
fun gapSchemelessYggdrasilAddressDefaultsToWss() {
assertEquals("wss://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url)
fun schemelessOverlayAddressDefaultsToWs() {
assertEquals("ws://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url)
assertTrue("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isOverlayNetwork())
// A clearnet IPv6 relay keeps requiring TLS.
assertEquals("wss://[2001:db8::1]:8080/", "[2001:db8::1]:8080".normalizeRelayUrl().url)
assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isOverlayNetwork())
}
/**
* `::1`, `fc00::/7` and `fe80::/10` are the IPv6 twins of 127.0.0.1 and 192.168., so they
* answer [isLocalHost] the same way — no TLS, no Tor, never advertised to the network.
*/
@Test
fun ipv6LoopbackAndPrivateRangesCountAsLocalHost() {
assertEquals("ws://[::1]:4869/", "[::1]:4869".normalizeRelayUrl().url)
assertTrue("ws://[::1]:4869/".normalizeRelayUrl().isLocalHost())
assertTrue("ws://[fd12:3456::1]:8080/".normalizeRelayUrl().isLocalHost(), "unique local address")
assertTrue("ws://[fe80::1]:8080/".normalizeRelayUrl().isLocalHost(), "link local address")
assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isLocalHost(), "clearnet ipv6")
// An overlay relay is reachable across the mesh, so it is NOT localhost.
assertFalse("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isLocalHost())
}
/**
* GAP 3 — an unbracketed IPv6 literal is rejected outright.
*
* `yggdrasilctl getSelf` prints the address unbracketed, which is what a user copies into
* the "add a relay" field. Normalization returns null and `RelayUrlEditField.submitRelay`
* has no else branch, so the Add button silently does nothing.
* `yggdrasilctl getSelf` prints the address unbracketed, which is what a user pastes into
* the "add a relay" field. It is bracketed automatically rather than rejected.
*/
@Test
fun gapUnbracketedYggdrasilAddressIsRejected() {
assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5"))
assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:8080"))
// Bracketing it by hand is the only accepted form.
assertTrue(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080") != null)
fun bareUnbracketedLiteralIsBracketedAutomatically() {
assertEquals(
"ws://[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]/",
"201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5".normalizeRelayUrl().url,
)
assertEquals("ws://[201:d0e:9ba5:8bbc::1]/", "201:d0e:9ba5:8bbc::1".normalizeRelayUrl().url)
assertNotNull(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080"))
}
/**
* Auto-bracketing must not swallow the other colon-bearing strings that reach the
* normalizer. Only a string that parses as a whole IPv6 address is bracketed.
*/
@Test
fun autoBracketingDoesNotCaptureNonAddresses() {
assertEquals("wss://relay.example.com:8080/", "relay.example.com:8080".normalizeRelayUrl().url)
assertEquals("ws://localhost:4869/", "localhost:4869".normalizeRelayUrl().url)
// addressable-event pointer, not a relay
assertEquals(null, RelayUrlNormalizer.normalizeOrNull("31990:abcdef:mydtag"))
// two hex-looking groups are a host and a port, not an address
assertEquals("wss://abcd:1234/", "abcd:1234".normalizeRelayUrl().url)
}
}