---
name: vpn-egress-testing
description: "General-purpose outside-the-network browser for testing ANY url -- your own, someone else's, one you were asked to check -- from a genuine external vantage point, using the Mullvad-tunneled browser stack in projects/vpn-egress. Use whenever asked to check if a site/URL is reachable, verify a domain is up, or test something 'from outside the network' -- and NEVER trust a direct request from the local shell or WebFetch for this (both give false negatives on this network). Sweeping every domain you own is ONE use case this supports, not the default mode -- see the branch below. For many URLs across parallel subagents, use vpn-egress-orchestrator; for a stack that won't start, vpn-egress-setup."
---

# 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:

- `WebFetch` gets `ECONNREFUSED` on 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

```bash
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.

```bash
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

```bash
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. Then confirm the *browser* is on the
tunnel too:

```bash
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-ip.js
```

These check different things and can disagree: `stack.sh health` tests
gluetun's egress, `check-ip.js` tests the browser's. A browser that started
before the tunnel came up holds stale routes. **`check-ip.js` is the one that
describes where your results actually came from.**

If it reports `ok: false` — stop. Fix it (`vpn-egress-setup`) or say you
couldn't test. Never report a result from an off-tunnel browser.

## 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.

```bash
MSYS_NO_PATHCONV=1 docker exec playwright-vpn node /scripts/check-domains.js
```

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:

```bash
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.
