Skip to content

Commit b2a879f

Browse files
committed
Prove the TUN carries an application's traffic, and make Phase D one command
The oldest claim on the honest list had never been evidenced: that the tunnel carries traffic for an application. It could not be, from inside this app — which is forced out of its own tunnel in every mode, because a VPN inside its own tunnel is a routing loop — and the script written to do it from a shell had never run, because adb cannot start a VPN. It can now. TunnelHolderTest brings the tunnel up and holds it, asserts nothing, and skips unless asked for by name. e2e-tunnel-fetch.sh uses it, and the shell user — who is not this app — fetches twenty megabytes through the TUN with a matching SHA-256, over both carriers, with the Portal's own byte counters moving by the same amount. Three things had to be learned to get there, and all three are the kind that would have been learned on a physical device at a worse moment. **Loopback never enters a VPN's TUN.** The kernel routes 127.0.0.0/8 to `lo` rather than to the default route, so the trick that works from inside the app — choose an address only the Portal can reach — proves nothing from a shell: a loopback target proves only that loopback is loopback. The evidence is the Portal's counters instead, which neither half of the script can fake. **A truncated transfer is non-empty.** The retry loop accepted any file with bytes in it, so a hold window that expired mid-transfer produced nine megabytes of twenty and the digest failed afterwards with no attempt left — reporting a corrupt tunnel when what it had was a short read. Verification moved inside the loop. **A Portal emits its counters on a timer.** Reading them two seconds after a fetch reads the line from before it, and reports that a transfer which plainly happened relayed nothing. The guard added yesterday moved from `TunnelHarness.start` to the fetch, which is where the invalid inference is actually made. Bringing a tunnel up is a fine thing to do with any target; it is fetching from inside this app that cannot prove anything. device-acceptance.sh runs all six of Phase D as one command: Private DNS, the package-visibility count that is D-16, address families, traffic over both carriers, battery management and path MTU. It reports each as a pass, a failure, or something only a person watching the device can settle — and says loudly when it is run against an emulator, because none of the six is answered by one. Exercised against the emulator: four passed, none failed, three left for a human. That checks the script, not the client. Gates green; device suite 53 cases.
1 parent 020a159 commit b2a879f

5 files changed

Lines changed: 377 additions & 24 deletions

File tree

‎app/src/androidTest/kotlin/eu/nodepass/somewhere/vpn/TunnelHarness.kt‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,19 @@ object TunnelHarness {
6565
throw AssertionError("the test node URL does not parse: ${parsed.reason.detail}")
6666
}
6767

68-
refuseADirectlyReachableTarget()
69-
7068
SomewhereVpnService.start(context, node)
7169
await("the tunnel to come up") { TunnelController.state.value is TunnelState.Connected }
7270
}
7371

7472
/**
75-
* Refuses to run a tunnel case whose target this device can reach without a
73+
* Refuses an in-app fetch of a target this device can reach without a
7674
* tunnel.
7775
*
76+
* On the fetch rather than on `start`, because it is the fetch that makes
77+
* the invalid inference. Bringing a tunnel up is a fine thing to do with any
78+
* target — `TunnelHolderTest` does it so that a *shell* can use the tunnel,
79+
* and a shell is not this app.
80+
*
7881
* **This exists because the suite spent a day proving nothing.** Every case
7982
* here fetches over an ordinary socket from inside the app's own process,
8083
* and this client is forced out of its own tunnel in every mode — a VPN
@@ -131,6 +134,8 @@ object TunnelHarness {
131134
* not catch it.
132135
*/
133136
fun fetchAndDigest(url: String): Fetched {
137+
refuseADirectlyReachableTarget()
138+
134139
// NO_PROXY, deliberately. Emulators commonly carry a system HTTP proxy
135140
// — this one advertises 10.0.2.2:6152 — and `HttpURLConnection` honours
136141
// it while `curl` does not, which is why the same fetch can look broken
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 androidx.test.platform.app.InstrumentationRegistry
8+
import org.junit.Assume.assumeTrue
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
12+
/**
13+
* Brings a tunnel up and holds it, so that a shell can use it.
14+
*
15+
* ## Why this exists
16+
*
17+
* The claim that matters most on a physical device — *does the TUN carry an
18+
* application's traffic* — cannot be made from inside this app. This client is
19+
* forced out of its own tunnel in every mode, because a VPN inside its own
20+
* tunnel is a routing loop, so instrumentation's sockets never enter the TUN.
21+
* The claim belongs to a process that is not this app, and the one available is
22+
* `adb shell`, which runs as the shell user and *is* inside the tunnel.
23+
*
24+
* But `adb` cannot start a VPN: `VpnService` needs consent and an application
25+
* to bind it. So the shell needs something inside the app to bring the tunnel
26+
* up and keep it up while the shell works — which is this.
27+
*
28+
* It is not a test and asserts nothing. It skips unless asked for by name, so
29+
* an ordinary suite run never sits here holding a tunnel open for a minute.
30+
*/
31+
@RunWith(AndroidJUnit4::class)
32+
class TunnelHolderTest {
33+
@Test
34+
fun holdTheTunnelOpen() {
35+
val seconds =
36+
InstrumentationRegistry.getArguments().getString("nowhereHoldSeconds")?.toIntOrNull()
37+
assumeTrue(
38+
"not asked to hold a tunnel; pass -e nowhereHoldSeconds N",
39+
seconds != null && seconds > 0,
40+
)
41+
42+
E2eEnvironment.requireConsent(TunnelHarness.context)
43+
TunnelHarness.start()
44+
try {
45+
// Printed so a script can wait for the tunnel rather than sleeping
46+
// a guessed interval, which is the difference between a check that
47+
// is slow and one that is flaky.
48+
println("TUNNEL_UP")
49+
System.out.flush()
50+
Thread.sleep(seconds!! * 1_000L)
51+
} finally {
52+
TunnelHarness.stop()
53+
println("TUNNEL_DOWN")
54+
}
55+
}
56+
}

‎conformance/cases/l1-coverage.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,22 @@ What replaces them:
255255
goes through the session layer at a target only the Portal can reach.
256256
- The claim about the **TUN itself** carrying an application's traffic belongs to
257257
`conformance/scripts/e2e-tunnel-fetch.sh`, which drives the fetch from the
258-
shell user — who is not this app and therefore is inside the tunnel. **That
259-
script is written and has not yet been run end to end.**
258+
shell user — who is not this app and therefore is inside the tunnel. **It has
259+
now run**: 20,971,520 bytes with a matching digest, over both carriers, with
260+
the Portal's own counters moving by the same amount. That is the first
261+
evidence this project has ever had that the TUN carries an application's
262+
traffic.
263+
264+
Two things it had to learn first. **Loopback never enters a VPN's TUN** — the
265+
kernel routes 127.0.0.0/8 to `lo` rather than to the default route — so the
266+
trick that works from inside the app, choosing an address only the Portal can
267+
reach, proves nothing from a shell. The evidence is the Portal's byte counters
268+
instead. And a **truncated** transfer is non-empty, so a retry loop that
269+
accepted any non-empty file reported a corrupt tunnel when what it had was a
270+
hold window that expired mid-transfer.
271+
272+
- `conformance/scripts/device-acceptance.sh` runs the whole of Phase D as one
273+
command against whatever device is attached.
260274

261275
# L3
262276

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env bash
2+
# Phase D: what only a physical device can answer.
3+
#
4+
# Everything else in this suite runs on an emulator, and an emulator is not a
5+
# phone in five specific ways that a VPN meets on its first real device. This
6+
# runs the checks that can be run, records the observations that can only be
7+
# looked at, and prints a report either way -- because "each failure has a
8+
# recorded symptom and a hypothesis" is a result and a run that never happened
9+
# is not.
10+
#
11+
# Usage:
12+
# NOWHERE_E2E_PORTAL=host:port NOWHERE_E2E_TARGET=host:port \
13+
# NOWHERE_PORTAL_LOG=/path/to/portal.log \
14+
# conformance/scripts/device-acceptance.sh
15+
#
16+
# The target must be an HTTP origin the **Portal** can reach, serving /blob.bin
17+
# and /small.bin with an X-Content-Sha256 header -- the one e2e-fakeip.sh
18+
# starts, or the extracted copy of it.
19+
set -uo pipefail
20+
21+
ROOT="$(cd "$(dirname "$0")" && pwd)"
22+
DEVICE="${NOWHERE_DEVICE:-}"
23+
PORTAL="${NOWHERE_E2E_PORTAL:-}"
24+
TARGET="${NOWHERE_E2E_TARGET:-}"
25+
APP_ID="eu.nodepass.somewhere"
26+
27+
PASSES=0; FAILS=0; NOTES=0
28+
pass() { echo " PASS $*"; PASSES=$((PASSES+1)); }
29+
miss() { echo " FAIL $*"; FAILS=$((FAILS+1)); }
30+
note() { echo " NOTE $*"; NOTES=$((NOTES+1)); }
31+
head2() { printf '\n── %s\n' "$*"; }
32+
die() { echo "cannot start: $*" >&2; exit 2; }
33+
34+
command -v adb >/dev/null || die "adb is not on PATH"
35+
[ -n "$DEVICE" ] || DEVICE="$(adb devices | awk '$2=="device"{print $1; exit}')"
36+
[ -n "$DEVICE" ] || die "no device"
37+
[ -n "$PORTAL" ] || die "set NOWHERE_E2E_PORTAL"
38+
[ -n "$TARGET" ] || die "set NOWHERE_E2E_TARGET"
39+
40+
sh() { adb -s "$DEVICE" shell "$@" 2>/dev/null | tr -d '\r'; }
41+
42+
MODEL="$(sh getprop ro.product.model)"
43+
SDK="$(sh getprop ro.build.version.sdk)"
44+
ABI="$(sh getprop ro.product.cpu.abi)"
45+
EMULATED="$(sh getprop ro.kernel.qemu)"
46+
47+
echo "Somewhere · device acceptance"
48+
echo " device $DEVICE"
49+
echo " model $MODEL"
50+
echo " android API $SDK, $ABI"
51+
if [ "$EMULATED" = "1" ]; then
52+
echo
53+
echo " !! This is an emulator. Every check below will run and none of them"
54+
echo " answers the question Phase D exists for, which is what a phone"
55+
echo " does. Use it to check the script, not the client."
56+
fi
57+
58+
# ── 1. Private DNS ──────────────────────────────────────────────────────────
59+
# Modern Android defaults to Automatic, and the resolver then speaks DoT to a
60+
# resolver of its own rather than sending UDP/53 to the address the TUN
61+
# announces. Fake-IP depends on seeing those queries; if it does not, remote
62+
# name resolution silently stops happening.
63+
head2 "1. Private DNS"
64+
PRIVATE_DNS="$(sh settings get global private_dns_mode)"
65+
case "$PRIVATE_DNS" in
66+
off) pass "Private DNS is off; the resolver will use the TUN's" ;;
67+
opportunistic|"null"|"") note "Private DNS is '$PRIVATE_DNS' (Automatic). The resolver may speak DoT and bypass the TUN's resolver. Watch whether names resolve below." ;;
68+
hostname) miss "Private DNS is set to a specific hostname ($(sh settings get global private_dns_specifier)). DoT will bypass the TUN's resolver." ;;
69+
*) note "Private DNS mode is '$PRIVATE_DNS', which this script does not know" ;;
70+
esac
71+
72+
# ── 2. The app list, which is D-16 ──────────────────────────────────────────
73+
# The manifest declares <queries> for launcher-visible apps instead of asking
74+
# for QUERY_ALL_PACKAGES. Whether that is enough has never been measured
75+
# against a device with real applications on it. This is the measurement.
76+
head2 "2. Package visibility (D-16)"
77+
INSTALLED="$(sh 'pm list packages -3' | wc -l | tr -d ' ')"
78+
LAUNCHABLE="$(sh 'cmd package query-activities --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER' | grep -c '/' || true)"
79+
echo " third-party packages installed: $INSTALLED"
80+
echo " packages with a launcher activity: $LAUNCHABLE"
81+
if [ "${INSTALLED:-0}" -lt 5 ]; then
82+
note "only $INSTALLED third-party apps here, so this measures nothing. Run it on a device someone uses."
83+
elif [ "${LAUNCHABLE:-0}" -gt 0 ] && [ "$INSTALLED" -gt 0 ]; then
84+
note "compare these two by hand against the per-app screen. D-16 closes as 'neither' if the screen's list is close to $INSTALLED, and reopens with a number if it is not."
85+
fi
86+
87+
# ── 3. Address families ─────────────────────────────────────────────────────
88+
# The TUN carries a v4 address and a v4 default route and nothing else, and
89+
# fake-IP answers AAAA with NODATA by design. On an IPv6-only mobile network
90+
# that may carry nothing at all.
91+
head2 "3. Address families on the current network"
92+
V4="$(sh 'ip -4 addr show scope global' | grep -c 'inet ' || true)"
93+
V6="$(sh 'ip -6 addr show scope global' | grep -c 'inet6' || true)"
94+
echo " global IPv4 addresses: $V4"
95+
echo " global IPv6 addresses: $V6"
96+
if [ "${V4:-0}" -eq 0 ] && [ "${V6:-0}" -gt 0 ]; then
97+
miss "IPv6-only network. This client's TUN carries no IPv6 route, so it will carry nothing here. This is the case Phase D exists to find."
98+
elif [ "${V6:-0}" -gt 0 ]; then
99+
note "dual-stack. v6 traffic takes the underlying network rather than the tunnel, by design and unchanged."
100+
else
101+
pass "IPv4 only; the TUN's single route covers it"
102+
fi
103+
104+
# ── 4. The tunnel actually carries traffic ──────────────────────────────────
105+
# Both carriers, because a device is where a carrier difference would first
106+
# show as something other than a test failure.
107+
for CARRIER in tcp udp; do
108+
head2 "4. Traffic over the tunnel ($CARRIER)"
109+
if NOWHERE_E2E_CARRIER="$CARRIER" "$ROOT/e2e-tunnel-fetch.sh" "$TARGET" /blob.bin > "/tmp/somewhere-fetch-$CARRIER.log" 2>&1; then
110+
RELAYED="$(grep -o 'the Portal relayed [0-9]* bytes' "/tmp/somewhere-fetch-$CARRIER.log" | head -1)"
111+
ATTEMPT="$(grep -o 'on attempt [0-9]*' "/tmp/somewhere-fetch-$CARRIER.log" | head -1)"
112+
pass "20 MB intact over $CARRIER — ${RELAYED:-no counter}, ${ATTEMPT:-first attempt}"
113+
else
114+
miss "$CARRIER: $(tail -3 "/tmp/somewhere-fetch-$CARRIER.log" | tr '\n' ' ')"
115+
fi
116+
done
117+
118+
# ── 5. Doze and battery management ──────────────────────────────────────────
119+
# Vendor battery managers kill foreground services that survive indefinitely on
120+
# an emulator. There is no way to assert the outcome from here; what can be
121+
# reported is whether the app is exempt, which is what a user would have to
122+
# change.
123+
head2 "5. Battery management"
124+
STANDBY="$(sh "am get-standby-bucket $APP_ID")"
125+
IGNORING="$(sh "dumpsys deviceidle whitelist" | grep -c "$APP_ID" || true)"
126+
echo " standby bucket: ${STANDBY:-unknown}"
127+
echo " exempt from battery optimisation: $([ "${IGNORING:-0}" -gt 0 ] && echo yes || echo no)"
128+
note "leave the tunnel running and the screen off for thirty minutes, then check it is still up. Nothing here can do that for you."
129+
130+
# ── 6. MTU ──────────────────────────────────────────────────────────────────
131+
# TUN_MTU is 1500. Real cellular paths are frequently smaller, and there is TLS
132+
# or QUIC framing on top. QUIC makes this sharper: a datagram that does not fit
133+
# is not fragmented by the path.
134+
head2 "6. Path MTU"
135+
UNDERLYING="$(sh "ip link show" | grep -E "wlan0|rmnet|eth0" | grep -o "mtu [0-9]*" | head -1 | cut -d' ' -f2)"
136+
echo " underlying interface MTU: ${UNDERLYING:-unknown}"
137+
if [ -n "$UNDERLYING" ] && [ "$UNDERLYING" -lt 1500 ] 2>/dev/null; then
138+
miss "the path MTU is $UNDERLYING, below the TUN's 1500. Large packets will need fragmenting somewhere, and this client does not do it."
139+
else
140+
pass "the underlying MTU is ${UNDERLYING:-1500}, so the TUN's 1500 fits"
141+
fi
142+
143+
# ── Report ──────────────────────────────────────────────────────────────────
144+
printf '\n%s\n' "──────────────────────────────────────────"
145+
echo "$PASSES passed, $FAILS failed, $NOTES to look at by hand"
146+
echo
147+
echo "A NOTE is not a pass. It is something only a person watching the device"
148+
echo "can settle, and leaving it unsettled is the honest state until they do."
149+
[ "$FAILS" -eq 0 ] || exit 1

0 commit comments

Comments
 (0)