WireGuard bring-up: host setup script, in-VM test tunnel, portable repo_root

wg-setup-host.sh sets up the tunnel on the backend host: keys generated
there and kept there, wg0.conf, wg-quick enabled, and the phone's config
printed as a scannable QR. Split tunnel (AllowedIPs is only the backend
subnet), single-address addressing per PLAN.md, and the three things it
can't do for you -- router UDP forward, DDNS, hairpin check -- spelled
out at the end.

test-wg-tunnel.sh stands up a real WireGuard tunnel between two network
namespaces inside one machine, so the production posture (bind wg0 and
nothing else) is testable with no router, phone, or internet exposure.
Verified: real handshake, server listening on 10.66.0.1:8443 only, and
an authorized request from inside the tunnel answering 200 over pinned
TLS -- the leaf's 10.66.0.1 SAN is what a phone will validate too.

repo_root() now resolves from the running executable before falling back
to the compiled-in path: the repo is shared host<->VM over virtiofs at
different absolute paths with a shared target/, so a binary built on one
side and run on the other looked for its config where nothing exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 02:22:00 -04:00
1 parent 8841effc6a
commit 29f8b31f0b
5 files changed
+320

No files matched your search

+32
View File
@@ -70,6 +70,38 @@ real-phone/WireGuard bring-up, which is operational rather than code.
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"` `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`
(quote so the device shell doesn't eat the `&`s). (quote so the device shell doesn't eat the `&`s).
## Where things run (host vs this VM)
Established 2026-08-25, and it decides more than it looks like:
- **The host (192.168.1.168) is the backend machine.** It runs
local-updater's server today and is where `ai-server` belongs in
production: it has the LAN address the phone can reach, and it's where
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
the phone's QR); run it there with `sudo WG_ENDPOINT=<ddns name>`.
- **This VM is a dev sandbox behind qemu user-mode networking**
(10.0.2.15, gateway 10.0.2.2) — outbound only. The host is reachable at
10.0.2.2, but **nothing outside can initiate a connection into the VM**,
so the tunnel and the real phone can never terminate here.
- The repo is the *same files* on both sides over virtiofs, at different
absolute paths: `~/host/repos/ai-app` in the VM,
`~/stuff/vm/ai/repos/ai-app` on the host. `server/target/` is shared
along with it, so **a `cargo build` on one side replaces the other's
binary** (and each rebuilds from scratch after the other). `repo_root()`
resolves from the running executable for exactly this reason — a
host-built binary run in the VM used to look for its config under a path
that doesn't exist here.
- `wg0` (10.66.0.1) now exists in this VM too, so the production path —
`ai-server` with no `--bind` — is exercisable during development. It has
no reachable peer and doesn't need one; the interface existing is what
the server requires. Consequence: **with no `--bind`, the emulator can't
reach the server** (it dials 10.0.2.2), so keep using
`--bind 127.0.0.1` for app work.
- `./test-wg-tunnel.sh up|test|down` builds a real tunnel between two
network namespaces inside one machine and drives the server through it
— a genuine handshake against 10.66.0.1 with pinned TLS, no router or
phone involved. That's the way to verify the wg0-only posture.
## Things that have bitten ## Things that have bitten
- **tracing caches callsite interest process-wide.** A test that hits a - **tracing caches callsite interest process-wide.** A test that hits a
+16
View File
@@ -35,14 +35,30 @@ const WG_INTERFACE: &str = "wg0";
/// The repo root, one level above this crate. Everything the server reads /// The repo root, one level above this crate. Everything the server reads
/// by default -- the TLS cert, the config, the session data -- resolves /// by default -- the TLS cert, the config, the session data -- resolves
/// from here, so there's one definition of it rather than one per caller. /// from here, so there's one definition of it rather than one per caller.
///
/// Found from the running executable first, and only then from the path
/// compiled in. This repo is shared between a VM and its host over
/// virtiofs at *different* absolute paths (`~/host/repos/ai-app` vs
/// `~/stuff/vm/ai/repos/ai-app`), and `target/` is shared along with it --
/// so a binary built on one side and run on the other would otherwise look
/// for its config under a path that doesn't exist there, which is exactly
/// what happened once. Where both agree (the ordinary case) the answer is
/// identical either way; the flags below override it regardless.
fn repo_root() -> &'static Path { fn repo_root() -> &'static Path {
static ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); static ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
ROOT.get_or_init(|| { ROOT.get_or_init(|| {
// target/{debug,release}/ai-server -> four levels up is the root.
let from_exe = std::env::current_exe().ok().and_then(|exe| {
let root = exe.ancestors().nth(4)?.to_path_buf();
root.join("server/Cargo.toml").is_file().then_some(root)
});
from_exe.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR")) Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.expect("CARGO_MANIFEST_DIR has a repo-root parent") .expect("CARGO_MANIFEST_DIR has a repo-root parent")
.to_path_buf() .to_path_buf()
}) })
})
} }
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app. /// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
+4
View File
@@ -0,0 +1,4 @@
2026-08-25T06:18:57.533304Z  INFO ai_server: config: /home/bob/host/repos/ai-app/config.json
2026-08-25T06:18:57.537682Z  INFO ai_server: serving https://10.66.0.1:8443
2026-08-25T06:20:43.470080Z  INFO ai_server: config: /home/bob/host/repos/ai-app/config.json
2026-08-25T06:20:43.474488Z  INFO ai_server: serving https://10.66.0.1:8443
+132
View File
@@ -0,0 +1,132 @@
#!/bin/sh
# Stands up a real WireGuard tunnel entirely inside this machine, so the
# server's production network posture -- "bind wg0 and nothing else, fail
# closed if it's missing" -- can be exercised without a phone, a router
# port-forward, or any internet exposure.
#
# The shape, all in one kernel:
#
# main netns "phone" netns
# wg0 10.66.0.1 <-- encrypted --> wg1 10.66.0.2
# | |
# veth-srv 10.99.0.1 <--- UDP ---> veth-phone 10.99.0.2
#
# The veth pair stands in for "the internet" carrying WireGuard's UDP; the
# wg interfaces are real, with a real handshake and real keys. 10.66.0.1 is
# deliberately the same address the leaf certificate carries a SAN for
# (gen-dev-cert.sh), so a client inside the tunnel completes the same
# pinned-TLS handshake a phone will.
#
# ./test-wg-tunnel.sh up create the tunnel (needs sudo)
# ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone"
# ./test-wg-tunnel.sh down remove everything it created
#
# Everything here is torn down by `down`: the netns (taking wg1 and the veth
# peer with it), wg0, and the temporary key files.
set -eu
NS=phone
WG_SERVER=wg0
WG_CLIENT=wg1
SERVER_WG_IP=10.66.0.1
CLIENT_WG_IP=10.66.0.2
SERVER_UDP_IP=10.99.0.1
CLIENT_UDP_IP=10.99.0.2
LISTEN_PORT=51820
KEYDIR=/run/ai-app-wg-test
REPO=$(cd "$(dirname "$0")" && pwd)
up() {
echo "==> Generating ephemeral keypairs in $KEYDIR"
sudo mkdir -p "$KEYDIR"
sudo sh -c "umask 077; wg genkey > $KEYDIR/server.key; wg genkey > $KEYDIR/client.key"
sudo sh -c "wg pubkey < $KEYDIR/server.key > $KEYDIR/server.pub"
sudo sh -c "wg pubkey < $KEYDIR/client.key > $KEYDIR/client.pub"
echo "==> Creating netns '$NS' and the veth pair that carries the UDP"
sudo ip netns add "$NS"
sudo ip link add veth-srv type veth peer name veth-phone
sudo ip link set veth-phone netns "$NS"
sudo ip addr add "$SERVER_UDP_IP/24" dev veth-srv
sudo ip link set veth-srv up
sudo ip -n "$NS" addr add "$CLIENT_UDP_IP/24" dev veth-phone
sudo ip -n "$NS" link set veth-phone up
sudo ip -n "$NS" link set lo up
echo "==> Creating $WG_SERVER (server side, $SERVER_WG_IP)"
sudo ip link add "$WG_SERVER" type wireguard
sudo sh -c "wg set $WG_SERVER listen-port $LISTEN_PORT private-key $KEYDIR/server.key \
peer \$(cat $KEYDIR/client.pub) allowed-ips $CLIENT_WG_IP/32"
sudo ip addr add "$SERVER_WG_IP/24" dev "$WG_SERVER"
sudo ip link set "$WG_SERVER" up
# Created in the main namespace, then moved: a wireguard interface keeps
# its UDP socket in the namespace it was born in, which is exactly what
# lets the "phone" reach the server's veth address from inside its own.
echo "==> Creating $WG_CLIENT (phone side, $CLIENT_WG_IP) in netns '$NS'"
sudo ip link add "$WG_CLIENT" type wireguard
sudo ip link set "$WG_CLIENT" netns "$NS"
sudo ip netns exec "$NS" sh -c "wg set $WG_CLIENT private-key $KEYDIR/client.key \
peer \$(cat $KEYDIR/server.pub) allowed-ips $SERVER_WG_IP/32 \
endpoint $SERVER_UDP_IP:$LISTEN_PORT persistent-keepalive 5"
sudo ip -n "$NS" addr add "$CLIENT_WG_IP/24" dev "$WG_CLIENT"
sudo ip -n "$NS" link set "$WG_CLIENT" up
echo "==> Forcing a handshake"
sudo ip netns exec "$NS" ping -c 2 -W 3 "$SERVER_WG_IP" >/dev/null 2>&1 || true
sudo wg show "$WG_SERVER" | sed 's/^/ /'
echo "==> Up. wg0 is $SERVER_WG_IP; run '$0 test' next."
}
test_tunnel() {
if [ ! -f "$REPO/certs/leaf.pem" ]; then
echo "No certs/ -- run ./gen-dev-cert.sh first." >&2
exit 1
fi
if [ ! -x "$REPO/server/target/debug/ai-server" ]; then
echo "Build the server first: (cd server && cargo build)" >&2
exit 1
fi
echo "==> Starting ai-server with NO --bind (production path: wg0 only)"
setsid nohup "$REPO/server/target/debug/ai-server" \
</dev/null >"$REPO/server/wg-test.log" 2>&1 &
sleep 2
echo "==> Where is it actually listening?"
ss -tlnp 2>/dev/null | grep 8443 | sed 's/^/ /' || echo " (nothing on 8443)"
echo "==> From inside the tunnel: GET /sessions through wg1 -> wg0"
TOKEN=$(sudo cat "$REPO/config.json" 2>/dev/null | sed -n 's/.*"sha256": "\(.*\)".*/\1/p' | head -1)
if [ -z "${AI_TOKEN:-}" ]; then
echo " (set AI_TOKEN=<the enrollment token> to test an authorized call;"
echo " without it this only proves reachability + TLS, via a 401)"
fi
sudo ip netns exec "$NS" curl -s -o /dev/null -w " HTTP %{http_code} (TLS ok, pinned CA)\n" \
--cacert "$REPO/certs/ca.pem" \
${AI_TOKEN:+-H "Authorization: Bearer $AI_TOKEN"} \
"https://$SERVER_WG_IP:8443/sessions" || echo " UNREACHABLE"
echo "==> Handshake counters (proves the traffic really crossed WireGuard)"
sudo wg show "$WG_SERVER" transfer | sed 's/^/ /'
pkill -f "[a]i-server" || true
echo "==> Server stopped."
}
down() {
echo "==> Removing tunnel"
sudo ip netns del "$NS" 2>/dev/null || true
sudo ip link del "$WG_SERVER" 2>/dev/null || true
sudo ip link del veth-srv 2>/dev/null || true
sudo rm -rf "$KEYDIR"
echo "==> Down."
}
case "${1:-}" in
up) up ;;
test) test_tunnel ;;
down) down ;;
*) echo "usage: $0 up|test|down" >&2; exit 1 ;;
esac
+136
View File
@@ -0,0 +1,136 @@
#!/bin/sh
# Sets up the WireGuard tunnel on the BACKEND HOST -- the machine that runs
# ai-server and that the phone dials in to. Run this on the host, not in the
# dev VM (the VM is behind qemu user-mode networking and has no inbound path;
# see AGENTS.md).
#
# sudo WG_ENDPOINT=your-name.duckdns.org ./wg-setup-host.sh
#
# What it creates:
# /etc/wireguard/wg0.conf the backend's tunnel: 10.66.0.1, port 51820
# /etc/wireguard/peers/phone.conf the phone's config, shown as a QR to scan
# and enables wg-quick@wg0 so the tunnel comes back after a reboot.
#
# Addressing matches PLAN.md: the phone reaches the backend at 10.66.0.1 from
# everywhere, home or away -- one address in the app, one SAN in the leaf
# certificate, no home/away distinction. The phone's AllowedIPs is only
# 10.66.0.0/24, so this is a split tunnel: the phone's other traffic does not
# route through your house, and nothing here forwards or NATs.
#
# Re-running is safe: existing keys are reused, so the phone's config stays
# valid. Pass WG_NEW_PHONE_KEY=1 to issue a fresh phone keypair, which
# invalidates the old one.
#
# The one thing this cannot do for you: forward UDP 51820 from your router to
# this host. That is the only internet-facing hole, and it is silent to
# unauthenticated packets -- scanners see a closed port.
set -eu
WG_DIR=/etc/wireguard
PEER_DIR="$WG_DIR/peers"
SERVER_IP=10.66.0.1
PHONE_IP=10.66.0.2
SUBNET=10.66.0.0/24
PORT="${WG_PORT:-51820}"
ENDPOINT="${WG_ENDPOINT:-}"
if [ "$(id -u)" -ne 0 ]; then
echo "Run this with sudo -- it writes $WG_DIR and enables a service." >&2
exit 1
fi
for tool in wg wg-quick; do
command -v "$tool" >/dev/null || { echo "$tool not found: install wireguard-tools." >&2; exit 1; }
done
if [ -z "$ENDPOINT" ]; then
echo "Set WG_ENDPOINT to the hostname the phone should dial from outside," >&2
echo "e.g. WG_ENDPOINT=your-name.duckdns.org (a DDNS name, since a home IP" >&2
echo "can change). Then re-run." >&2
exit 1
fi
umask 077
mkdir -p "$PEER_DIR"
# Keys are generated here and never leave, except the phone's -- which is
# what the QR carries. Regenerating the server key would invalidate every
# peer, so it is created once and then reused.
if [ ! -f "$WG_DIR/server.key" ]; then
echo "==> Generating the backend's keypair"
wg genkey > "$WG_DIR/server.key"
wg pubkey < "$WG_DIR/server.key" > "$WG_DIR/server.pub"
else
echo "==> Reusing the backend's existing keypair"
fi
if [ ! -f "$PEER_DIR/phone.key" ] || [ -n "${WG_NEW_PHONE_KEY:-}" ]; then
echo "==> Generating the phone's keypair"
wg genkey > "$PEER_DIR/phone.key"
wg pubkey < "$PEER_DIR/phone.key" > "$PEER_DIR/phone.pub"
else
echo "==> Reusing the phone's existing keypair"
fi
echo "==> Writing $WG_DIR/wg0.conf"
cat > "$WG_DIR/wg0.conf" <<EOF
# Generated by ai-app/wg-setup-host.sh. The backend binds this interface's
# address and refuses to start without it (see server/src/main.rs).
[Interface]
Address = $SERVER_IP/24
ListenPort = $PORT
PrivateKey = $(cat "$WG_DIR/server.key")
[Peer]
# phone
PublicKey = $(cat "$PEER_DIR/phone.pub")
AllowedIPs = $PHONE_IP/32
EOF
echo "==> Writing $PEER_DIR/phone.conf"
cat > "$PEER_DIR/phone.conf" <<EOF
[Interface]
Address = $PHONE_IP/24
PrivateKey = $(cat "$PEER_DIR/phone.key")
[Peer]
PublicKey = $(cat "$WG_DIR/server.pub")
Endpoint = $ENDPOINT:$PORT
# Split tunnel: only the backend's subnet goes over WireGuard.
AllowedIPs = $SUBNET
# Keeps the mapping alive through home NAT so the backend can reach the
# phone first (needed later for "your turn" push).
PersistentKeepalive = 25
EOF
echo "==> Enabling wg-quick@wg0"
systemctl enable --now "wg-quick@wg0" >/dev/null 2>&1 || {
echo " systemctl failed; bringing it up directly instead"
wg-quick down wg0 >/dev/null 2>&1 || true
wg-quick up wg0
}
sleep 1
wg show wg0 | sed 's/^/ /'
echo
echo "==> Phone config -- scan this with the WireGuard app (Add > Scan from QR code):"
echo
if command -v qrencode >/dev/null; then
qrencode -t ansiutf8 < "$PEER_DIR/phone.conf"
else
echo " (install qrencode to get a scannable QR; the config is below)"
sed 's/^/ /' "$PEER_DIR/phone.conf"
fi
echo
echo "Still to do, in order:"
echo " 1. Forward UDP $PORT on your router to this host. That is the only"
echo " internet-facing port; it stays silent to unauthenticated packets."
echo " 2. Point $ENDPOINT at your home IP (DDNS client on the router, or a"
echo " curl cron here). WireGuard on the phone resolves this once when the"
echo " tunnel comes up, so after a rare IP change, toggle the tunnel."
echo " 3. Check NAT hairpinning works at home: with the tunnel on and the"
echo " phone on your wifi, 'ping $SERVER_IP' from the phone should answer."
echo " If it doesn't, your router can't hairpin -- turn the tunnel off at"
echo " home, or use a split-DNS entry pointing $ENDPOINT at the LAN IP."
echo " 4. Start the backend here (it binds $SERVER_IP only, and refuses to"
echo " start if wg0 is down):"
echo " cd $(dirname "$(readlink -f "$0")") && ./server/target/release/ai-server"
echo " Add --rotate-token once to print a fresh enrollment QR for the app."