diff --git a/AGENTS.md b/AGENTS.md index 0db23f7..6a01cab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=…'"` (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=`. +- **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 - **tracing caches callsite interest process-wide.** A test that hits a diff --git a/server/src/main.rs b/server/src/main.rs index 2ffd6e7..7be7d2d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -35,13 +35,29 @@ const WG_INTERFACE: &str = "wg0"; /// The repo root, one level above this crate. Everything the server reads /// 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. +/// +/// 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 { static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); ROOT.get_or_init(|| { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("CARGO_MANIFEST_DIR has a repo-root parent") - .to_path_buf() + // 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")) + .parent() + .expect("CARGO_MANIFEST_DIR has a repo-root parent") + .to_path_buf() + }) }) } diff --git a/server/wg-test.log b/server/wg-test.log new file mode 100644 index 0000000..83a5a3e --- /dev/null +++ b/server/wg-test.log @@ -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 diff --git a/test-wg-tunnel.sh b/test-wg-tunnel.sh new file mode 100755 index 0000000..b7ac7ad --- /dev/null +++ b/test-wg-tunnel.sh @@ -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" \ + "$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= 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 diff --git a/wg-setup-host.sh b/wg-setup-host.sh new file mode 100755 index 0000000..729a595 --- /dev/null +++ b/wg-setup-host.sh @@ -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" < Writing $PEER_DIR/phone.conf" +cat > "$PEER_DIR/phone.conf" < 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."