Repository navigation
Expand file tree
/
Copy pathdevice-acceptance.sh
More file actions
executable file
·338 lines (313 loc) · 19.6 KB
/
Copy pathdevice-acceptance.sh
File metadata and controls
executable file
·338 lines (313 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env bash
# Phase D: what only a physical device can answer.
#
# Everything else in this suite runs on an emulator, and an emulator is not a
# phone in five specific ways that a VPN meets on its first real device. This
# runs the checks that can be run, records the observations that can only be
# looked at, and prints a report either way -- because "each failure has a
# recorded symptom and a hypothesis" is a result and a run that never happened
# is not.
#
# Usage:
# NOWHERE_E2E_PORTAL=host:port NOWHERE_E2E_TARGET=host:port \
# NOWHERE_E2E_KEY=<32 hex> NOWHERE_E2E_PIN=<the Portal's fingerprint> \
# NOWHERE_BIN=/path/to/nowhere \
# conformance/scripts/device-acceptance.sh
#
# The target must be an HTTP origin the **Portal** can reach, serving /blob.bin
# and /small.bin with an X-Content-Sha256 header -- the one e2e-fakeip.sh
# starts, or the extracted copy of it.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
DEVICE="${NOWHERE_DEVICE:-}"
PORTAL="${NOWHERE_E2E_PORTAL:-}"
TARGET="${NOWHERE_E2E_TARGET:-}"
APP_ID="eu.nodepass.somewhere"
PASSES=0; FAILS=0; NOTES=0
pass() { echo " PASS $*"; PASSES=$((PASSES+1)); }
miss() { echo " FAIL $*"; FAILS=$((FAILS+1)); }
note() { echo " NOTE $*"; NOTES=$((NOTES+1)); }
head2() { printf '\n── %s\n' "$*"; }
die() { echo "cannot start: $*" >&2; exit 2; }
command -v adb >/dev/null || die "adb is not on PATH"
[ -n "$DEVICE" ] || DEVICE="$(adb devices | awk '$2=="device"{print $1; exit}')"
[ -n "$DEVICE" ] || die "no device"
[ -n "$PORTAL" ] || die "set NOWHERE_E2E_PORTAL"
[ -n "$TARGET" ] || die "set NOWHERE_E2E_TARGET"
KEY="${NOWHERE_E2E_KEY:-0123456789abcdef0123456789abcdef}"
PIN="${NOWHERE_E2E_PIN:-}"
[ -n "$PIN" ] || die "set NOWHERE_E2E_PIN to the fingerprint the Portal logged at startup; v3 has no unverified mode"
export NOWHERE_E2E_KEY="$KEY" NOWHERE_E2E_PIN="$PIN"
sh() { adb -s "$DEVICE" shell "$@" 2>/dev/null | tr -d '\r'; }
MODEL="$(sh getprop ro.product.model)"
SDK="$(sh getprop ro.build.version.sdk)"
ABI="$(sh getprop ro.product.cpu.abi)"
EMULATED="$(sh getprop ro.kernel.qemu)"
echo "Somewhere · device acceptance"
echo " device $DEVICE"
echo " model $MODEL"
echo " android API $SDK, $ABI"
if [ "$EMULATED" = "1" ]; then
echo
echo " !! This is an emulator. Every check below will run and none of them"
echo " answers the question Phase D exists for, which is what a phone"
echo " does. Use it to check the script, not the client."
else
# The address a device reaches the build host at is not the same one an
# emulator does, and getting it wrong looks like a broken tunnel rather
# than a wrong argument. 10.0.2.2 is QEMU's alias for the host and means
# nothing on a phone; a phone needs the host's address on the network they
# share, and the Portal has to be listening on it rather than on loopback.
case "$PORTAL" in
10.0.2.2:*|127.0.0.1:*|localhost:*)
die "NOWHERE_E2E_PORTAL is '$PORTAL', which is an emulator's or this machine's own address. A phone cannot reach either. Use the build host's address on the network the phone is on, and start the Portal bound to 0.0.0.0."
;;
esac
case "$TARGET" in
10.0.2.2:*)
die "NOWHERE_E2E_TARGET is '$TARGET'. 10.0.2.2 is QEMU's host alias; a Portal on a real network cannot dial it."
;;
esac
echo
echo " Physical hardware. This is the run Phase D was written for."
fi
# ── 1. Private DNS ──────────────────────────────────────────────────────────
# Modern Android defaults to Automatic, and the resolver then speaks DoT to a
# resolver of its own rather than sending UDP/53 to the address the TUN
# announces. Fake-IP depends on seeing those queries; if it does not, remote
# name resolution silently stops happening.
head2 "1. Private DNS"
PRIVATE_DNS="$(sh settings get global private_dns_mode)"
case "$PRIVATE_DNS" in
off) pass "Private DNS is off; the resolver will use the TUN's" ;;
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." ;;
hostname) miss "Private DNS is set to a specific hostname ($(sh settings get global private_dns_specifier)). DoT will bypass the TUN's resolver." ;;
*) note "Private DNS mode is '$PRIVATE_DNS', which this script does not know" ;;
esac
# ── 2. The app list, which is D-16 ──────────────────────────────────────────
# The manifest declares <queries> for launcher-visible apps instead of asking
# for QUERY_ALL_PACKAGES. Whether that is enough has never been measured
# against a device with real applications on it. This is the measurement.
head2 "2. Package visibility (D-16)"
INSTALLED="$(sh 'pm list packages -3' | wc -l | tr -d ' ')"
LAUNCHABLE="$(sh 'cmd package query-activities --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER' | grep -c '/' || true)"
echo " third-party packages installed: $INSTALLED"
echo " packages with a launcher activity: $LAUNCHABLE"
if [ "${INSTALLED:-0}" -lt 5 ]; then
note "only $INSTALLED third-party apps here, so this measures nothing. Run it on a device someone uses."
elif [ "${LAUNCHABLE:-0}" -gt 0 ] && [ "$INSTALLED" -gt 0 ]; then
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."
fi
# ── 3. Address families ─────────────────────────────────────────────────────
# The TUN carries both families since L4: an address and a default route for
# each, and the DNS layer synthesises AAAA behind that route. Two things can go
# wrong on a device and neither raises anything: the platform can decline a
# family it did not like the look of, and the network can be one this tunnel has
# never met.
head2 "3. Address families on the current network"
V4="$(sh 'ip -4 addr show scope global' | grep -c 'inet ' || true)"
V6="$(sh 'ip -6 addr show scope global' | grep -c 'inet6' || true)"
echo " global IPv4 addresses: $V4"
echo " global IPv6 addresses: $V6"
if [ "${V4:-0}" -eq 0 ] && [ "${V6:-0}" -gt 0 ]; then
note "IPv6-only network. The tunnel itself carries both families, but the socket to the Portal must still reach it — over IPv6, or over NAT64 if the Portal is v4-only. Watch for a tunnel that comes up and carries nothing."
elif [ "${V6:-0}" -gt 0 ]; then
pass "dual-stack, which is the case the v6 route exists for"
else
note "IPv4 only, so nothing here exercises the v6 half. Re-run on a network that has it."
fi
head2 "3c. A synthetic IPv6 address, end to end"
# The v6 half of the fake-IP layer, proved with nothing but this device: the
# name is resolved through the tunnel's own resolver, which answers AAAA with an
# address out of fc00::/96; the packet takes the ::/0 route into the TUN;
# lwIP's IPv6 side carries it; the address is resolved back to the name; and the
# Portal dials that name over whichever family its own network prefers. No
# IPv6 connectivity is needed anywhere outside this device.
#
# It needs a NAME rather than an address, because a synthetic address exists
# only as the answer to a query. NOWHERE_E2E_ORIGIN is that name; without one
# this says so rather than passing.
if [ -z "${NOWHERE_E2E_ORIGIN:-}" ]; then
note "no NOWHERE_E2E_ORIGIN given, so the v6 half of the fake-IP layer was not exercised. Set it to name:port of an origin only the Portal can resolve — e2e-fakeip.sh starts one at origin.somewhere.test:8080."
elif NOWHERE_E2E_FAMILY=6 NOWHERE_E2E_CARRIER=tcp "$ROOT/e2e-tunnel-fetch.sh" "$NOWHERE_E2E_ORIGIN" /blob.bin > /tmp/somewhere-fetch-v6.log 2>&1; then
RELAYED6="$(grep -o 'the Portal relayed [0-9]* bytes' /tmp/somewhere-fetch-v6.log | head -1)"
SYNTH="$(grep -o 'resolved to [0-9a-fA-F:]*' /tmp/somewhere-fetch-v6.log | head -1)"
pass "a synthetic IPv6 address carried 20 MB — $SYNTH, ${RELAYED6:-no counter}"
else
miss "synthetic IPv6: $(tail -3 /tmp/somewhere-fetch-v6.log | tr '\n' ' ')"
fi
head2 "3d. A real IPv6 destination through the tunnel"
# The other half, which this device cannot supply: an origin the Portal reaches
# over IPv6. Supplied or a note, never a silent pass.
if [ -z "${NOWHERE_E2E_TARGET6:-}" ]; then
note "no NOWHERE_E2E_TARGET6 given, so nothing checked that a genuinely IPv6 destination is carried. Set it to [addr]:port of an HTTP origin reachable over IPv6 from the Portal, serving /blob.bin with X-Content-Sha256."
elif NOWHERE_E2E_CARRIER=tcp "$ROOT/e2e-tunnel-fetch.sh" "$NOWHERE_E2E_TARGET6" /blob.bin > /tmp/somewhere-fetch-v6dst.log 2>&1; then
RELAYED6D="$(grep -o 'the Portal relayed [0-9]* bytes' /tmp/somewhere-fetch-v6dst.log | head -1)"
pass "an IPv6 destination is carried — ${RELAYED6D:-no counter}"
else
miss "IPv6 destination: $(tail -3 /tmp/somewhere-fetch-v6dst.log | tr '\n' ' ')"
fi
# ── 4. The tunnel actually carries traffic ──────────────────────────────────
# Both carriers, because a device is where a carrier difference would first
# show as something other than a test failure.
for CARRIER in tcp udp; do
head2 "4. Traffic over the tunnel ($CARRIER)"
if NOWHERE_E2E_CARRIER="$CARRIER" "$ROOT/e2e-tunnel-fetch.sh" "$TARGET" /blob.bin > "/tmp/somewhere-fetch-$CARRIER.log" 2>&1; then
RELAYED="$(grep -o 'the Portal relayed about [0-9]* bytes' "/tmp/somewhere-fetch-$CARRIER.log" | head -1)"
ATTEMPT="$(grep -o 'on attempt [0-9]*' "/tmp/somewhere-fetch-$CARRIER.log" | head -1)"
pass "20 MB intact over $CARRIER — ${RELAYED:-no counter}, ${ATTEMPT:-first attempt}"
else
miss "$CARRIER: $(tail -3 "/tmp/somewhere-fetch-$CARRIER.log" | tr '\n' ' ')"
fi
done
head2 "4a. What the platform actually built"
# Read from the device rather than from the source: Builder.addAddress accepts
# what it is given and establish() returns null rather than explaining itself,
# so a family Android declined looks exactly like one it did not. The
# instrumentation case brings the tunnel up itself and reads the interface while
# it is there, which a shell probe between two other checks cannot.
if adb -s "$DEVICE" shell am instrument -w \
-e class eu.nodepass.somewhere.vpn.TunFamiliesOnDeviceTest \
-e nowhereE2ePortal "$PORTAL" \
-e nowhereE2eKey "$KEY" \
-e nowhereE2ePin "$PIN" \
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
| tee /tmp/somewhere-families.log | grep -q "^OK ("; then
pass "the TUN carries both families at the MTU asked for"
else
miss "TUN families: $(grep -m1 'stack=' /tmp/somewhere-families.log | cut -c1-200)"
fi
# ── 4b. Failing over to another node ────────────────────────────────────────
# Two nodes, one that cannot be reached. The instrumentation case is the
# assertion; this runs it here so the whole device pass is still one command,
# and so a phone with a real network is what it runs on.
head2 "4b. Failover"
if adb -s "$DEVICE" shell am instrument -w \
-e class eu.nodepass.somewhere.vpn.FailoverOnDeviceTest \
-e nowhereE2ePortal "$PORTAL" \
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
| tee /tmp/somewhere-failover.log | grep -q "^OK ("; then
pass "an unreachable node is recorded as unreachable, and a refused one is not"
else
miss "failover: $(grep -m1 'stack=' /tmp/somewhere-failover.log | cut -c1-160)"
fi
# ── 4c. The background surface ──────────────────────────────────────────────
head2 "4c. Subscription refresh, and the tile"
if adb -s "$DEVICE" shell am instrument -w \
-e class eu.nodepass.somewhere.subscription.SubscriptionRefreshScheduleTest \
eu.nodepass.somewhere.test/androidx.test.runner.AndroidJUnitRunner 2>/dev/null \
| tee /tmp/somewhere-refresh.log | grep -q "^OK ("; then
pass "the platform accepts the refresh schedule, persists it, and drops it with the switch"
else
miss "refresh schedule: $(grep -m1 'stack=' /tmp/somewhere-refresh.log | cut -c1-160)"
fi
# Whether the job ever *fires* is Doze's decision and cannot be asserted from
# here; it can be provoked, which is a different thing and worth saying so.
if [ "${EMULATED:-0}" = "1" ]; then
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."
else
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."
fi
# Asked of the package manager's own resolution rather than of the manifest:
# a tile declared but not resolvable — a missing intent-filter, a permission
# the system does not accept — is a service that exists and can never be added.
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')"
if [ -n "$TILE" ]; then
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."
else
miss "the quick-settings tile is not registered on this device"
fi
# ── 5. Doze and battery management ──────────────────────────────────────────
# Vendor battery managers kill foreground services that survive indefinitely on
# an emulator. There is no way to assert the outcome from here; what can be
# reported is whether the app is exempt, which is what a user would have to
# change.
head2 "5. Battery management"
STANDBY="$(sh "am get-standby-bucket $APP_ID")"
IGNORING="$(sh "dumpsys deviceidle whitelist" | grep -c "$APP_ID" || true)"
echo " standby bucket: ${STANDBY:-unknown}"
echo " exempt from battery optimisation: $([ "${IGNORING:-0}" -gt 0 ] && echo yes || echo no)"
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."
# ── 6. MTU ──────────────────────────────────────────────────────────────────
# The TUN's MTU is 1500 unless Network settings lowers it (1280 to 1500). Real
# cellular paths are frequently smaller, and there is TLS or QUIC framing on top. QUIC makes this sharper: a datagram that does not fit
# is not fragmented by the path.
head2 "6. Path MTU"
UNDERLYING="$(sh "ip link show" | grep -E "wlan0|rmnet|eth0" | grep -o "mtu [0-9]*" | head -1 | cut -d' ' -f2)"
echo " underlying interface MTU: ${UNDERLYING:-unknown}"
if [ -n "$UNDERLYING" ] && [ "$UNDERLYING" -lt 1500 ] 2>/dev/null; then
miss "the path MTU is $UNDERLYING, below the TUN's default 1500. Try Settings > Network > MTU at $UNDERLYING and say whether large transfers then complete."
else
pass "the underlying MTU is ${UNDERLYING:-1500}, so the TUN's 1500 fits"
fi
# ── 7. Reaching the tunnel from outside the app (L5) ────────────────────────
# What can be asked of the system from here. Each of these was checked on an
# emulator, from the shell; the point of running them again is that a vendor
# build can differ in exactly these places — shortcuts, background starts,
# notification policy.
head2 "7. Shortcuts, automation, notification, tile"
SHORTCUTS="$(sh "dumpsys shortcut" | grep -A80 "Package: $APP_ID" | grep -c 'ShortcutInfo {id=\(connect\|disconnect\|toggle\)')"
if [ "${SHORTCUTS:-0}" -ge 3 ]; then
pass "the three launcher shortcuts are registered"
else
miss "expected three launcher shortcuts, found ${SHORTCUTS:-0}"
fi
# With the switch off — the default — the exported alias must refuse. Checked
# by the tunnel staying down, since the activity finishes either way.
sh "am force-stop $APP_ID" >/dev/null
sh "am start -a eu.nodepass.somewhere.action.START" >/dev/null
sleep 5
if sh "ip addr show tun0" | grep -q inet; then
miss "START from another app brought the tunnel up with the automation switch off"
sh "am start -a eu.nodepass.somewhere.action.STOP" >/dev/null
else
pass "START from another app is refused while the automation switch is off"
fi
DIRECT="$(adb -s "$DEVICE" shell am start -n "$APP_ID/.QuickActionActivity" -a eu.nodepass.somewhere.action.START 2>&1 | grep -c 'not exported\|SecurityException')"
if [ "${DIRECT:-0}" -gt 0 ]; then
pass "the screenless activity itself is not reachable from another app"
else
miss "the screenless activity was started directly from the shell"
fi
PREFS="$(adb -s "$DEVICE" shell cmd package query-activities -a android.service.quicksettings.action.QS_TILE_PREFERENCES "$APP_ID" 2>/dev/null | grep -c "$APP_ID.MainActivity")"
[ "${PREFS:-0}" -gt 0 ] && pass "long-pressing the tile opens the app" || miss "the tile's long-press does not resolve to the app"
if [ "${SDK:-0}" -ge 33 ]; then
GRANTED="$(sh "dumpsys package $APP_ID" | grep -c 'POST_NOTIFICATIONS: granted=true')"
[ "${GRANTED:-0}" -gt 0 ] && pass "notification permission is granted" || note "notification permission is not granted yet; it is asked for once a tunnel is up — answer it, then re-run"
fi
note "work through docs/device-checklist.md: every feature added in L5, with what to look for. Report each line that does not match."
# ── Report ──────────────────────────────────────────────────────────────────
printf '\n%s\n' "──────────────────────────────────────────"
echo "$PASSES passed, $FAILS failed, $NOTES to look at by hand"
echo
echo "A NOTE is not a pass. It is something only a person watching the device"
echo "can settle, and leaving it unsettled is the honest state until they do."
[ "$FAILS" -eq 0 ] || exit 1
# ── Running this on a phone ─────────────────────────────────────────────────
#
# 1. Start a Portal on this machine, bound to every interface rather than
# loopback, and an origin beside it:
#
# nowhere "portal://<key>@0.0.0.0:22095" > portal.log &
#
# The key is 32 lowercase hex characters (`nowhere generate-key`). The
# Portal logs its certificate fingerprint on its first line; that is the
# pin.
#
# 2. Find this machine's address on the network the phone is on:
#
# ipconfig getifaddr en0 # macOS
# hostname -I | awk '{print $1}' # Linux
#
# 3. Attach the phone with USB debugging on, then:
#
# NOWHERE_E2E_PORTAL=<that address>:22095 \
# NOWHERE_E2E_KEY=<key> \
# NOWHERE_E2E_PIN=<the fingerprint> \
# NOWHERE_E2E_TARGET=<that address>:28091 \
# NOWHERE_BIN=<path to nowhere> \
# conformance/scripts/device-acceptance.sh
#
# The phone and this machine must be on the same network, and this machine's
# firewall must let the phone reach both ports. A tunnel that cannot reach its
# Portal looks exactly like a tunnel that does not work.