# Vpn Egress Testing

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

- Skill: `therocksss/vpn-egress-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add therocksss/vpn-egress-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/therocksss/vpn-egress-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: THEROCKSSS (https://skillmd.com/u/therocksss)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/therocksss/vpn-egress-testing

---


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

**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](../../docs/adr/0004-tunnel-gate-in-the-check-path.md)).

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:

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

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

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

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

