• The Call Stays Up

    A person at a desk faces five evening windows, one opening onto a blue-lit snowy Alpine slope.

    Sometimes I need to see a publisher page from Switzerland. I don’t need the rest of my laptop to move there.

    Looking at a page from the wrong country produces a precise diagnosis of the wrong system. Ads are local in ways websites often are not: demand, campaigns, consent flows, even the empty space where an ad should have been can change with the visitor’s IP address.

    The usual fix is a VPN, and it’s the wrong size for this job. A full tunnel moves everything: the call already in progress, SSH sessions, downloads, music, and whatever else happens to be using the network. Split tunneling exists, but it’s still a system-wide config change on a connection that may be home Wi-Fi, a phone hotspot, another VPN, or some precarious arrangement I’d rather not touch. Interrupting all of that to inspect one page is absurd.

    So I gave one Chrome window its own country.

    The Route

    The path, end to end:

    Chrome
      -> SOCKS5 on 127.0.0.1:1055
      -> SSH local port forward
      -> server loopback on 127.0.0.1:1056
      -> Tailscale userspace SOCKS5 proxy in Docker
      -> chosen Tailscale exit node
      -> website

    The laptop keeps its normal route. Only what this Chrome instance sends through --proxy-server enters the tunnel.

    The server in the middle doesn’t need to be in the country I’m testing (one DNS caveat aside); the exit node does. That split leaves two useful boundaries: SSH makes the remote proxy reachable without publishing it on the internet, and the Tailscale container turns any permitted exit node in my tailnet into a SOCKS5 endpoint.

    If the exit node itself accepts SSH and is reachable from the laptop, ssh -D can be enough. That shortcut doesn’t fit my setup: the exit node stays inside the tailnet, and the only public SSH endpoint is a small server whose container can point at whichever tailnet exit node the test requires. Running the same proxy container on the laptop would work too. I keep it on the server so the client setup stays minimal and Tailscale doesn’t run inside whatever VPN the laptop is already on — a tunnel in a tunnel. From the laptop’s side, the whole interface is one SSH alias.

    The Server: Tailscale as a SOCKS5 Container

    Tailscale has a userspace networking mode for environments without a TUN device. In that mode, tailscaled can expose a SOCKS5 proxy instead of installing routes into the host operating system. That is exactly what this setup needs: Docker runs the Tailscale node, while the host only publishes one loopback port.

    Here is the complete compose.yaml:

    yaml
    services:
      geo-proxy:
        image: tailscale/tailscale:stable
        hostname: geo-proxy
        restart: unless-stopped
        environment:
          TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in .env}
          TS_AUTH_ONCE: 'true'
          TS_HOSTNAME: ${TS_HOSTNAME:-geo-proxy}
          TS_STATE_DIR: /var/lib/tailscale
          TS_USERSPACE: 'true'
          TS_SOCKS5_SERVER: ':1055'
          TS_EXTRA_ARGS: '--exit-node=${TAILSCALE_EXIT_NODE:?Set TAILSCALE_EXIT_NODE in .env}'
        volumes:
          - tailscale-state:/var/lib/tailscale
        ports:
          - '127.0.0.1:1056:1055'
    
    volumes:
      tailscale-state:

    And the .env.example beside it:

    dotenv
    TS_AUTHKEY=tskey-auth-REPLACE_ME
    TS_HOSTNAME=geo-proxy
    TAILSCALE_EXIT_NODE=exit-node-name-or-tailscale-ip

    Copy it to .env, protect it, and start the container:

    bash
    cp .env.example .env
    chmod 600 .env
    docker compose up -d
    docker compose exec geo-proxy tailscale status

    Use a one-off auth key if possible. Otherwise, use a short-lived key and revoke it after the node has authenticated. Keep .env out of version control. While the key is valid, users with Docker access can also inspect it in the container configuration.

    The named volume preserves the node identity across restarts, and TS_AUTH_ONCE then avoids logging in again when that state already exists.

    The stable tag points at Tailscale’s current stable release when Docker pulls it. Pin a version or digest if reproducible deployments matter; pull the tag explicitly when you want an update.

    The official Docker parameters map directly to the container flags: TS_USERSPACE enables the userspace network stack, TS_SOCKS5_SERVER opens its proxy, and TS_EXTRA_ARGS passes --exit-node to tailscale up.

    The exit node must already be advertised and approved, and this container must be allowed to use it. Tailscale’s exit-node documentation covers that part. The value can be a Tailscale IP or a machine name.

    DNS does not follow the exit node by default. The proxy resolves hostnames inside tailscaled, and with TS_ACCEPT_DNS unset that means the server’s own resolver, over the server’s own route — DNS-based geo steering can still place you at the server while every TCP connection arrives from the exit node. This is the spot where the middle server’s country can leak into the test. Set TS_ACCEPT_DNS: 'true' to move resolution into the tailnet (an exit node runs a DNS server for the peers behind it), or keep the server in-region when edge selection matters.

    TS_EXTRA_ARGS is used during the first login. With TS_AUTH_ONCE and persistent state, a restart does not replay arbitrary extra arguments. To switch an existing container, update TAILSCALE_EXIT_NODE in .env and apply the same value to the running node:

    bash
    docker compose exec geo-proxy \
      tailscale set --exit-node=next-exit-node-name-or-tailscale-ip

    A window already open through the tunnel follows the switch mid-session. Close the geo Chrome first, and relaunch only after the IP check below shows the new address.

    One line in the Compose file is doing most of the security work:

    yaml
    ports:
      - '127.0.0.1:1056:1055'

    The SOCKS proxy has no reason to listen on the server’s public interface. Chrome doesn’t support SOCKS5 username/password authentication, so the practical control sits elsewhere: bind the proxy to loopback and reach it through authenticated SSH. This assumes Docker Engine 28.0.0 or newer — on older releases, Docker warns that localhost-published ports can be reached from hosts on the same L2 segment, so upgrade or add a host firewall rule before treating the port as host-only. Loopback is not per-user isolation, either: any local user on the server can reach the port. I use a single-tenant host; on a shared machine, add a stronger boundary.

    Before involving the laptop, test the path on the server:

    bash
    curl --fail --silent --show-error \
      --proxy socks5h://127.0.0.1:1056 \
      https://api.ipify.org
    echo

    The address should be the public IP of the selected exit node. The h in socks5h tells curl to send the hostname to the proxy for resolution instead of resolving it on the server first — the same thing Chrome will do once it points at this proxy.

    The Laptop: Tunnel First, Chrome Second

    The client script requires Bash, OpenSSH, curl, netcat (OpenBSD netcat or a recent ncat), grep, sed, and tr. It has three jobs:

    1. Find a real Chrome binary.
    2. Establish an SSH local forward to the server-side SOCKS port.
    3. Refuse to launch the visible Chrome window until that proxy produces the expected egress IP.

    That last rule matters most. The most expensive failure in geo-testing is a browser that silently ignores the proxy, keeps your local connection, and looks exactly like one that doesn’t.

    Here is launch-geo-chrome.sh:

    bash
    #!/usr/bin/env bash
    set -euo pipefail
    
    SSH_HOST="${GEO_SSH_HOST:?Set GEO_SSH_HOST to an SSH host or alias}"
    LOCAL_SOCKS_PORT="${GEO_LOCAL_PORT:-1055}"
    REMOTE_SOCKS_PORT="${GEO_REMOTE_PORT:-1056}"
    CDP_PORT="${GEO_CDP_PORT:-9222}"
    CONTROL_DIR="${GEO_RUNTIME_DIR:-/tmp/geo-chrome-ssh-${UID}}"
    PROFILE="${GEO_CHROME_PROFILE:-${CONTROL_DIR}/profile}"
    LOG_FILE="${CONTROL_DIR}/chrome.log"
    EXPECTED_IP="${GEO_EXPECTED_IP:?Set GEO_EXPECTED_IP to the public IP of the exit node}"
    IP_CHECK_URL="${GEO_IP_CHECK_URL:-https://api.ipify.org}"
    
    CHROME="${CHROME_BIN:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
    
    if [[ ! -x "$CHROME" ]] && command -v "$CHROME" >/dev/null 2>&1; then
      CHROME="$(command -v "$CHROME")"
    fi
    
    if [[ ! -x "$CHROME" ]]; then
      CHROME=""
      for candidate in google-chrome google-chrome-stable chromium chromium-browser; do
        if command -v "$candidate" >/dev/null 2>&1; then
          CHROME="$candidate"
          break
        fi
      done
    fi
    
    if [[ -z "$CHROME" ]]; then
      echo 'ERROR: Chrome or Chromium was not found. Set CHROME_BIN.' >&2
      exit 1
    fi
    
    is_chrome_cdp() {
      local body
      body="$(curl --fail --silent --max-time 2 \
        "http://127.0.0.1:${CDP_PORT}/json/version" 2>/dev/null)" || return 1
      grep -qiE '"Browser"[[:space:]]*:[[:space:]]*"(Headless)?Chrom(e|ium)/' <<<"$body"
    }
    
    CONTROL_PATH="${CONTROL_DIR}/%C-${LOCAL_SOCKS_PORT}-${REMOTE_SOCKS_PORT}"
    mkdir -p "$CONTROL_DIR"
    chmod 700 "$CONTROL_DIR"
    
    CREATED_TUNNEL=0
    CHROME_PID=""
    SUCCESS=0
    
    cleanup_on_exit() {
      local status=$?
      trap - EXIT INT TERM
    
      if ((SUCCESS == 0)); then
        if [[ -n "$CHROME_PID" ]]; then
          kill "$CHROME_PID" >/dev/null 2>&1 || true
        fi
        if ((CREATED_TUNNEL == 1)); then
          ssh -S "$CONTROL_PATH" -O exit "$SSH_HOST" >/dev/null 2>&1 || true
        fi
      fi
    
      exit "$status"
    }
    
    trap cleanup_on_exit EXIT INT TERM
    
    if ! ssh -S "$CONTROL_PATH" -O check "$SSH_HOST" >/dev/null 2>&1; then
      if nc -z 127.0.0.1 "$LOCAL_SOCKS_PORT" >/dev/null 2>&1; then
        echo "ERROR: local port ${LOCAL_SOCKS_PORT} is already occupied." >&2
        exit 1
      fi
    
      echo "Opening SSH tunnel on 127.0.0.1:${LOCAL_SOCKS_PORT}"
      ssh -M -S "$CONTROL_PATH" -f -N \
        -o ExitOnForwardFailure=yes \
        -o ServerAliveInterval=30 \
        -o ServerAliveCountMax=3 \
        -L "127.0.0.1:${LOCAL_SOCKS_PORT}:127.0.0.1:${REMOTE_SOCKS_PORT}" \
        "$SSH_HOST"
      CREATED_TUNNEL=1
    fi
    
    for ((attempt = 0; attempt < 20; attempt++)); do
      nc -z 127.0.0.1 "$LOCAL_SOCKS_PORT" >/dev/null 2>&1 && break
      sleep 0.25
    done
    
    if ! nc -z 127.0.0.1 "$LOCAL_SOCKS_PORT" >/dev/null 2>&1; then
      echo 'ERROR: SSH tunnel did not become ready.' >&2
      exit 1
    fi
    
    EGRESS_IP="$(curl --fail --silent --show-error --max-time 10 \
      --proxy "socks5h://127.0.0.1:${LOCAL_SOCKS_PORT}" \
      "$IP_CHECK_URL")"
    
    if [[ -z "$EGRESS_IP" ]]; then
      echo 'ERROR: proxy returned an empty egress IP.' >&2
      exit 1
    fi
    
    if [[ "$EGRESS_IP" != "$EXPECTED_IP" ]]; then
      echo "ERROR: expected egress ${EXPECTED_IP}, got ${EGRESS_IP}." >&2
      exit 1
    fi
    
    echo "Proxy ready. Egress IP: ${EGRESS_IP}"
    
    if nc -z 127.0.0.1 "$CDP_PORT" >/dev/null 2>&1; then
      echo "ERROR: CDP port ${CDP_PORT} is already occupied." >&2
      echo 'Close that browser or choose another GEO_CDP_PORT; its proxy flags cannot be trusted.' >&2
      exit 1
    fi
    
    echo 'Checking the route from Chrome itself'
    if ! BROWSER_HTML="$(
      "$CHROME" \
        --headless \
        --proxy-server="socks5://127.0.0.1:${LOCAL_SOCKS_PORT}" \
        --user-data-dir="$PROFILE" \
        --no-first-run \
        --no-default-browser-check \
        --dump-dom \
        --timeout=10000 \
        "$IP_CHECK_URL" \
        2>>"$LOG_FILE"
    )"; then
      echo "ERROR: Chrome could not load ${IP_CHECK_URL} through the proxy." >&2
      exit 1
    fi
    
    BROWSER_EGRESS="$(
      sed 's/<[^>]*>/ /g' <<<"$BROWSER_HTML" |
        tr -d '[:space:]'
    )"
    
    if [[ "$BROWSER_EGRESS" != "$EXPECTED_IP" ]]; then
      echo "ERROR: expected Chrome egress ${EXPECTED_IP}, got ${BROWSER_EGRESS:-nothing}." >&2
      exit 1
    fi
    
    "$CHROME" \
      --proxy-server="socks5://127.0.0.1:${LOCAL_SOCKS_PORT}" \
      --remote-debugging-address=127.0.0.1 \
      --remote-debugging-port="$CDP_PORT" \
      --user-data-dir="$PROFILE" \
      --no-first-run \
      --no-default-browser-check \
      --new-window about:blank \
      >>"$LOG_FILE" 2>&1 &
    
    CHROME_PID=$!
    
    for ((attempt = 0; attempt < 40; attempt++)); do
      if is_chrome_cdp; then
        echo "Chrome ready. PID ${CHROME_PID}; CDP port ${CDP_PORT}; profile ${PROFILE}"
        printf 'Stop tunnel: ssh -S %q -O exit %q\n' "$CONTROL_PATH" "$SSH_HOST"
        SUCCESS=1
        exit 0
      fi
      sleep 0.25
    done
    
    echo "ERROR: Chrome did not start. See ${LOG_FILE}" >&2
    exit 1

    Run it with the server’s SSH alias and the expected public IP:

    bash
    chmod +x launch-geo-chrome.sh
    read -r -p 'Exit-node public IP: ' GEO_EXPECTED_IP
    GEO_SSH_HOST=geo-gateway \
    GEO_EXPECTED_IP="$GEO_EXPECTED_IP" \
    ./launch-geo-chrome.sh

    Enter the address printed by the server-side check — or better, one confirmed from the exit node itself, out of band: its provider console, or a curl run on the node. The expected value has to come from outside the tunnel; an address derived through the same proxy would also bless a correctly functioning route through the wrong exit node.

    The script uses an SSH control socket, so repeated runs can reuse the tunnel it created. The short, user-specific /tmp path keeps the expanded control-socket name below macOS’s limit on Unix-socket path length and avoids collisions with other local users. The script also refuses to open a new tunnel on an occupied SOCKS port. If an earlier master died uncleanly and left its socket behind, ssh falls back to a plain connection — the tunnel still works, but the printed stop command finds no master. Delete the stale socket from the control directory and rerun.

    A run that reuses the master still has to pass both egress checks; the script never trusts the control socket alone. It checks the proxy with curl, then uses Chrome’s documented headless --dump-dom mode with the same binary, profile, and proxy flag against the IP endpoint. Only after Chrome itself returns GEO_EXPECTED_IP does the visible window start. If a later check fails — or the run is interrupted — the trap stops a tunnel created by that run and sends TERM to the Chrome process it started.

    The SSH master keeps running after the launcher exits. The script prints the exact command that stops it. With the example alias and default ports, that command is:

    bash
    ssh -S "/tmp/geo-chrome-ssh-${UID}/%C-1055-1056" \
      -O exit geo-gateway

    To stop the server-side proxy without deleting its identity, run docker compose down. To retire it for good, run docker compose down --volumes, remove the node from the Tailscale admin console, and revoke the auth key if it is still valid.

    The local SOCKS port carries the same caveat as the server’s: loopback, not per-user isolation. While the tunnel is open, other local users on the laptop can reach it; on a shared machine, use OS-level isolation rather than treating the port binding as an access boundary.

    Why a Separate Chrome Profile

    Chrome normally reuses its existing process. Starting the application again with a new flag doesn’t reliably give that flag to a new window, so you keep testing the route you already had.

    --user-data-dir forces a separate instance with its own cookies and cache. It’s also required for current Chrome remote debugging: since Chrome 136, --remote-debugging-port is ignored for the default data directory. Chrome’s own remote-debugging guidance recommends a non-standard profile for this reason.

    The debugging port is optional for the network route, but useful for test tooling. A script can connect to http://127.0.0.1:9222, create a tab, navigate, collect network events, take a screenshot, or inspect page state through the Chrome DevTools Protocol. None of that requires Puppeteer’s bundled Chromium pretending to be the browser I’m actually looking at — the visible Chrome window is the test browser. CDP has no authentication; headful Chrome serves it only on loopback, and local processes can still control this profile while it’s running.

    The profile is intentionally disposable, but the default path is reused until you delete it. It should not contain the cookies, sessions, or extensions from the everyday browser unless the test explicitly needs them. Set GEO_CHROME_PROFILE to a new directory for a clean run; close Chrome and delete old profiles when their state is no longer useful.

    What Actually Goes Through the Proxy

    Chromium accepts a process-level proxy with --proxy-server. For a SOCKS5 proxy, target hostnames are resolved on the proxy side, not by Chrome’s normal host resolver.

    The boundary is still a browser proxy, not a second network interface. Chromium documents SOCKS5 as carrying TCP-based URL requests only — arbitrary UDP traffic never enters it. Other applications don’t use it either, which is why the call, terminal, package download, and system VPN continue along their existing routes.

    This is not an anonymity tunnel, and it doesn’t replace a VPN. Anything the proxy doesn’t carry can take another path — UDP-based peer-to-peer features such as WebRTC deserve their own test, since they may use the laptop’s normal route. A system or namespace-level tunnel is still the right tool when the test includes native applications, UDP, or several cooperating processes. This setup is for the case where the thing that needs a location is a browser.

    The whole setup exists because changing location shouldn’t be a laptop-wide event. I can open the page, watch the real ad path from the right market, close the window, and leave every other connection alone. The call stays up, and only the one Chrome window moved.