VPN Egress Testing
A Mullvad-tunneled headless browser in projects/vpn-egress/ that answers "is
this reachable from outside" for any URL. If the user hands you a URL —
theirs, someone else's, anything — just test that URL.
Why this exists (read before testing anything)
Direct requests from the local shell, and WebFetch, are not valid
reachability evidence for this network's public domains:
WebFetchgetsECONNREFUSEDon port 443 for domains a phone on cellular loads fine. This network rejects some datacenter-sourced traffic on 443.- Requests from this machine's shell hit hairpin NAT reaching the router's own public IP from inside the LAN.
Both produce false negatives that look authoritative — a working site reported down. Not weaker evidence; not evidence.
The command
MSYS_NO_PATHCONV=1 docker exec playwright-vpn \
node /scripts/check-url.js https://example.com
Multiple URLs in one invocation share a browser and are much faster than
separate runs. Bare hostnames get https:// prepended.
MSYS_NO_PATHCONV=1 docker exec playwright-vpn \
node /scripts/check-url.js --timeout=20000 https://a.example b.example
| Flag | Effect |
|---|---|
--timeout=MS |
Per-URL timeout, default 15000. |
--no-screenshot |
Faster, no disk use. |
--strict-tls |
Fail on a bad cert instead of recording it. |
--label=NAME |
Label in the JSON envelope. |
--quiet |
JSON only, no stderr progress. |
Do not write a new script for an ordinary check. check-url.js takes
arguments. Writing one per URL is the old workflow and it was replaced.
MSYS_NO_PATHCONV=1 is required on Windows/Git Bash — without it the shell
rewrites /scripts/check-url.js into a Windows path before docker exec sees
it, and the container reports MODULE_NOT_FOUND on a path you never typed.
Before you run anything
cd projects/vpn-egress && bash tools/stack.sh up
Idempotent — starts the stack if down, verifies the tunnel either way, exits non-zero if egress is not via Mullvad.
You no longer have to remember to check the tunnel before each check. Every
run of check-url.js / check-domains.js confirms the browser's own egress
first and refuses to check anything if it can't: blocked: true, total: 0,
exit 2. Read the envelope's tunnel field — it records the exit IP,
country and city the result actually came from
(ADR 0004).
Read exit codes precisely, because the difference matters more than usual here:
| Exit | Meaning | What to say |
|---|---|---|
| 0 | Checked, at least one URL OK | Report the result |
| 1 | Checked, everything failed | Report it as unreachable |
| 2 | No answer obtained — blocked, or a usage error | Report that you couldn't test, never that the site is down |
Confusing 2 with 1 is how a blocked run becomes a reported outage. If a run is
blocked, fix the tunnel (vpn-egress-setup) or say you couldn't test.
check-ip.js still exists and is still the right tool when the question is
"where am I exiting from" on its own, with no URL to check:
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-ip.js
--allow-off-tunnel exists and you should essentially never reach for it. It
runs the check from the real IP, which reproduces the exact false negative this
stack exists to eliminate. Results from such a run are not evidence about
reachability, and must be labelled as off-tunnel if mentioned at all.
Testing from another country
The exit city is movable, so "does this load from Germany?" is answerable:
cd projects/vpn-egress && bash tools/exit.sh Frankfurt # switch
bash tools/exit.sh # show current + live egress
It reattaches the browsers to the new tunnel, which is mandatory rather than
tidy — they share gluetun's network namespace and are silently unreachable
without it. Every result records the country it came from in tunnel.country.
Put the city back when you're done (bash tools/exit.sh "Denver CO"), and
never switch it mid-sweep — half the results would come from somewhere else.
Which task is this?
A. A specific URL — the default
The user gives you a URL, or asks "is X reachable", or hands you a domain someone asked about. Test that URL. Don't grep a proxy config, don't look up a domain list — none of it is relevant when you already have the URL.
B. Sweeping every domain — only on "all"/"every"
A distinct, narrower task: checking every domain the user owns when they don't have the list to hand.
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-domains.js
That list is managed with tools/bookmark.sh add|list|remove (see the
duckdns-url-bookmark skill) — the same entries also render as links on the
GUI status page, so bookmarking a URL puts it in front of both the human and
the sweep.
It reads scripts/local/domains.json (gitignored) and accepts extra
domains as arguments. Any saved list goes stale — re-derive it from the
reverse proxy's config when the answer needs to be current. Whichever proxy
owns port 443 is the authority for what an outside visitor reaches with no
port typed, and it may not be the one under version control.
For one specific domain, use check-url.js.
Reading the result
stdout is a JSON envelope, stderr is human-readable. ok: true needs a
response with status < 400.
errorKind |
Means | Next step |
|---|---|---|
null, 200 |
Up and correctly routed. | Check tls.issuer if it's the user's own domain. |
null, 4xx/5xx |
Reachable; server returned an error. | Application/proxy problem, not network. |
timeout |
Nothing came back. | The user's own domain: usually TLS/ACME, below. |
refused |
Actively rejected. | Port not forwarded, or backend down — docker ps on the target. |
dns |
Doesn't resolve. | Typo, expired domain, or a dead dynamic-DNS updater. |
A 200 is not always a pass. Checks ignore TLS errors by default, so a site
with a bad certificate reports clean. Read tls.issuer: a public CA
(Let's Encrypt, Cloudflare TLS Issuing …, R11) means ACME worked.
Caddy Local Authority means the proxy fell back to its internal CA and no
real browser will trust the site. This is the most-missed failure here.
Timeout on one of the user's own proxied domains usually means the
Caddyfile is missing a tls { dns <provider> <token> } block, leaving it on
HTTP-01 issuance that can't complete because inbound 80/443 doesn't reach
Let's Encrypt's validators. Read the proxy's stderr log for the real error
before concluding. That config is typically live and unversioned — ask
before editing.
This applies only to the user's own domains behind their own proxy. A third-party URL timing out has nothing to do with any Caddyfile.
Cleaning up
Screenshots and JSON land in scripts/out/<runId>/ (gitignored). Delete them
after reporting; don't let them accumulate.
Stop the stack when done with everything that needs it:
cd projects/vpn-egress && docker compose stop
stop, not down — keeps volumes and the Playwright install so the next
start is seconds. Leave it running if the user also wants the GUI browser at
https://localhost:3081 (offer the Tailscale URL if they're testing from a
phone).
When it's still inconclusive
Ask the user to test from a phone on cellular. Don't loop on more automated attempts from this machine — every remaining path shares the network conditions that made direct testing unreliable to begin with.
Related
vpn-egress-orchestrator— many URLs across parallel subagents.vpn-egress-setup— stack won't start, or credentials need rotating.projects/vpn-egress/docs/— architecture, full troubleshooting, ADRs.