testsAndMisc/linux_configuration/scripts/single_use/features/setup_phone_wireguard.sh
Krzysztof kuhy Rudnicki 143d260c1c
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
Park phone DNS filtering: Private DNS fails closed, so the PC cannot host it
Measured on the Pixel 6a, twice: with private_dns_mode=hostname and the
resolver unreachable, `ping example.com` returns "unknown host". Android's
Private DNS does not fall back to the carrier's resolver -- hostname mode is
RFC 8310's strict profile, and failing to establish TLS on 853 is a hard error.

So a resolver living on this PC means "PC off" equals "phone has no internet",
not merely "no filtering". That is disqualifying for a daily driver, and it
disqualifies the whole home-hosted delivery path regardless of how well it
works while the machine is up -- which it does; it was verified end to end.

DNS_TRUSTED_DOT_HOST/IPS go back to empty, which restores the previous enforcer
behaviour byte for byte. No code revert is needed, which is exactly why the
flag was built to default empty.

Two things found while testing are worth keeping. The nftables input chain is
policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel
address was not sufficient on its own: ICMP passed while TCP 853 was dropped,
which presents confusingly as "ping works but the port does not". That rule is
now added explicitly and scoped to wg0. And setup_phone_wireguard.sh automates
the client setup the tunnel needed, verifying handshake, reachability and DNS
rather than assuming success, and deleting the pushed private key afterwards.

The deeper reason the home endpoint could not work even with the tunnel up:
dns.kuhy.duckdns.org resolves to the public IP while stunnel listens only on
10.8.0.1, and with AllowedIPs=10.8.0.0/24 that routes outside the tunnel to a
closed port. Android never opened a connection at all. Split-horizon DNS does
not fix it either, since on mobile data the phone uses the carrier's resolver.

Research also ruled out the hosted-hybrid option that looked most attractive:
no provider can ingest a 185k-domain list (NextDNS none, AdGuard 1-5K, ControlD
10K), and the free tiers fail OPEN -- they stop filtering past 300k queries a
month while still resolving, which for a self-control tool is the worst failure
mode available. Details and the remaining options are in the parked doc.

The stunnel endpoint is left running deliberately: WireGuard-only, verified
unreachable from LAN and WAN, and it makes the next attempt testable
immediately. Removal commands are documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 10:05:16 +02:00

226 lines
6.6 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Get the phone onto the WireGuard tunnel, so it can reach the DoT resolver.
#
# Why this exists:
# setup_dot_resolver.sh binds the DoT endpoint to 10.8.0.1 (WireGuard only),
# which is what keeps it from being an open resolver on the internet. That
# makes the tunnel a hard prerequisite: with it down, pinning Private DNS to
# the resolver leaves the phone with no working DNS at all. This script
# installs the client, pushes the config, and -- most importantly -- verifies
# the tunnel actually carries DNS before anything starts depending on it.
#
# What it cannot do:
# Android requires an explicit user tap to grant VPN permission and to
# import a tunnel config; there is no ADB path around that, by design. The
# script does everything either side of those taps and tells you exactly
# what to press.
#
# Usage:
# ./setup_phone_wireguard.sh [--serial <adb-serial>] [--check]
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly DEFAULT_SERIAL="23181JEGR08034"
readonly WG_PACKAGE="com.zaneschepke.wireguardautotunnel"
readonly WG_FALLBACK_PACKAGE="com.wireguard.android"
readonly SERVER_CONF="/etc/wireguard/wg0.conf"
readonly CLIENT_CONF="/etc/wireguard/clients/phone.conf"
readonly TUNNEL_ADDR="10.8.0.1"
readonly PHONE_ADDR="10.8.0.2"
readonly PUSH_PATH="/sdcard/Download/phone.conf"
SERIAL="$DEFAULT_SERIAL"
CHECK_ONLY=0
log() { printf '[phone-wg] %s\n' "$1"; }
die() {
printf '[phone-wg] ERROR: %s\n' "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
--serial <id> adb serial (default: $DEFAULT_SERIAL)
--check Verify the tunnel end to end, change nothing
-h, --help Show this help
EOF
exit 0
}
adb_sh() { adb -s "$SERIAL" shell "$@" 2>/dev/null | tr -d '\r'; }
require_device() {
command -v adb >/dev/null 2>&1 ||
die "adb not found; install android-tools"
adb -s "$SERIAL" get-state >/dev/null 2>&1 ||
die "device $SERIAL not connected"
}
require_server() {
sudo test -f "$SERVER_CONF" ||
die "$SERVER_CONF missing - is WireGuard set up on this host?"
sudo test -f "$CLIENT_CONF" ||
die "$CLIENT_CONF missing - no client config to import"
ip -brief addr show 2>/dev/null | grep -q "$TUNNEL_ADDR" ||
die "$TUNNEL_ADDR is not up; start it with: sudo wg-quick up wg0"
}
installed_client() {
# Echo whichever WireGuard client is present, preferring the one already
# whitelisted in phone_focus_mode.
local pkg
for pkg in "$WG_PACKAGE" "$WG_FALLBACK_PACKAGE"; do
if adb_sh "pm list packages $pkg" | grep -q .; then
printf '%s' "$pkg"
return 0
fi
done
return 1
}
install_client() {
local pkg
if pkg="$(installed_client)"; then
log "WireGuard client already installed: $pkg"
return 0
fi
# Play Store installs cannot be driven headlessly. Open the listing and
# let the user tap Install; re-running the script then proceeds.
log "No WireGuard client installed. Opening the Play Store listing..."
adb -s "$SERIAL" shell am start -a android.intent.action.VIEW \
-d "market://details?id=$WG_PACKAGE" >/dev/null 2>&1 || true
die "install WireGuard on the phone, then re-run $SCRIPT_NAME"
}
push_config() {
# The config carries a private key, so it goes to Download/ only long
# enough to be imported, and is deleted again in verify/cleanup.
log "Pushing client config to $PUSH_PATH"
local tmp
tmp="$(mktemp)"
# shellcheck disable=SC2064 # expand $tmp now, not at trap time
trap "rm -f '$tmp'" RETURN
# The redirect is to our own mktemp file, not to the root-owned source;
# install(1) makes that explicit and keeps the copy mode-0600.
sudo install -m 600 -o "$USER" "$CLIENT_CONF" "$tmp" ||
die "could not read $CLIENT_CONF"
grep -q "^Address *= *$PHONE_ADDR" "$tmp" ||
die "client config does not assign $PHONE_ADDR - wrong peer?"
adb -s "$SERIAL" push "$tmp" "$PUSH_PATH" >/dev/null ||
die "adb push failed"
}
prompt_import() {
local pkg
pkg="$(installed_client)" || die "no WireGuard client installed"
log "Opening $pkg - complete these steps on the phone:"
log " 1. '+' -> Import from file -> Download/phone.conf"
log " 2. Toggle the tunnel ON"
log " 3. Accept Android's VPN permission dialog"
adb -s "$SERIAL" shell monkey -p "$pkg" \
-c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
}
cleanup_pushed_config() {
# The pushed file contains a private key; do not leave it in Download/.
adb_sh "rm -f $PUSH_PATH" >/dev/null 2>&1 || true
log "Removed $PUSH_PATH from the phone"
}
run_check() {
local ok=0
if ! installed_client >/dev/null; then
log "FAIL no WireGuard client installed"
return 1
fi
# A handshake proves the tunnel is established from the server's side;
# the phone's own interface list is not visible without root.
local handshake
handshake="$(sudo wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)"
if [[ -z "$handshake" || "$handshake" == "0" ]]; then
log "FAIL no WireGuard handshake yet - is the tunnel toggled on?"
ok=1
else
log "OK handshake $(($(date +%s) - handshake))s ago"
fi
# The decisive test: not "is a VPN up" but "does the phone reach the
# resolver". Everything downstream depends on this one answer.
if adb_sh "ping -c 2 -W 3 $TUNNEL_ADDR" | grep -q " 0% packet loss"; then
log "OK phone reaches $TUNNEL_ADDR over the tunnel"
else
log "FAIL phone cannot reach $TUNNEL_ADDR"
ok=1
fi
# And that DNS actually resolves through it, which is the thing that
# broke when this was attempted without a tunnel.
if adb_sh "ping -c 2 -W 3 example.com" | grep -q " 0% packet loss"; then
log "OK DNS resolves on the phone"
else
log "FAIL DNS does not resolve on the phone"
ok=1
fi
if [[ $ok -eq 0 ]]; then
log "Tunnel healthy. Safe to set in phone_focus_mode/config.sh:"
log " DNS_TRUSTED_DOT_HOST=\"dns.kuhy.duckdns.org\""
log " DNS_TRUSTED_DOT_IPS=\"$TUNNEL_ADDR\""
fi
return "$ok"
}
main() {
require_device
if [[ $CHECK_ONLY -eq 1 ]]; then
run_check
exit $?
fi
require_server
install_client
push_config
prompt_import
log "Waiting up to 180s for the tunnel to come up..."
local deadline=$((SECONDS + 180))
while ((SECONDS < deadline)); do
if adb_sh "ping -c 1 -W 2 $TUNNEL_ADDR" | grep -q " 0% packet loss"; then
log "Tunnel is up."
break
fi
sleep 5
done
cleanup_pushed_config
run_check || {
log "Tunnel not verified. Re-run with --check after toggling it on."
exit 1
}
}
while [[ $# -gt 0 ]]; do
case $1 in
--serial)
SERIAL="$2"
shift 2
;;
--check)
CHECK_ONLY=1
shift
;;
-h | --help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"