• Звонок не прервётся

    Человек за столом смотрит на пять вечерних окон, одно из которых выходит на освещённый синим альпийский склон.

    Иногда мне нужно посмотреть страницу издателя так, как её видят из Швейцарии. Перемещать туда весь остальной ноутбук незачем.

    Если открыть страницу не из той страны, можно очень точно диагностировать не ту систему. Реклама зависит от географии даже тогда, когда сам сайт от неё не зависит: с IP-адресом посетителя меняются спрос, кампании, консент-флоу и даже пустое место там, где должна была быть реклама.

    Обычно для этого включают VPN — и для такой задачи он великоват. Полный туннель перемещает всё: уже идущий звонок, SSH-сессии, загрузки, музыку и всё остальное, что сейчас пользуется сетью. Бывает split tunneling, но это всё равно перестройка всей системы — а соединение в этот момент может быть домашним Wi-Fi, раздачей с телефона, другим VPN или какой-нибудь шаткой конструкцией, которую лучше не трогать. Прерывать всё это ради проверки одной страницы — абсурд.

    Поэтому я выдал одному окну Chrome собственную страну.

    Маршрут

    Маршрут целиком:

    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

    Ноутбук продолжает ходить своим обычным маршрутом. В туннель попадает только то, что этот экземпляр Chrome отправляет через --proxy-server.

    Сервер посередине не обязан находиться в стране, из которой я тестирую (с одной оговоркой про DNS); exit node — обязана. Это разделение даёт две полезные границы: SSH делает удалённый прокси доступным, не публикуя его в интернете, а контейнер Tailscale превращает в SOCKS5-эндпоинт любую разрешённую exit node моего tailnet.

    Если exit node сама принимает SSH-соединения и доступна с ноутбука, может хватить ssh -D. В мою схему этот короткий путь не вписывается: exit node остаётся внутри tailnet, а снаружи по SSH доступен только небольшой сервер — и уже его контейнер наводится на ту exit node, которая нужна тесту. Тот же прокси-контейнер можно поднять и на ноутбуке. Я держу его на сервере, чтобы клиентская часть оставалась минимальной и не приходилось городить туннель в туннеле — Tailscale поверх VPN, к которому ноутбук уже подключён. Со стороны ноутбука весь интерфейс — один SSH-алиас.

    Сервер: Tailscale как SOCKS5-контейнер

    У Tailscale есть сетевой режим userspace для окружений без TUN-устройства. В нём tailscaled может поднять SOCKS5-прокси вместо того, чтобы добавлять маршруты в операционную систему хоста. Именно это здесь и нужно: Docker запускает ноду Tailscale, а хост публикует только один loopback-порт.

    Полный 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:

    Рядом лежит .env.example:

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

    Скопируйте его в .env, ограничьте права и запустите контейнер:

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

    По возможности используйте одноразовый auth key. Иначе возьмите короткоживущий ключ и отзовите его после аутентификации ноды. Не добавляйте .env в систему контроля версий. Пока ключ действует, пользователи с доступом к Docker могут подсмотреть его и в конфигурации контейнера.

    Именованный volume сохраняет идентификатор ноды между перезапусками, а TS_AUTH_ONCE избавляет от повторного входа, когда это состояние уже есть.

    При загрузке образа тег stable указывает на текущий стабильный релиз Tailscale. Для воспроизводимых развёртываний зафиксируйте версию или digest; когда понадобится обновление, явно скачайте stable заново.

    Официальные параметры Docker напрямую соответствуют флагам контейнера: TS_USERSPACE включает сетевой стек в userspace, TS_SOCKS5_SERVER поднимает прокси, а TS_EXTRA_ARGS передаёт --exit-node в tailscale up.

    Exit node уже должна быть анонсирована и одобрена, а этому контейнеру нужно разрешить её использовать. Эта часть описана в документации Tailscale по exit node. Значением может быть Tailscale IP или имя машины.

    DNS за exit node по умолчанию не переезжает. Прокси резолвит имена хостов внутри tailscaled, и без TS_ACCEPT_DNS это значит: резолвером самого сервера, по его обычному маршруту. GeoDNS может по-прежнему считать, что вы там, где сервер, хотя каждое TCP-соединение приходит с exit node. Именно здесь страна сервера-посредника может просочиться в тест. Включите TS_ACCEPT_DNS: 'true', чтобы перенести резолвинг в tailnet (exit node поднимает DNS-сервер для нод за ней), либо держите сервер в нужном регионе, когда выбор edge-серверов важен.

    TS_EXTRA_ARGS используется при первом входе. При TS_AUTH_ONCE и сохранённом состоянии перезапуск не прогоняет дополнительные аргументы заново. Чтобы переключить существующий контейнер, обновите TAILSCALE_EXIT_NODE в .env и примените то же значение к запущенной ноде:

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

    Окно, уже открытое через туннель, переедет вместе с переключением — прямо посреди сессии. Сначала закройте гео-Chrome, а перезапускайте его только после того, как проверка IP ниже покажет новый адрес.

    Основную работу по безопасности в Compose-файле делает одна строка:

    yaml
    ports:
      - '127.0.0.1:1056:1055'

    SOCKS-прокси незачем слушать публичный интерфейс сервера. Chrome не поддерживает аутентификацию SOCKS5 по логину и паролю, поэтому реальная защита в другом: привязать прокси к loopback и ходить к нему через аутентифицированный SSH. Здесь нужен Docker Engine 28.0.0 или новее: в старых релизах, предупреждает Docker, опубликованный на localhost порт бывает доступен хостам в том же L2-сегменте, поэтому обновите Docker или добавьте правило файрвола на хосте, прежде чем считать порт доступным только с сервера. Loopback к тому же не изолирует пользователей друг от друга: любой локальный пользователь сервера может подключиться к этому порту. У меня на этом сервере соседей нет; на общей машине понадобится более строгая граница.

    Прежде чем подключать ноутбук, проверьте маршрут на сервере:

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

    Команда должна вывести публичный IP выбранной exit node. Буква h в socks5h означает, что имя хоста резолвит сам прокси, а не сервер, — позже ровно так же поступит и Chrome.

    Ноутбук: сначала туннель, потом Chrome

    Клиентскому скрипту нужны Bash, OpenSSH, curl, netcat (OpenBSD netcat или свежий ncat), grep, sed и tr. У него три задачи:

    1. Найти настоящий бинарник Chrome.
    2. Поднять локальный SSH-проброс до SOCKS-порта на сервере.
    3. Не запускать видимое окно Chrome, пока прокси не покажет ожидаемый внешний IP.

    Последнее правило — самое важное. Дороже всего в геотестировании обходится браузер, который молча проигнорировал прокси, ходит через обычное соединение и выглядит ровно так же, как браузер, у которого всё работает.

    Вот 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

    Запустите его с SSH-алиасом сервера и ожидаемым публичным 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

    Введите адрес, который показала проверка на сервере, — а лучше сверенный с самой exit node по независимому каналу: через консоль провайдера или curl прямо на ноде. Ожидаемое значение должно браться не из туннеля: адрес, полученный через тот же прокси, заодно благословил бы и правильно работающий маршрут через неправильную exit node.

    Скрипт использует управляющий сокет SSH, поэтому при повторных запусках подхватывает уже созданный туннель. Короткий пользовательский путь в /tmp не даёт развёрнутому имени сокета упереться в лимит macOS на длину пути Unix-сокета и не пересекается с путями других локальных пользователей. Открывать новый туннель на занятом SOCKS-порту скрипт тоже отказывается. Если прежний master умер, не убрав за собой сокет, ssh откатится к обычному соединению — туннель заработает, но напечатанная команда остановки не найдёт master-процесс. Удалите протухший сокет из каталога с сокетами и запустите скрипт снова.

    Даже если master уже был запущен, скрипт всё равно проходит обе проверки внешнего IP: на один только управляющий сокет он не полагается. Сначала он проверяет прокси через curl, затем прогоняет тот же бинарник Chrome в документированном headless-режиме с --dump-dom — с тем же профилем и флагом прокси, по тому же IP-эндпоинту. Видимое окно запускается, только когда сам Chrome вернул GEO_EXPECTED_IP. Если одна из проверок не прошла — или работу прервали, — trap закроет туннель, созданный этим запуском, и отправит TERM поднятому им процессу Chrome.

    SSH master продолжает работать после завершения лаунчера. Скрипт печатает точную команду, которая его остановит. С алиасом из примера и портами по умолчанию это:

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

    Чтобы остановить серверный прокси, не удаляя его идентификатор, выполните docker compose down. Чтобы убрать его насовсем — docker compose down --volumes, затем удалите ноду из админки Tailscale и отзовите auth key, если он ещё действует.

    У локального SOCKS-порта та же оговорка, что и у серверного: это loopback, а не изоляция пользователей. Пока туннель открыт, другие локальные пользователи ноутбука могут к нему подключаться; на общей машине полагайтесь на изоляцию средствами ОС, а не на привязку порта.

    Зачем отдельный профиль Chrome

    Обычно Chrome переиспользует уже запущенный процесс. Повторный запуск приложения с новым флагом не гарантирует, что новое окно получит этот флаг, — и вы продолжаете тестировать прежний маршрут.

    --user-data-dir заставляет Chrome запустить отдельный экземпляр со своими cookies и кешем. Этот флаг также нужен для remote debugging в актуальном Chrome: начиная с Chrome 136, --remote-debugging-port игнорируется для каталога данных по умолчанию. Поэтому сам Chrome в рекомендации по remote debugging советует нестандартный профиль.

    Порт отладки не нужен самому сетевому маршруту, но полезен для инструментов тестирования. Скрипт может подключиться к http://127.0.0.1:9222, создать вкладку, перейти по адресу, собрать сетевые события, сделать скриншот или проверить состояние страницы через Chrome DevTools Protocol. Для этого не нужен встроенный Chromium из Puppeteer, который притворяется браузером, на который я смотрю, — видимое окно Chrome и есть тестовый браузер. У CDP нет аутентификации; headful Chrome отдаёт его только на loopback, и локальные процессы всё равно могут управлять профилем, пока он запущен.

    Профиль задуман как временный, но путь по умолчанию переиспользуется, пока вы его не удалите. Не стоит переносить в него cookies, сессии или расширения из повседневного браузера, если они не нужны для конкретного теста. Для чистого запуска укажите в GEO_CHROME_PROFILE новый каталог; когда старое состояние больше не нужно, закройте Chrome и удалите старые профили.

    Что на самом деле проходит через прокси

    Chromium принимает прокси на уровне процесса через --proxy-server. При использовании SOCKS5 имена хостов резолвит прокси, а не обычный резолвер Chrome.

    Границей по-прежнему остаётся браузерный прокси, а не второй сетевой интерфейс. По документации Chromium, SOCKS5 переносит только TCP-запросы к URL — произвольный UDP в него не попадает. Остальные приложения им и не пользуются, поэтому звонок, терминал, загрузка пакетов и системный VPN продолжают ходить своими прежними маршрутами.

    Это не туннель для анонимности и не замена VPN. Всё, что прокси не переносит, может пойти другим путём — peer-to-peer-функциям поверх UDP, вроде WebRTC, нужна отдельная проверка: они могут использовать обычный маршрут ноутбука. Туннель на уровне системы или namespace по-прежнему нужен, когда в тесте участвуют нативные приложения, UDP или несколько взаимодействующих процессов. Эта схема — для случая, когда местоположение нужно браузеру.

    Вся схема существует потому, что смена страны не должна затрагивать весь ноутбук. Я могу открыть страницу, посмотреть настоящий рекламный путь с нужного рынка, закрыть окно и не тронуть остальные соединения. Звонок не прервётся — переедет только одно окно Chrome.