MacOS, два VPN одновременно

Суть проблемы

Дано: macOS, на которой одновременно нужны два VPN.

  • Личный VPN через Xray-клиент (например Happ) для доступа в интернет.
  • Корпоративный VPN через Cisco Secure Client (AnyConnect) для доступа к внутренним корпоративным сервисам: GitLab, почте и т.д.

Проблема: как только включается личный VPN, доступ к корпоративным сервисам пропадает. Приходится постоянно переключаться между туннелями.

Причина в том, что Xray-клиент в режиме VPN поднимает свой TUN-интерфейс и перехватывает весь трафик системы включая пакеты, которые должны уходить в корпоративный туннель. Запросы к внутреннему GitLab отправляются через личный VPN наружу, где корпоративная сеть недоступна.

Вторая, менее очевидная часть проблемы — это DNS. Внутренние домены компании резолвятся только корпоративными DNS-серверами, которые AnyConnect подключает как scoped-резолверы. Xray-клиент перехватывает DNS-запросы и отправляет их публичным серверам, а там внутренних имён нет.

Цель: сделать так, чтобы оба VPN работали одновременно, корпоративные сервисы были доступны, а весь остальной трафик шёл через личный VPN.

Диагностика

Здесь важно отметить одну особенность macOS: разные утилиты резолвят имена по-разному.

  • nslookup и dig ходят на DNS-серверы напрямую, игнорируя /etc/hosts и scoped-резолверы системы.
  • git, браузеры и остальные обычные приложения используют системный резолвер (mDNSResponder), который учитывает и hosts, и scoped-резолверы.
  • Честный способ спросить системный резолвер из терминала:
dscacheutil -q host -a name gitlab.corp.example.ru

Из-за этого возможна ситуация «nslookup показывает NXDOMAIN, а git работает» и наоборот. В моём случае диагностика выглядела так (личный VPN выключен, AnyConnect включён):

## nslookup не знает про scoped-резолверы AnyConnect:
nslookup gitlab.corp.example.ru
## ** server can't find gitlab.corp.example.ru: NXDOMAIN

## А системный резолвер знает:
dscacheutil -q host -a name gitlab.corp.example.ru
## ip_address: 10.0.200.5

Вывод: домен внутренний, резолвится только корпоративными DNS. Их адреса можно посмотреть так:

scutil --dns | grep nameserver

В выводе будут адреса, которые пушит AnyConnect (обычно из диапазона 10.x.x.x), например 10.0.100.53 и 10.0.100.54.

Ещё один полезный инструмент — это tcpdump. Помогает видеть, куда физически уходят DNS-запросы (и уходят ли вообще):

sudo tcpdump -i any -n port 53

Пошаговое решение

Итоговая схема состоит из двух частей: маршрутизация (direct-правила Xray-клиента) и DNS (файл /etc/hosts + скрипт для его обновления).

1. Direct-правила в Xray-клиенте

В Happ: Настройки → Маршрутизация → профиль → Direct. В других клиентах (v2rayN, Nekoray, sing-box) настройка называется похожим образом.

Добавляем приватные подсети и, при желании, корпоративный домен:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
corp.example.ru

Смысл: трафик к этим адресатам Xray выпускает из своего туннеля в систему, где его подхватывает маршрут AnyConnect.

Важно. Порядок проверки правил должен быть таким, чтобы Direct проверялся до Proxy (например, Block → Direct → Proxy), и после изменения правил нужно перезапустить туннель, то есть выключить и включить VPN.

2. Узнаём IP внутренних сервисов

При включённом AnyConnect (личный VPN выключен):

dscacheutil -q host -a name gitlab.corp.example.ru
## ip_address: 10.0.200.5

3. Прописываем в /etc/hosts

sudo sh -c 'echo "10.0.200.5 gitlab.corp.example.ru" >> /etc/hosts'

Теперь имя превращается в IP без DNS-запросов. IP попадает под правило 10.0.0.0/8 из direct-списка и уходит в корпоративный туннель.

На этом этапе схема уже работает: оба VPN включены, GitLab доступен, интернет идёт через личный VPN.

4. Скрипт для управления списком хостов

Внутренних корпоративных сервисов обычно больше одного, а руками править hosts надоедает. Скрипт держит список хостов, спрашивает их адреса напрямую у корпоративных DNS (это работает при обоих включённых VPN, запросы к 10.x уходят по direct-правилам) и обновляет свой блок в /etc/hosts:

mkdir -p ~/bin
cat > ~/bin/corp-hosts.sh << 'EOF'
#!/bin/zsh
## Обновляет блок корпоративных хостов в /etc/hosts.
## Запуск: sudo ~/bin/corp-hosts.sh  (корпоративный VPN должен быть включён)

## ── Список сервисов: добавляй хосты сюда ──
HOSTS=(
  gitlab.corp.example.ru
  mail.corp.example.ru
)

## ── Корпоративные DNS (см. scutil --dns) ──
DNS=(10.0.100.53 10.0.100.54)

BEGIN="## BEGIN corp-hosts"
END="## END corp-hosts"

newblock=$(mktemp)
echo "$BEGIN" > "$newblock"

for h in $HOSTS; do
  ip=""
  for d in $DNS; do
    ip=$(dig +short +time=2 +tries=1 @$d $h A 2>/dev/null | grep -E '^[0-9]+\.' | head -1)
    [[ -n "$ip" ]] && break
  done
  if [[ -n "$ip" ]]; then
    echo "$ip $h" >> "$newblock"
    echo "✓ $h → $ip"
  else
    echo "✗ $h не резолвится — пропускаю (VPN включён?)" >&2
  fi
done

echo "$END" >> "$newblock"

tmp=$(mktemp)
sed "/^$BEGIN/,/^$END/d" /etc/hosts > "$tmp"
cat "$newblock" >> "$tmp"
cat "$tmp" > /etc/hosts
rm "$tmp" "$newblock"

dscacheutil -flushcache
killall -HUP mDNSResponder
echo "Готово: /etc/hosts обновлён."
EOF
chmod +x ~/bin/corp-hosts.sh

Запуск:

sudo ~/bin/corp-hosts.sh

Таким образом рутина сводится к двум сценариям:

  • Новый внутренний сервис → дописать хост в список HOSTS → перезапустить скрипт.
  • Сервис отвалился (переехал на другой IP) → просто перезапустить скрипт.

5. Проверка

## Git отвечает?
git ls-remote https://gitlab.corp.example.ru/group/project.git

## Остальной трафик всё ещё в личном VPN?
## Открываем 2ip.ru — должен быть IP личного VPN.

Нюанс: ping внешних хостов при включённом Xray-туннеле будет показывать 100% packet loss, это нормально. Xray переносит TCP/UDP, а ICMP через него не ходит. Проверять доступность нужно браузером или curl, а не пингом.

Итог

Схема из трёх компонентов:

  • Direct-правила в Xray-клиенте выпускают трафик к приватным подсетям из личного туннеля;
  • AnyConnect подхватывает этот трафик своими маршрутами;
  • /etc/hosts + скрипт решают DNS для внутренних имён без участия DNS.

Результат: оба VPN работают одновременно. Добавление нового корпоративного сервиса — это одна строка в скрипте и один запуск.