Experimental: pure userspace Tailscale web browser for iOS/macOS with Aperture Chat and Thundersnap VM
README.md

Proxy-semantics measurement harness #

The tools that turned the iPad "invalid URL" (-1000) investigation from hypothesis into measurement. They run on macOS (host), need no device, no simulator and no signing, and they exercise the same two APIs the app uses: URLSession.proxyConfigurations and WKWebsiteDataStore.proxyConfigurations.

Findings are written up in ../../timing/README.md ("RESOLVED: measured matchDomains semantics…") and encoded as unit tests in ../test-proxy-policy.sh.

socks.py — logging SOCKS5 proxy #

A real SOCKS5 server that logs every CONNECT and then relays. This is the instrument: whether a request appears in its log is ground truth for "did the OS route this host through the proxy, or send it DIRECT?"

python3 socks.py 18099          # listens on 127.0.0.1:18099, logs to /tmp/pxprobe/socks.log

Used to establish the matchDomains semantics table (suffix matching, CIDR support and boundaries, the single-label/TLD collision, empty-entry wildcard, WebKit ≡ URLSession).

failsocks.py — SOCKS5 proxy that always fails CONNECT #

Accepts the handshake, then answers CONNECT with a chosen reply code (1=general, 2=not-allowed, 3=net-unreachable, 4=host-unreachable, 5=refused).

python3 failsocks.py 18201 1    # port, reply code

failmap.swift — which NSURLError does each SOCKS failure produce? #

Drives failsocks.py on five ports through WebKit and prints the error the navigation delegate reports.

for c in 1 2 3 4 5; do python3 failsocks.py $((18200+c)) $c & done
xcrun swiftc -O failmap.swift -o failmap && ./failmap

Result — the key finding: every SOCKS CONNECT failure code surfaces as NSURLErrorDomain -1000 (NSURLErrorBadURL, shown to users as "invalid URL"), for both short names and public hostnames. So -1000 means "the proxy could not establish this connection" — not "the URL is malformed", and not necessarily Local Network Access. This invalidated the earlier reasoning that -1000 proved the request never reached tsnet.

e2e.swift — the fix, end to end #

Loads a public host and tailnet destinations through WebKit twice: once with an unscoped proxy (the old behaviour) and once with the tailnet-scoped rule set TailnetProxyPolicy produces. Confirms the public host stops traversing the proxy while tailnet destinations still do.

python3 socks.py 18099 &
xcrun swiftc -O e2e.swift -o e2e && ./e2e

Note it also reproduces the user-visible -1000 locally: a tailnet name the stand-in proxy can't resolve fails exactly the way the iPad does.