# vpnchecker
[English version](README.md)
VPN kill switch для macOS, сделан под GlobalProtect (работает с любым VPN, создающим интерфейс `utunN`). Гарантирует, что **Claude, Cursor и Rider выходят в интернет только через VPN**, и по умолчанию так же ограничивает всю машину (`mode: full`); `mode: scoped` ограничивает только эти три приложения.
| `mode` | Что происходит, пока VPN отключён |
|---|---|
| `scoped` | Затронуты только Claude, Cursor и Rider. Всё остальное, включая переподключение GlobalProtect, captive-порталы и split-tunnel трафик, работает как обычно. |
| `full` (по умолчанию) | Дополнительно блокируется **весь** исходящий трафик, кроме самого VPN-клиента (портал / шлюзы) и локальной сети. |
Защита Claude / Cursor / Rider **включена всегда**, в обоих режимах. Она состоит из трёх слоёв, чтобы ни один из них не должен быть идеальным:
1. **Заморозка** – все процессы Claude / Cursor / Rider и все их потомки (MCP-серверы, терминалы, extension host) замораживаются `SIGSTOP` (или убиваются), как только VPN падает, и размораживаются, когда VPN снова подтверждён. Замороженное приложение ничего не отправит.
2. **DNS shield** – запросы к `anthropic.com`, `claude.ai`, `claude.com`, `cursor.sh`, `cursor.com` обрабатываются локальным ответчиком через файлы резолвера по доменам в `/etc/resolver`. Запрос не покидает Mac, поэтому провайдер / роутер не видит, каким AI-сервисом вы собирались воспользоваться.
3. **Блокировка в ядре** – правило [pf](https://man.openbsd.org/pf.conf) отклоняет известные адреса серверов Claude / Cursor на любом интерфейсе, кроме туннеля. В отличие от заморозки окна реакции нет: оно закрывает и первые пакеты после пробуждения, до того как демон успел отработать.
Слои 2 и 3 знают только серверы Claude / Cursor. Rider (JetBrains) защищён только заморозкой: это IDE, которая ходит в ваши собственные репозитории и сервисы, поэтому его хосты по умолчанию не экранируются и не блокируются. При желании добавьте их в `protect.domains` / `protect.hosts`.
## Как это работает
Демон работает как root-демон launchd, стартует при загрузке и перезапускается, если завершился. Он реагирует на изменения маршрутов / адресов / интерфейсов в ядре (`PF_ROUTE` socket) за миллисекунды и опрашивает состояние раз в `check.interval` как запасной вариант.
**Состояние VPN.** VPN поднят, если интерфейс по `interface_pattern` имеет адрес из `vpn.subnets` **и** маршрут по умолчанию идёт через него (GlobalProtect оставляет `utunN` с адресом из пула после отключения, одного интерфейса недостаточно) **и** GlobalProtect не сообщает об отключённом туннеле. Собственное состояние GlobalProtect читается из plist настроек, принадлежащего root (`tunnel-status`, текущий шлюз и адрес туннеля); учитывается только явное «disconnected», поэтому неизвестное значение само по себе никогда не заморозит приложения.
**Сон / пробуждение.** Пока Mac спит, ничего не работает, а сразу после пробуждения GlobalProtect может ещё показывать мёртвый туннель как поднятый. Демон замечает скачок часов (`protect.wake_gap`), сначала замораживает, держит приложения замороженными не меньше `protect.wake_hold` и размораживает только после `protect.resume_checks` подряд успешных проверок, растянутых по времени. Поэтому каждое пробуждение стоит короткой паузы, даже если VPN в порядке. Блокировка в ядре (слой 3) покрывает несколько сотен миллисекунд до первой проверки демона.
**Автообнаружение шлюзов.** Демон узнаёт шлюзы VPN из пяти источников, чтобы GlobalProtect всегда мог переподключиться, в том числе к **новому** шлюзу после реконнекта (важно в режиме `full` и чтобы шлюзы не попадали в защищаемый набор):
- шлюз, который GlobalProtect сообщает для текущего соединения (plist под root; DNS не нужен, адрес разрешается в момент смены);
- имена портала и шлюзов из настроек GlobalProtect и `pan_gp_event.log`, перерезолвятся каждые `resolve_interval`, сразу после падения VPN и при каждой смене шлюза, о которой сообщает GlobalProtect;
- host-маршруты, которые ядро ставит для шлюза в обход туннеля (`allow.learn_gateways`);
- в режиме `full` любой запрос под `allow.gateway_suffixes` (по умолчанию `gpcloudservice.com`) и к `allow.probe_hosts` (по умолчанию `captive.apple.com`, `clients3.google.com` и `www.msftconnecttest.com`, три хоста проверки captive-портала в GlobalProtect) идёт через DNS-форвардер, поэтому ровно тот адрес, который получил VPN-клиент, разрешается до того, как клиент к нему подключится. Это покрывает шлюзы, которыми клиент ещё не пользовался, меняющиеся адреса и саму проверку: две отклонённые проверки GlobalProtect принимает за captive-портал и ждёт вместо подключения;
- `allow.domains` / `allow.cidrs`, которые вы добавили сами. Каждый домен покрывает **все свои поддомены**: демон поднимает DNS-форвардер на `127.0.0.1:10053` и пишет `/etc/resolver/<домен>` для каждой записи, поэтому любой запрос под этим доменом идёт через форвардер, а адреса из ответа попадают в pf-таблицу allowlist *до того*, как ответ получит приложение. Вышестоящий DNS выбирается для каждого запроса из `scutil --dns` (split DNS VPN продолжает работать).
Каждый адрес хранится `resolve_retain` и сохраняется в `/var/db/vpnchecker/cache.json`. Имена хостов, найденные только в доступном пользователю на запись логе, принимаются, только если оканчиваются на один из `allow.gateway_suffixes` (по умолчанию `gpcloudservice.com`), совпадают с порталом или со шлюзом, о котором сообщает сам GlobalProtect.
**Правила режима `full`** (якорь `com.apple/vpnchecker`; стандартный `/etc/pf.conf` уже подключает `com.apple/*`, системный конфиг не меняется):
```
pass out quick on lo0 all flags any no state # localhost, намеренно без состояния
pass out quick on utun4 all keep state (if-bound) # туннель
pass out quick proto { tcp udp } to any port 53 keep state # DNS (системный резолвер)
pass out quick proto udp to any port { 67 68 123 546 547 } keep state
pass out quick inet6 proto icmp6 all keep state
pass out quick to <vpnchecker_allow> keep state # портал/шлюзы VPN, LAN
block return out quick all
```
Правило для loopback не хранит состояние, потому что на `lo0` ответ тоже уходит «наружу». Правило с состоянием совпадает там только с первым пакетом TCP-соединения, а ответ сбрасывался бы финальной блокировкой: это любое локальное TCP-соединение, включая окно GlobalProtect, которое общается со своим сервисом на `127.0.0.1:4767`.
**Правила режима `scoped`:**
```
table <vpnchecker_protected> persist { …адреса серверов Claude / Cursor… }
pass out quick on utun4 to <vpnchecker_protected> keep state (if-bound)
block return out quick to <vpnchecker_protected>
```
**Переживает собственный выход.** По `SIGTERM` / `SIGINT` демон оставляет правила, пропускающие только трафик *с адресов `vpn.subnets`*, замороженные приложения остаются замороженными, DNS shield остаётся; launchd перезапускает демон, а `vpnchecker stop` освобождает всё. При падении или `SIGKILL` в pf остаются последние загруженные правила.
**Обновления.** При `update.auto: true` (по умолчанию) установленный демон раз в `update.interval` (24h) смотрит GitHub-релизы этого репозитория, и только пока VPN подтверждён, поэтому загрузка идёт через туннель, а перезапуск никогда не происходит, пока приложения заблокированы. Найдя более новый, он **сначала спрашивает** (`update.confirm: true`, по умолчанию): диалог в вашей сессии предлагает *Update*, *Later* (спросит снова через `update.interval`) и *Skip This Version* (не спрашивать, пока не выйдет ещё более новый релиз). Без согласия ничего не скачивается; диалог без ответа закрывается через пять минут и считается отложенным, а если никто не вошёл в систему, ничего не устанавливается. `update.confirm: false` ставит без вопросов. После согласия релиз скачивается в каталог, доступный только root, сверяется с файлом SHA-256 из релиза, должен сообщить объявленную версию и принять текущий конфиг. Только после этого он заменяет бинарник через rename; демон завершается как по `SIGTERM`, оставляя exit-правила, и launchd за секунды запускает новую версию. Пре-релизные теги игнорируются, понижения версии не бывает. `update.auto: false` отключает это; `sudo vpnchecker update` обновляет по требованию, `vpnchecker update --check` только сообщает.
## Требования
- macOS (Apple Silicon или Intel)
- `sudo` для установки (pf, `/etc/resolver` и отправка сигналов процессам требуют root)
## Установка
Подключите VPN и выполните:
```sh
curl -fsSL https://github.com/pwyllcrusader/vpnchecker/releases/latest/download/install.sh | sh
```
Чтобы ограничивать только Claude / Cursor / Rider и не трогать остальную машину, передайте режим скрипту:
```sh
curl -fsSL https://github.com/pwyllcrusader/vpnchecker/releases/latest/download/install.sh | sh -s -- --mode scoped
```
Скрипт скачивает готовый универсальный бинарник из последнего GitHub-релиза, проверяет контрольную сумму, создаёт `~/.config/vpnchecker/config.yaml`, если его нет, отказывается продолжать, если VPN не обнаружен (иначе пропал бы интернет), выполняет `sudo vpnchecker install` и создаёт ссылку `/usr/local/bin/vpnchecker`. `--mode scoped|full` (или `VPNCHECKER_MODE`) записывает `mode` в конфиг, в том числе в уже существующий; без него новый конфиг начинает с `full`. Дальше демон сам предлагает новые релизы (см. **Обновления** выше); `sudo vpnchecker update` или та же команда обновляют сразу. Если пул адресов VPN не `10.0.0.0/8`, укажите `vpn.subnets` в конфиге и запустите ещё раз.
### Из исходников
```sh
cargo install vpnchecker # или из исходников: cargo install --path .
# 1. Создать конфиг и заполнить vpn.subnets (см. Конфигурация)
vpnchecker config init
$EDITOR ~/.config/vpnchecker/config.yaml
vpnchecker config validate
# 2. Проверить детекцию при подключённом VPN
vpnchecker diagnose
vpnchecker procs # процессы Claude / Cursor / Rider, которые будут заморожены
# 3. Установить как launchd-демон
sudo "$(which vpnchecker)" install
vpnchecker status
```
`install` копирует **запущенный** бинарник в `/Library/PrivilegedHelperTools/vpnchecker` (владелец `root:wheel`, обычный пользователь не сможет его подменить), пишет `/Library/LaunchDaemons/com.vpnchecker.daemon.plist` и запускает демон. Он стартует при загрузке и перезапускается, если завершился. С `sudo` используйте полный путь — у sudo свой `PATH`. После каждого обновления повторяйте `install`.
## Команды
| Команда | Root | Описание |
|---|---|---|
| `install` / `uninstall` | да | Установить / удалить launchd-демон (`uninstall` также убирает правила pf, размораживает приложения и удаляет DNS shield) |
| `start` / `restart` | да | Запустить / перезапустить установленный демон |
| `stop [--keep-block]` | да | Остановить демон, убрать правила pf (`--keep-block` оставляет exit-правила), разморозить приложения, удалить DNS shield |
| `flush` | да | Сразу убрать правила pf vpnchecker |
| `status [--json]` | нет | Состояние VPN, шлюз GlobalProtect, режим, состояние защиты, замороженные процессы, DNS shield |
| `check [-q]` | нет | Проверить VPN один раз. Код возврата: `0` поднят, `1` отключён, `2` ошибка |
| `procs` | нет | Список охваченных процессов Claude / Cursor / Rider (STAT `T` = заморожен) |
| `diagnose` | нет | Интерфейсы, проверки, соединение GlobalProtect, защищаемые серверы, разрезолвленные домены allowlist, найденные шлюзы |
| `reload` | владелец | Перечитать конфиг в работающем демоне (то же, что `SIGHUP`) |
| `allow <домен\|URL>` | владелец | Добавить домен (с поддоменами) в `allow.domains` конфига, сохранив комментарии, и перечитать конфиг в демоне |
| `update [--check]` | да (нет с `--check`) | Установить последний релиз сейчас и перезапустить демон; `--check` только сообщает, есть ли новая версия |
| `logs [-f] [-n N]` | нет | Показать `/var/log/vpnchecker.log` |
| `config init [--force]` / `validate` / `path` | нет | Управление файлом конфигурации |
| `run [--dry-run]` | да (нет с `--dry-run`) | Запуск на переднем плане. `--dry-run` пишет в лог правила и то, что было бы заморожено, вместо действий |
Глобальные опции: `--config <path>` (env `VPNCHECKER_CONFIG`), `--socket <path>` (по умолчанию `/var/run/vpnchecker.sock`), `-v`/`-vv`.
## Конфигурация
Путь: `~/.config/vpnchecker/config.yaml`. Неизвестные поля отклоняются. Полный пример с комментариями: [config.example.yaml](config.example.yaml).
```yaml
mode: full # full (по умолчанию) | scoped
vpn:
interface_pattern: '^(utun\d+|gpd\d+)$'
subnets: [10.4.0.0/16] # пул адресов VPN, обязательно
check_globalprotect: true
protect: # Claude / Cursor / Rider, всегда активно
action: stop # stop (заморозка) | kill
dns_shield: true
pf_block: true
observe_connections: false
extra_process_patterns: [] # свои регулярные выражения
wake_gap: 3s
wake_hold: 5s
resume_checks: 4
update:
auto: true # искать новые релизы
confirm: true # спрашивать перед установкой
interval: 24h
```
У всего в `protect` есть значения по умолчанию: Claude.app, Claude Code (`claude`), Cursor.app, `cursor-agent`, Rider.app (включая `Rider.Backend` и `fsnotifier`), их расширения и все дочерние процессы; `vpnchecker procs` покажет, что подходит на вашей машине.
**`vpn.subnets`:** пул адресов, который выдаёт VPN. Посмотрите адрес `utunN` при подключённом VPN (`ifconfig`); GlobalProtect каждый раз выдаёт другой адрес, поэтому указывайте весь пул (например `10.4.0.0/16`), а не один адрес.
**Что писать в `allow`:** для самого VPN с GlobalProtect обычно ничего. Важно в режиме `full`: VPN-клиент должен уметь переподключиться (другие VPN-клиенты и прежде всего провайдер идентификации при SAML / SSO-входе, например `vpnchecker allow jumpcloud.com`), и туда же идут сервисы, которые должны работать без VPN, вместе со всеми поддоменами: `vpnchecker allow telegram.org`. Один и тот же домен нельзя указать и в `allow.domains`, и в `protect.domains`.
## Проверка
```sh
vpnchecker status
vpnchecker procs
sudo pfctl -a com.apple/vpnchecker -sr # правила загружены всегда, с VPN и без
ls /etc/resolver # allow.domains всегда (форвардер); пока VPN отключён ещё и anthropic.com, claude.ai, cursor.sh, ... (shield)
```
Отключите GlobalProtect: Claude, Cursor и Rider замирают за доли секунды (`vpnchecker procs` покажет STAT `T`), `vpnchecker status` показывает `Blocked`. Подключите снова: через секунду-две они продолжат работу. Закрытие и открытие крышки ноутбука даёт то же самое с паузой около `wake_hold`.
## Устранение неполадок
- **Claude / Cursor / Rider остаются замороженными, хотя VPN поднят** — `vpnchecker status` и `vpnchecker check` покажут, какая проверка не проходит (обычно `vpn.subnets` не содержит адрес туннеля или GlobalProtect сообщает состояние со словом «disconnect»). Исправьте конфиг и `vpnchecker reload`, либо `sudo vpnchecker stop`, чтобы освободить всё.
- **После сбоя приложения всё ещё заморожены** — `sudo vpnchecker stop` или `sudo pkill -CONT -f 'Claude.app|Cursor.app|Rider.app'`.
- **Защищаемый домен не резолвится, хотя VPN поднят** — файлы shield удаляются, когда VPN подтверждён; `sudo vpnchecker stop` удалит их сразу (`ls /etc/resolver` покажет файлы, начинающиеся с `# managed by vpnchecker`). vpnchecker никогда не трогает файлы резолвера, которые написал не он.
- **Режим `full`: VPN не переподключается, пока заблокировано** — клиенту нужен хост вне allowlist. Шлюзы под `allow.gateway_suffixes` и проверка captive-портала (`allow.probe_hosts`) покрываются автоматически. Обычно остаётся **провайдер идентификации при SAML / SSO-входе**: его страница открывается в браузере и должна загрузиться без VPN, например `vpnchecker allow jumpcloud.com` (`okta.com`, `login.microsoftonline.com`, ...). Без этого вход работает, только пока действует сохранённая GlobalProtect auth-cookie, поэтому добавьте хост заранее. Если уже заблокированы: `sudo vpnchecker stop`, подключитесь, добавьте хост, `sudo vpnchecker start`. `vpnchecker diagnose` показывает действующие зоны. Подумайте о режиме `scoped`: он никогда не трогает трафик VPN-клиента.
- **Captive-порталы** (отели, аэропорты) — в режиме `scoped` страница входа просто работает (задерживаются только Claude / Cursor / Rider). В режиме `full`: `sudo vpnchecker stop`, войти, `sudo vpnchecker start`.
- **Демон не запускается** — `vpnchecker logs`. Ошибка `unknown field` значит, что установленный бинарник старее конфига: переустановите `sudo "$(which vpnchecker)" install`.
- **Удаление** — `sudo vpnchecker uninstall` удаляет демон, plist, бинарник, правила pf и DNS shield, размораживает приложения и освобождает токен pf.
## Хук для Claude Code (по желанию)
Заморозка уже останавливает Claude Code; хук даёт понятное сообщение вместо зависания. В `~/.claude/settings.json`:
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "vpnchecker check -q || { echo 'VPN is down, request blocked by vpnchecker' >&2; exit 2; }"
}
]
}
]
}
}
```
## Ограничения
- **Защита на уровне процессов — не файрвол.** Между падением VPN и заморозкой проходят десятки миллисекунд (событие маршрутов, сканирование процессов, сигнал); блокировка в ядре закрывает этот промежуток только для известных адресов серверов. Новый, ещё не виденный адрес Claude / Cursor или процесс, не подходящий под шаблоны, покрывается только заморозкой (а неподошедший процесс — не покрывается вовсе).
- **Блокировка в ядре работает по адресу назначения, а не по процессу.** Пока VPN отключён, любому приложению на Mac отказывают в адресах серверов Claude / Cursor; общие адреса CDN (например Cloudflare для `api3.cursor.sh`) могут сделать недоступными несколько посторонних сайтов. `observe_connections` расширяет это (GitHub и т. п.), поэтому по умолчанию выключен.
- **DNS shield блокирует защищаемые домены для всех приложений** (браузер не откроет `claude.ai`, пока VPN отключён). Кэшированные ответы могут ещё отдаваться, но ни один запрос не покидает Mac, а замороженные приложения соединений не делают. Работает для программ, использующих системный резолвер (`getaddrinfo`); программа со своим DNS-клиентом или DNS-over-HTTPS его обходит, это по-прежнему закрывают заморозка и блокировка в ядре.
- **Приложения со своим DNS** (DoH, зашитые резолверы) обходят `/etc/resolver`: поддомены разрешённых доменов, которые они резолвят, не запоминаются и в режиме `full` остаются заблокированными.
- **Нет защиты до старта демона после загрузки** и пока он остановлен.
- **Режим `full`:** блокирует трафик, который VPN направляет в обход туннеля (исключения split-tunnel), и captive-порталы; DNS (порт 53) разрешён на любой сервер, поэтому запросы других доменов уходят к локальному резолверу, пока VPN отключён.
- **Доверие:** демон работает от root и читает конфиг и лог событий GlobalProtect, доступные пользователю на запись. В конфиге только шаблоны, имена хостов, подсети и интервалы (имена хостов строго проверяются, потому что становятся именами файлов), но пользователь, который может его править, может заставить демон замораживать произвольные процессы. Хосты из лога фильтруются по `gateway_suffixes`. Автообновление доверяет GitHub-релизам этого репозитория ровно так же, как установочный one-liner: контрольная сумма защищает от повреждённой загрузки, но не от скомпрометированного релиза, и тот, кто может опубликовать релиз, получает root на каждой машине, принявшей обновление. Вопрос перед установкой (по умолчанию) оставляет в этой цепочке человека; `update.confirm: false` его убирает. Отключается через `update.auto: false`; версию можно закрепить через `VPNCHECKER_VERSION=vX.Y.Z` у инсталлятора.
- При первом запуске с отключённым VPN и пустым кэшем блокировки в ядре ещё нет (ничего не изучено); заморозка и DNS shield работают.
## Разработка
```sh
cargo test
cargo clippy --all-targets
vpnchecker --socket ./vc.sock run --dry-run -v # root не нужен, показывает, что было бы заморожено
```
Тесты покрывают конечный автомат (`guard`), выбор процессов и реальные SIGSTOP / SIGCONT на дочернем процессе, DNS-ответчик по настоящему UDP, генерацию правил pf, разбор plist GlobalProtect и проверку конфига.
Релиз автоматический. Каждый мерж в `master`, меняющий `src/`, `Cargo.*` или `install.sh`, запускает workflow `release`: он поднимает patch-версию (или берёт `version`, поднятую в PR; *Run workflow* предлагает minor / major), коммитит и ставит тег, собирает универсальный бинарник, выкладывает его вместе с `install.sh` в GitHub Releases, а крейт — на crates.io (секрет `CARGO_REGISTRY_TOKEN`). Машины с `update.auto` предложат его пользователю в пределах `update.interval` (или сразу поставят при `update.confirm: false`), поэтому релиз должен принимать существующие конфиги: релиз, отвергающий конфиг, на такой машине пропускается.
## Лицензия
На выбор: [Apache License, Version 2.0](LICENSE-APACHE) или [MIT](LICENSE-MIT).