Skip to content

Commit 5831e9b

Browse files
committed
Make the device pass check what L4 added, and read the tunnel while it is up
Four items join the one command: the TUN's own families and MTU, failover, the refresh schedule, and the tile's registration. Three of them are instrumentation cases already written; running them from here keeps the whole device pass one command rather than a script plus a list of gradle invocations. Two corrections to the script itself. The families check was a shell probe sitting between two other sections, so it read an interface that was not up and reported a note every time; it is now the instrumentation case that brings the tunnel up itself and reads the interface while it is there. And the tile was looked up with 'cmd package resolve-service', which does not exist on this API and answered 'Unknown command' into the middle of a NOTE — it is now asked of the package manager's own service resolution, which is also the better question: a tile declared but not resolvable is a service that exists and can never be added. The docker Portal now publishes UDP as well as TCP. Without it every QUIC connection to it waits out the handshake deadline and reports a timeout rather than being refused, which reads as a broken client and cost a round of the certificate work.
1 parent fd74e6f commit 5831e9b

4 files changed

Lines changed: 134 additions & 35 deletions

File tree

‎CLAUDE.md‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ comments, everything. Do not emit Chinese in project files.
1717
- **Delivered in layers**: L1 TLS/TCP → L2 Mux → L3 QUIC. Each layer ships to
1818
internal testing and leaves a fix-back window. No big-bang release.
1919
- **All three layers are delivered** (L1 and L2 on 2026-08-27, L3 on 2026-08-29),
20-
together with the release tail. A bare `nowhere://key@host:port` — a QUIC node,
20+
together with the release tail and L4 — IPv6 end to end, `pin` on the QUIC
21+
carrier, node failover, a scheduled subscription refresh and a tile. A bare `nowhere://key@host:port` — a QUIC node,
2122
because the specification defaults both directions to `udp` — connects and
2223
carries traffic, and the oracle differential agrees on every case over every
2324
carrier combination. What has *not* happened is a physical device: every device
@@ -32,7 +33,7 @@ comments, everything. Do not emit Chinese in project files.
3233
| `app/src/main/jni/` | Vendored lwIP and its JNI bridge, inherited from the donor, plus the QUIC bridge and the library's export map |
3334
| `tools/quic/` | The QUIC stack's pinned commits and the script that fetches and builds them. Not vendored (D-17); the conformance probe reads the same pin |
3435
| `conformance/` | Byte-level vectors, self-verifying checker, end-to-end smoke test, drift detection, test matrix |
35-
| `app/src/main/assets/rules/` | The bundled rule set (D-14), network-structural tier only, carrying a provenance header |
36+
| `app/src/main/assets/rules/` | The bundled rule set (D-14), network-structural tier only, both address families, carrying a provenance header |
3637
| `docs/` | Architecture, the design system, i18n, brand, privacy, store policy, and the TLS exporter ADR |
3738

3839
The lwIP layer is inherited from `NodePassProject/Anywhere-Android` (GPL-3.0) at
@@ -325,6 +326,59 @@ Two rules from those documents that reach into protocol code:
325326
oracle differential caught split-UDP diverging when the framing was attached
326327
to the flow instead. `DatagramLane` is the shared piece both directions use.
327328

329+
33. **A poll interval is not a protocol deadline.** `MuxCarrier.readStream`
330+
answers three things with three values, and **0 means "nothing in the last
331+
250 ms"** rather than a verdict. `open` read it as one, so a Portal that
332+
answered a SYN slower than a quarter of a second was reported as
333+
"authentication most likely failed" — the most misleading sentence
334+
available. A dedicated lane gives the same protocol step fifteen seconds;
335+
the carrier now gives it the same, because how long a Portal takes to dial
336+
a target belongs to the target rather than to the carrier.
337+
34. **A rejected flow must not be reset.** The rejection *is* the teardown — the
338+
Portal answers and forgets the stream in the same breath — and section 3
339+
says STREAM data for an unknown flow is a **carrier** error. So a RST after
340+
a rejection closes the whole connection and fails every other flow on it.
341+
The trigger is ordinary and permanent: Android's Private DNS probes port 853
342+
the moment a tunnel comes up, the Portal answers `DIAL_FAILED`, and whichever
343+
shard the probe landed on lost its other flows half a second later. One to
344+
four of sixteen concurrent fetches came back empty, intermittently. This
345+
client now sends no RST at all; reading one stays implemented, because the
346+
Portal may send one.
347+
35. **The TUN was one route away from carrying IPv6.** `LWIP_IPV6` was on, the
348+
netif had a valid v6 address, the bridge threaded `is_ipv6` through every
349+
callback, `FakeIpPool` minted `fc00::/96`, `RoutingRules` had a v6 trie, and
350+
an IPv6 Portal host parsed — all switched off by a builder that added one
351+
IPv4 address and one `0.0.0.0/0` route. And the leak was narrower than it
352+
looked: a destination reached by *name* never leaked, because AAAA is
353+
answered NODATA by design and the **name** is what travels to the Portal.
354+
Only a literal IPv6 destination left the tunnel.
355+
36. **A declined DNS query must be forwarded to whatever resolver exists**, not
356+
only to one of the family it arrived over. The reply is written back from
357+
the address the device addressed, whatever family the forward was dialled
358+
over. Requiring a match meant that on an IPv6-only network every query the
359+
interceptor declines — MX, SRV, PTR, and the HTTPS records a browser asks
360+
for before it connects — was answered SERVFAIL while A and AAAA kept
361+
working, which reads as the site being broken.
362+
37. **`getHostAddress` does not agree with itself across implementations for
363+
IPv6.** One prints `fd66::2` and another `fd66:0:0:0:0:0:0:2`. A device test
364+
comparing printed forms passes or fails for reasons unrelated to the
365+
tunnel; compare `InetAddress.address` bytes.
366+
38. **A Portal container that publishes only TCP makes every QUIC connection
367+
look like a client defect.** It does not refuse — it waits out the handshake
368+
deadline and reports a timeout. A round of the certificate work was spent
369+
debugging that as a verification failure before `-p PORT:PORT/udp` was the
370+
answer.
371+
39. **`TunnelController.state` is process-wide and outlives the test that set
372+
it.** "Wait until it is not Connecting" is true before the service has run,
373+
because the state before a start is Disconnected; "wait until it is Failed"
374+
is true because the *previous* test failed. Both were written and both
375+
passed vacuously. Wait on something `@Before` clears.
376+
40. **Lint's `StartActivityAndCollapseDeprecated` is not suppressible.** Neither
377+
`@Suppress` nor `@SuppressLint` on the enclosing function silences it, even
378+
inside the `Build.VERSION` check that makes the call safe. The tile
379+
therefore calls `startActivity` below API 34 and opens the app with the
380+
shade still over it, which is recorded rather than left to be rediscovered.
381+
328382
## Already verified — do not redo
329383

330384
- The donor project `Anywhere-Android` **builds clean** on this toolchain

‎README.md‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,23 @@ which is a QUIC node, because the specification's default for both directions is
1919
| Protocol, L2 | TLS Mux. Sixteen concurrent flows measured over four connections, at upstream's stated shard density |
2020
| Protocol, L3 | QUIC: authentication, TCP flows over reliable streams, UDP over DATAGRAM with fragmentation, split flows across two carriers |
2121
| Carriers | All four combinations — `tcp/tcp`, `udp/udp`, `udp/tcp`, `tcp/udp` — compared case by case against the reference implementation, which agrees on every one |
22-
| Tunnel | `VpnService` + lwIP, TCP with back-pressure, UDP, DNS interception with fake-IP, live throughput |
23-
| Routing | A rule matcher, an import path, a bundled network-structural rule set, a direct path, a reject path |
22+
| Tunnel | `VpnService` + lwIP, **both address families**, TCP with back-pressure, UDP, DNS interception with fake-IP over IPv4 and IPv6, live throughput |
23+
| Routing | A rule matcher, an import path, a bundled network-structural rule set for both families, a direct path, a reject path |
2424
| Per-app | A real installed list, a persisted selection, and this client structurally outside its own tunnel |
25-
| Nodes | Stored, imported from a `nowhere://` link, probed for reachability |
26-
| Subscription | Fetching and parsing, with upstream's actual quota semantics |
27-
| UI | Nine screens, both themes, three locales |
25+
| Nodes | Stored, imported from a `nowhere://` link, probed for reachability, and failed over when one cannot be reached |
26+
| Subscription | Fetching and parsing with upstream's actual quota semantics, and a periodic refresh that is off until asked for |
27+
| UI | Nine screens, both themes, three locales, and a quick-settings tile |
2828

2929
**What is not done**, stated as plainly as the rest:
3030

3131
- **Nothing has ever run on physical hardware.** Every device result is from an
3232
emulator. Private DNS over DoT, IPv6-only and NAT64 networks, a real Wi-Fi to
3333
cellular change, Doze and a path MTU below 1500 are all things an emulator
3434
cannot represent, and all things a VPN meets on the first real device.
35+
- **Start on boot is deliberately absent.** Android's always-on VPN starts the
36+
tunnel before applications run and can block traffic while it is down; an
37+
in-app boot receiver would be a worse version of it. Settings links to the
38+
system switch instead.
3539
- **The QUIC carrier does not verify a certificate chain.** `pin` works on both
3640
carriers; a node carrying `sni` is refused rather than carried without it. See
3741
[ADR-0002](docs/adr-0002-quic-certificate-verification.md).
@@ -50,8 +54,9 @@ There is no big-bang release.
5054
| **L2** · TLS Mux | The Mux carrier at upstream's own shard density, and concurrency measured over it | **Done**, 2026-08-27 |
5155
| **L3** · QUIC | ngtcp2 over aws-lc, authentication, TCP over reliable streams, UDP over DATAGRAM, split flows across two carriers, keep-alive, and every carrier combination compared against the reference implementation | **Done**, 2026-08-29 |
5256
| **Release tail** | A bundled rule set, a connection log that shows what the Portal actually said, a launcher icon, a release build that has been run rather than only produced, and the documents a VPN-class listing requires | **Done**, 2026-08-29 |
57+
| **L4** · product surface | IPv6 end to end, `pin` on the QUIC carrier, node health and failover, a scheduled subscription refresh and a quick-settings tile | **Done**, 2026-08-29 |
5358
| **Device pass** | The first run on physical hardware: Private DNS, a real per-app list, a Wi-Fi to cellular change, Doze, a path MTU below 1500 | **Not started.** No physical device has ever been attached to this project |
54-
| **L4** · product surface | Subscription refresh, latency testing, encrypted DNS. Scope deliberately not fixed before a device pass says what the product actually needs | **Not started** |
59+
| **L5** · what the device pass asks for | Chain verification against `sni` on QUIC ([ADR-0002](docs/adr-0002-quic-certificate-verification.md)), and whatever a real phone turns out to need | **Not started**, deliberately: scope fixed after a device pass, not before |
5560
| **Publication** | A signing key, a distribution channel, a first release | **Waiting on decisions rather than on code** |
5661

5762
### How much of it is done
@@ -66,9 +71,9 @@ from the table.
6671
| Layer | Rows | Covered by a test | Partly, with the gap named | Blocked |
6772
|---|---|---|---|---|
6873
| L1 | 63 | 62 | 1 | 0 |
69-
| L2 | 21 | 20 | 1 | 0 |
74+
| L2 | 23 | 22 | 1 | 0 |
7075
| L3 | 27 | 25 | 2 | 0 |
71-
| **Total** | **111** | **107** | **4** | **0** |
76+
| **Total** | **113** | **109** | **4** | **0** |
7277

7378
The four partial rows are each half of a pair: this client's half is tested, and
7479
the other half needs something no repository can contain — a running dashboard, a

‎conformance/cases/l1-coverage.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ Three things, which is the argument for writing it rather than assuming it:
168168

169169
# L2
170170

171-
Twenty-one rows, added when the Mux carrier landed. The same three states, and
171+
Twenty-three rows, added when the Mux carrier landed and extended when a
172+
concurrency measurement that had been passing vacuously was repaired. The same three states, and
172173
the same rule: no row is silent.
173174

174175
| # | Case | State | Covered by |
@@ -183,6 +184,8 @@ the same rule: no row is silent.
183184
| 8 | RST must be the only flag with `value=0` | covered | `MuxHeaderVectorTest.everyRejectionVectorIsRefused` (both halves: `ResetNotAlone` and `ResetWithValue`) |
184185
| 9 | Any other flag bit set is rejected | covered | `MuxHeaderVectorTest.everyReservedFlagBitIsRejected` |
185186
| 10 | Late FIN and RST processing is idempotent | covered | `MuxCarrierTest.lateFinAndResetAreIdempotent`. This row found a real defect: the first carrier treated a late FIN as data for an unknown flow and tore itself down whenever any flow closed |
187+
| 10a | A rejected flow is released, never reset | covered | `MuxCarrierRefusalTest.aRejectedFlowIsReleasedRatherThanReset`. **The mirror of row 10, from this client's side, and a defect until 2026-08-29.** Section 3 makes STREAM data for an unknown flow a *carrier* error, and the Portal forgets a refused stream as it answers — so a RST after a rejection closed the connection and failed every other flow multiplexed onto it. Found on a device: Android's Private DNS probes port 853 the moment a tunnel comes up, the Portal answers `DIAL_FAILED`, and one to four of sixteen concurrent fetches came back empty depending on which shard the probe landed on. This client now writes no RST at all |
188+
| 10b | A slow SYN answer is waited for, not called a refusal | covered | `MuxCarrierTest.aPortalThatTakesItsTimeAnsweringIsNotAFailedAuthentication`, `.theSetupDeadlineIsTheDedicatedLanesRatherThanAPollInterval`. The carrier gave the setup byte a 250 ms queue-poll interval where a dedicated lane gives the identical protocol step fifteen seconds, and reported anything slower as "authentication most likely failed" |
186189
| 11 | Payload needs both credits before queueing | covered | `MuxCarrierCreditTest.aWindowWithFlowZeroReplenishesTheConnection` — stream credit alone is shown not to be enough. Verified to fail: removing the connection-credit check turns it red |
187190
| 12 | WINDOW with `flowId=0` replenishes connection credit | covered | as row 11 |
188191
| 13 | Credit beyond the configured window closes the carrier | covered | `MuxCreditTest.creditBeyondTheWindowIsRefused`, `MuxCarrierCreditTest.creditBeyondTheWindowClosesTheCarrier` |

‎conformance/scripts/device-acceptance.sh‎

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -121,30 +121,6 @@ else
121121
note "IPv4 only, so nothing here exercises the v6 half. Re-run on a network that has it."
122122
fi
123123

124-
head2 "3b. What the platform actually built"
125-
# Read from the device rather than from the source: Builder.addAddress accepts
126-
# what it is given and establish() returns null rather than explaining itself,
127-
# so a family Android declined looks exactly like one it did not.
128-
TUN_IFACE="$(sh 'ip -4 addr show' | grep -B2 '10\.66\.0\.2' | head -1 | sed 's/^[0-9]*: \([^:]*\).*/\1/')"
129-
if [ -z "$TUN_IFACE" ]; then
130-
note "no interface carries 10.66.0.2, so the tunnel is not up. Start it and re-run; §4 brings it up on its own."
131-
else
132-
echo " tunnel interface: $TUN_IFACE"
133-
TUN_V4="$(sh "ip -4 addr show dev $TUN_IFACE" | grep -c 'inet ' || true)"
134-
TUN_V6="$(sh "ip -6 addr show dev $TUN_IFACE" | grep -c 'inet6' || true)"
135-
TUN_ROUTE6="$(sh "ip -6 route show dev $TUN_IFACE" | grep -c 'default\|^::/0' || true)"
136-
if [ "${TUN_V4:-0}" -gt 0 ] && [ "${TUN_V6:-0}" -gt 0 ]; then
137-
pass "$TUN_IFACE carries both families"
138-
else
139-
miss "$TUN_IFACE carries v4=$TUN_V4 v6=$TUN_V6 — the platform declined a family this client declares, and the DNS layer is minting placeholders for it"
140-
fi
141-
if [ "${TUN_ROUTE6:-0}" -gt 0 ]; then
142-
pass "an IPv6 default route points into $TUN_IFACE"
143-
else
144-
miss "no IPv6 default route into $TUN_IFACE, so v6 traffic leaves the device outside the tunnel"
145-
fi
146-
fi
147-
148124
head2 "3c. A synthetic IPv6 address, end to end"
149125
# The v6 half of the fake-IP layer, proved with nothing but this device: the
150126
# name is resolved through the tunnel's own resolver, which answers AAAA with an
@@ -192,6 +168,67 @@ for CARRIER in tcp udp; do
192168
fi
193169
done
194170

171+
head2 "4a. What the platform actually built"
172+
# Read from the device rather than from the source: Builder.addAddress accepts
173+
# what it is given and establish() returns null rather than explaining itself,
174+
# so a family Android declined looks exactly like one it did not. The
175+
# instrumentation case brings the tunnel up itself and reads the interface while
176+
# it is there, which a shell probe between two other checks cannot.
177+
if adb -s "$DEVICE" shell am instrument -w \
178+
-e class eu.nodepass.somewhere.vpn.TunFamiliesOnDeviceTest \
179+
-e nowhereE2ePortal "$PORTAL" \
180+
-e nowhereE2eKey "${NOWHERE_E2E_KEY:-conformance-smoke-key}" \
181+
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
182+
| tee /tmp/somewhere-families.log | grep -q "^OK ("; then
183+
pass "the TUN carries both families at the MTU asked for"
184+
else
185+
miss "TUN families: $(grep -m1 'stack=' /tmp/somewhere-families.log | cut -c1-200)"
186+
fi
187+
188+
# ── 4b. Failing over to another node ────────────────────────────────────────
189+
# Two nodes, one that cannot be reached. The instrumentation case is the
190+
# assertion; this runs it here so the whole device pass is still one command,
191+
# and so a phone with a real network is what it runs on.
192+
head2 "4b. Failover"
193+
if adb -s "$DEVICE" shell am instrument -w \
194+
-e class eu.nodepass.somewhere.vpn.FailoverOnDeviceTest \
195+
-e nowhereE2ePortal "$PORTAL" \
196+
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
197+
| tee /tmp/somewhere-failover.log | grep -q "^OK ("; then
198+
pass "an unreachable node is recorded as unreachable, and a refused one is not"
199+
else
200+
miss "failover: $(grep -m1 'stack=' /tmp/somewhere-failover.log | cut -c1-160)"
201+
fi
202+
203+
# ── 4c. The background surface ──────────────────────────────────────────────
204+
head2 "4c. Subscription refresh, and the tile"
205+
if adb -s "$DEVICE" shell am instrument -w \
206+
-e class eu.nodepass.somewhere.subscription.SubscriptionRefreshScheduleTest \
207+
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
208+
| tee /tmp/somewhere-refresh.log | grep -q "^OK ("; then
209+
pass "the platform accepts the refresh schedule, persists it, and drops it with the switch"
210+
else
211+
miss "refresh schedule: $(grep -m1 'stack=' /tmp/somewhere-refresh.log | cut -c1-160)"
212+
fi
213+
214+
# Whether the job ever *fires* is Doze's decision and cannot be asserted from
215+
# here; it can be provoked, which is a different thing and worth saying so.
216+
if [ "${EMULATED:-0}" = "1" ]; then
217+
note "Doze is not real on an emulator. On a phone: adb shell cmd deviceidle force-idle, wait, then adb shell cmd jobscheduler run -f $APP_ID 0x50FA, and check the subscription's timestamp moved."
218+
else
219+
note "to see the refresh actually fire under Doze: adb shell cmd deviceidle force-idle, then adb shell cmd jobscheduler run -f $APP_ID 0x50FA, then look at the subscription timestamp on the Nodes screen."
220+
fi
221+
222+
# Asked of the package manager's own resolution rather than of the manifest:
223+
# a tile declared but not resolvable — a missing intent-filter, a permission
224+
# the system does not accept — is a service that exists and can never be added.
225+
TILE="$(adb -s "$DEVICE" shell pm query-services --brief -a android.service.quicksettings.action.QS_TILE 2>/dev/null | grep -F "$APP_ID/.vpn.TunnelTileService" | tr -d ' \r')"
226+
if [ -n "$TILE" ]; then
227+
note "the tile is registered as $TILE. Add it from the shade's editor and check by hand: it shows the node name, it opens the app when no node has ever connected, and it refuses to start without VPN consent."
228+
else
229+
miss "the quick-settings tile is not registered on this device"
230+
fi
231+
195232
# ── 5. Doze and battery management ──────────────────────────────────────────
196233
# Vendor battery managers kill foreground services that survive indefinitely on
197234
# an emulator. There is no way to assert the outcome from here; what can be

0 commit comments

Comments
 (0)