Skip to content

Commit c05d9d1

Browse files
committed
Carry IPv6, which was one missing route away from working
The TUN was built with one IPv4 address and one 0.0.0.0/0 route and nothing else, so no IPv6 packet ever entered it. Everything downstream was already there and switched off by that: LWIP_IPV6 is on, the netif carries a valid v6 address, the JNI bridge threads is_ipv6 through every callback, FakeIpPool mints fc00::/96, DnsInterceptor has a synthesiseIpv6 switch, RoutingRules has a v6 trie and literal parser, and an IPv6 Portal host already parses. What actually leaked was narrower than "IPv6 bypasses the tunnel", and worth stating because it is the reason this was invisible. For a destination reached by name nothing leaked: AAAA is answered NODATA by design, the device falls back to A, and the name — not an address — is what travels to the Portal, which resolves it in its own network over whichever family it prefers. What left the tunnel was a literal IPv6 destination, dialled with no DNS step. The configuration is now a value rather than a chain of builder calls, because its interesting properties are relationships between its parts and a relationship cannot be checked by a test that needs a VpnService to exist. Three have teeth: a family is carried or it is not and everything must agree (carriesIpv6 is read off the routes, and the DNS layer asks the same question before it synthesises, so a route without a synthesiser is not expressible); no announced address may be one the fake-IP pool could mint; and an announced resolver must be routed here, or it is a query that leaves and never comes back. Routes are both defaults rather than the donor's complement of eight. That is the design the IPv4 side already has — everything enters the TUN and the rule set decides — and two answers to one question, one in routes and one in rules, is how they come to disagree. It also leaves a user's imported rule able to override what ships, which a route is not. The bundled set gains the v6 structural tier. fc00::/7 belongs in it for the same reason 192.168.0.0/16 does, and it textually contains the fake-IP range, which is safe for a reason worth writing down rather than rediscovering: a synthetic address is resolved back to its name before the router is consulted, so a live mapping never reaches these rules as an address. What can is a mapping that expired, and DIRECT is the better answer for it — the direct path refuses a synthetic address by name instead of spending a Portal flow on it. A test also pins that 64:ff9b::/96 is *not* here: a DIRECT rule over the NAT64 prefix would take every IPv4 destination out of the tunnel on exactly the networks where this client is most needed, and would look like a sensible reserved-range line. Announcing a resolver per family found a defect that predates this change. A declined query was forwarded only to an upstream resolver of the same family as the address it was sent to, so on an IPv6-only network every query the interceptor declines — MX, SRV, PTR, and the HTTPS records a browser asks for before it connects — was answered SERVFAIL while A and AAAA kept working. The reply is written back from the address the device addressed whatever family the forward was dialled over, so family is a preference and never a requirement. The device case reads the interface the platform built, which is the honest thing an in-app test can observe: this client is forced out of its own tunnel in every mode, so nothing this process sends enters the TUN. Verified to fail — dropping the v6 address from the builder reports "the platform did not give tun0 the declared address fd66::2; it has fe80…, 0a420002" and the carried/synthesised disagreement, on the emulator, in the same run. Addresses are compared as bytes: getHostAddress does not agree with itself across implementations for IPv6, and a string comparison would pass or fail for reasons unrelated to the tunnel. device-acceptance.sh stops describing a tunnel that carries no IPv6 route. It now reads what the platform actually built, checks for the v6 default route, and carries a v6-destination fetch that is a note rather than a silent pass until an IPv6-reachable origin is supplied.
1 parent 580bcfb commit c05d9d1

11 files changed

Lines changed: 665 additions & 39 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.vpn
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import org.junit.After
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Before
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import java.net.NetworkInterface
14+
15+
/**
16+
* That the device accepted the TUN this client asked for.
17+
*
18+
* The configuration itself is checked by `TunConfigurationTest`, which needs no
19+
* device and checks the relationships between its parts. What that cannot check
20+
* is the part where Android is asked for it: `Builder.addAddress` and
21+
* `addRoute` validate what they are given and throw, `establish()` returns null
22+
* rather than explaining itself, and a family the platform declined is not an
23+
* exception — it is a tunnel that comes up carrying one family while the DNS
24+
* layer mints placeholders for two.
25+
*
26+
* That failure has a specific shape worth naming: every name still resolves,
27+
* IPv4 still works, and the only thing that breaks is whatever the device
28+
* decided to try over IPv6 first — intermittently, per app, and never in a way
29+
* that points here.
30+
*
31+
* **This is deliberately not a traffic test.** This client is forced out of its
32+
* own tunnel in every mode, so nothing this process sends enters the TUN. What
33+
* a test inside the app can honestly observe is the interface the platform
34+
* built, which is exactly what this reads.
35+
*/
36+
@RunWith(AndroidJUnit4::class)
37+
class TunFamiliesOnDeviceTest {
38+
@Before
39+
fun startTunnel() {
40+
E2eEnvironment.requireConsent(TunnelHarness.context)
41+
TunnelHarness.start()
42+
}
43+
44+
@After
45+
fun stopTunnel() {
46+
runCatching { TunnelHarness.stop() }
47+
}
48+
49+
/**
50+
* Every address on an interface, as bytes.
51+
*
52+
* Bytes rather than the printed form: `getHostAddress` does not agree with
53+
* itself across implementations for IPv6 — one prints `fd66::2` and another
54+
* `fd66:0:0:0:0:0:0:2` — so a string comparison here would pass or fail for
55+
* reasons that have nothing to do with the tunnel.
56+
*/
57+
private fun addressesOf(candidate: NetworkInterface): List<ByteArray> = candidate.inetAddresses.toList().map { it.address }
58+
59+
/** The interface carrying the TUN's own IPv4 address, or null. */
60+
private fun tunInterface(): NetworkInterface? {
61+
val expected = TunConfiguration.parse(TunConfiguration.addresses.first { !it.isIpv6 }.address)!!
62+
return NetworkInterface
63+
.getNetworkInterfaces()
64+
.toList()
65+
.firstOrNull { candidate -> addressesOf(candidate).any { it.contentEquals(expected) } }
66+
}
67+
68+
@Test
69+
fun theTunnelInterfaceCarriesEveryFamilyThisClientDeclares() {
70+
val tun =
71+
tunInterface() ?: throw AssertionError(
72+
"no interface carries the TUN's own address; the tunnel reported Connected without one",
73+
)
74+
75+
val present = addressesOf(tun)
76+
val printed = present.joinToString { bytes -> bytes.joinToString("") { "%02x".format(it) } }
77+
TunConfiguration.addresses.forEach { declared ->
78+
val expected = TunConfiguration.parse(declared.address)!!
79+
assertTrue(
80+
"the platform did not give ${tun.name} the declared address ${declared.address}; it has $printed",
81+
present.any { it.contentEquals(expected) },
82+
)
83+
}
84+
}
85+
86+
@Test
87+
fun theInterfaceMtuIsTheOneTheConfigurationAsksFor() {
88+
// A platform that silently clamped the MTU would make every path-MTU
89+
// measurement in the device pass measure the clamp instead.
90+
val tun = tunInterface() ?: throw AssertionError("no interface carries the TUN's own address")
91+
assertEquals("the TUN's MTU is not what was asked for", TunConfiguration.MTU, tun.mtu)
92+
}
93+
94+
@Test
95+
fun carryingIpv6AndSynthesisingItAreTheSameDecision() {
96+
// Restated on the device because the two live in different layers and
97+
// the failure is silent: an AAAA record pointing at an address nothing
98+
// routes fails at the device with no error anyone can act on.
99+
val tun = tunInterface() ?: throw AssertionError("no interface carries the TUN's own address")
100+
val hasIpv6 =
101+
tun.inetAddresses
102+
.toList()
103+
.any { it.address.size == 16 && !it.isLinkLocalAddress }
104+
assertEquals(
105+
"the tunnel carries IPv6=$hasIpv6 while the configuration says ${TunConfiguration.carriesIpv6}",
106+
TunConfiguration.carriesIpv6,
107+
hasIpv6,
108+
)
109+
}
110+
}

‎app/src/main/assets/rules/local-network.list‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
# DIRECT rule over it would be a second, contradictory statement of something
2323
# the router already guarantees: a direct flow dials the name, never the address
2424
# that carried it.
25+
#
26+
# Its IPv6 counterpart, fc00::/96, cannot be kept out the same way: it sits
27+
# inside fc00::/7, which is the whole unique-local range and belongs here for
28+
# exactly the reason 192.168.0.0/16 does. That overlap is safe, and it is worth
29+
# saying why rather than leaving a reader to work it out. A synthetic address is
30+
# resolved back to its name *before* the router is consulted, so a live mapping
31+
# never reaches these rules as an address at all. What can reach them is a
32+
# synthetic address whose mapping has expired — and DIRECT is the better answer
33+
# for it than TUNNEL: the direct path refuses a synthetic address by name, with
34+
# a reason that says so, instead of spending a Portal flow on an address that
35+
# exists only inside this device.
2536

2637
# This device.
2738
IP-CIDR,127.0.0.0/8,DIRECT
@@ -57,3 +68,29 @@ DOMAIN-SUFFIX,localhost,DIRECT
5768
DOMAIN-SUFFIX,home.arpa,DIRECT
5869
DOMAIN-SUFFIX,lan,DIRECT
5970
DOMAIN-SUFFIX,internal,DIRECT
71+
72+
# --- IPv6, which is the same arithmetic ---
73+
#
74+
# Written as IP-CIDR6 for readers of Clash-family lists, though this client
75+
# routes on the address family rather than on the keyword.
76+
77+
# This device, and the address that means "no address".
78+
IP-CIDR6,::1/128,DIRECT
79+
IP-CIDR6,::/128,DIRECT
80+
81+
# RFC 4193. Unique-local: the private ranges of IPv6, and what a home router
82+
# hands out when it has no delegated prefix.
83+
IP-CIDR6,fc00::/7,DIRECT
84+
85+
# RFC 4291. Link-local, which every IPv6 interface has whether or not anything
86+
# configured it, and which is meaningless off the link it belongs to.
87+
IP-CIDR6,fe80::/10,DIRECT
88+
89+
# RFC 4291. Multicast, which does not survive a tunnel by definition.
90+
IP-CIDR6,ff00::/8,DIRECT
91+
92+
# RFC 3849 and RFC 6666. Documentation, and the range whose entire purpose is
93+
# to be dropped. A flow reaching either is a misconfiguration worth failing
94+
# fast.
95+
IP-CIDR6,2001:db8::/32,DIRECT
96+
IP-CIDR6,100::/64,DIRECT
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.dns
5+
6+
/**
7+
* Which of the device's own resolvers a declined query is forwarded to.
8+
*
9+
* The tunnel announces its own resolver addresses, which exist nowhere but
10+
* inside the TUN, so a query the interceptor declines cannot be forwarded to
11+
* where it was addressed. It goes to a resolver the underlying network
12+
* declared, read before the tunnel replaced them.
13+
*
14+
* **Family is a preference, not a requirement**, and that is the whole content
15+
* of this file. The reply is written back to the device from the address it
16+
* addressed, whatever family the forwarded query was dialled over, so the two
17+
* need not match. Requiring them to match fails whenever the underlying network
18+
* offers resolvers of one family and the tunnel announces both: on an
19+
* IPv6-only network that was every query this client declines — MX, SRV, PTR,
20+
* and the HTTPS records a browser asks for before it connects — answered
21+
* SERVFAIL while A and AAAA kept working. A failure shaped like that looks like
22+
* the site being broken, not like the tunnel.
23+
*/
24+
object UpstreamResolvers {
25+
/**
26+
* @param resolvers the underlying network's own, in the order it gave them.
27+
* @param addressLength 4 or 16: the family the query arrived over.
28+
* @return the resolver to dial, or null when the network declared none.
29+
*/
30+
fun choose(
31+
resolvers: List<ByteArray>,
32+
addressLength: Int,
33+
): ByteArray? = resolvers.firstOrNull { it.size == addressLength } ?: resolvers.firstOrNull()
34+
}

‎app/src/main/kotlin/eu/nodepass/somewhere/vpn/NowhereFlowHandler.kt‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import eu.nodepass.somewhere.dns.DnsInterceptor
88
import eu.nodepass.somewhere.dns.DnsMessage
99
import eu.nodepass.somewhere.dns.FakeIpPool
1010
import eu.nodepass.somewhere.dns.FakeIpResolver
11+
import eu.nodepass.somewhere.dns.UpstreamResolvers
1112
import eu.nodepass.somewhere.protocol.DecodeResult
1213
import eu.nodepass.somewhere.protocol.frame.FlowRejected
1314
import eu.nodepass.somewhere.protocol.frame.SetupResult
@@ -214,7 +215,7 @@ class NowhereFlowHandler(
214215
* not: this is parsing and a map lookup over a datagram-sized buffer, and
215216
* it finishes in microseconds rather than in round trips.
216217
*/
217-
private val dns = DnsInterceptor(fakeIp)
218+
private val dns = DnsInterceptor(fakeIp, synthesiseIpv6 = TunConfiguration.carriesIpv6)
218219

219220
/** Datagrams, carried as UDP over stream. Everything DNS cannot answer lands here. */
220221
private val udp = UdpRelay(session, fakeIp, meter, pump, scope)
@@ -332,7 +333,8 @@ class NowhereFlowHandler(
332333
isIpv6: Boolean,
333334
data: ByteArray,
334335
) {
335-
val resolver = upstreamResolvers.firstOrNull { it.size == destination.size }
336+
// Family is a preference rather than a requirement; see [UpstreamResolvers].
337+
val resolver = UpstreamResolvers.choose(upstreamResolvers, destination.size)
336338
if (resolver == null) {
337339
answerLocally(source, sourcePort, destination, destinationPort, isIpv6, serverFailure(data))
338340
return

‎app/src/main/kotlin/eu/nodepass/somewhere/vpn/SomewhereVpnService.kt‎

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,17 @@ import java.net.InetSocketAddress
5050
*
5151
* ## Names
5252
*
53-
* The TUN announces **its own resolver**, at [TUN_DNS], and that is what makes
54-
* the fake-IP layer work at all. Android's resolver does not simply emit a UDP
53+
* The TUN announces **its own resolvers**, from [TunConfiguration], and that is
54+
* what makes the fake-IP layer work at all. Android's resolver does not simply emit a UDP
5555
* packet and let the routing table decide: it asks `netd`, and `netd` queries
5656
* the servers configured for the network the app is on. A tunnel that announces
5757
* none leaves those queries on the underlying network, where this client never
5858
* sees them — the names would be resolved on the device, and every flow would
5959
* open to an address again.
6060
*
61-
* Announcing one has a cost, and it is paid two lines below. [TUN_DNS] exists
62-
* nowhere but inside this TUN, so a query that the interceptor declines cannot
63-
* simply be forwarded to where it was addressed. The device's **own** resolvers
61+
* Announcing one has a cost, and it is paid in [underlyingResolvers]. Those
62+
* addresses exist nowhere but inside this TUN, so a query that the interceptor
63+
* declines cannot simply be forwarded to where it was addressed. The device's **own** resolvers
6464
* are therefore read off the underlying network before the tunnel replaces
6565
* them, and the declined queries go there. Choosing a public resolver instead
6666
* would be a policy decision a tunnel has no business making; the user's
@@ -91,27 +91,8 @@ class SomewhereVpnService : VpnService() {
9191
const val ACTION_STOP = "eu.nodepass.somewhere.STOP"
9292
const val EXTRA_NODE_URL = "node"
9393

94-
/**
95-
* The address the TUN presents to the device.
96-
*
97-
* `10.66.0.0/24` rather than one of the ranges every other tunnel picks
98-
* — a collision with the device's real network makes the LAN
99-
* unreachable while connected, and the symptom ("my printer stopped
100-
* working") is never attributed to this.
101-
*/
102-
private const val TUN_ADDRESS = "10.66.0.2"
103-
private const val TUN_PREFIX = 24
104-
private const val TUN_MTU = 1500
105-
106-
/**
107-
* The resolver this tunnel announces.
108-
*
109-
* Inside the TUN's own subnet, so a query to it is routed here rather
110-
* than anywhere. Nothing listens on it in the ordinary sense — the
111-
* packets arrive at lwIP and are answered by
112-
* [NowhereFlowHandler.onUdpDatagram].
113-
*/
114-
private const val TUN_DNS = "10.66.0.1"
94+
// Addresses, routes, resolvers and MTU live in [TunConfiguration],
95+
// which is a value a test can read. What is left here is applying it.
11596

11697
fun start(
11798
context: Context,
@@ -241,14 +222,20 @@ class SomewhereVpnService : VpnService() {
241222
val builder =
242223
Builder()
243224
.setSession(getString(R.string.app_name))
244-
.addAddress(TUN_ADDRESS, TUN_PREFIX)
245-
.addRoute("0.0.0.0", 0)
246-
.addDnsServer(TUN_DNS)
247-
.setMtu(TUN_MTU)
225+
.setMtu(TunConfiguration.MTU)
248226
// Blocking reads: a non-blocking TUN returns 0 in a tight loop
249227
// and burns a core doing nothing.
250228
.setBlocking(true)
251229

230+
// Both families, addresses before routes, and a resolver per family.
231+
// Applied from the configuration rather than written out here so that
232+
// the relationships between them — a resolver that must be routed, an
233+
// address that must not be one the fake-IP pool could mint — stay
234+
// checkable by a test that needs no device.
235+
TunConfiguration.addresses.forEach { builder.addAddress(it.address, it.prefix) }
236+
TunConfiguration.routes.forEach { builder.addRoute(it.address, it.prefix) }
237+
TunConfiguration.dnsServers.forEach { builder.addDnsServer(it) }
238+
252239
// Fixed here for the life of this descriptor: Android has no way to
253240
// change the per-application set on a tunnel that is already up, which
254241
// is why changing it elsewhere has to rebuild rather than pretend.

0 commit comments

Comments
 (0)