Меню-бар-приложение для macOS, которое следит за фактическим egress-IP машины и в момент, когда трафик пошёл мимо VPN, включает запрещающие rule groups в Little Snitch. Туннель восстановился — выключает их автоматически.
English TL;DR: a macOS menu bar app that watches the machine's actual egress IP and,
the moment traffic leaks past your VPN, flips deny-rule groups in Little Snitch — then
flips them back when the tunnel recovers. Detection is beacon-based (Cloudflare
cdn-cgi/trace + a local-country IP beacon), enforcement goes through a tiny root
helper that only knows three XPC operations. Docs and UI are in Russian.
VPN-клиент упал, переподключается или «тихо» перестал держать маршрут — а трафик продолжает идти, уже напрямую через провайдера. Kill-switch в клиенте есть не всегда, а главное — он доверяет самому клиенту. Это приложение не доверяет никому: оно регулярно спрашивает у внешних маяков, кто на самом деле видит ваш трафик снаружи, и реагирует на факт, а не на статус в чужом UI.
Реакция — штатный механизм Little Snitch: rule groups с запрещающими правилами, которые companion включает при утечке и выключает при восстановлении.
- Проба egress. Раз в 60 с (и по каждому событию сетевого пути) приложение
опрашивает маяк Cloudflare (
cdn-cgi/trace): оттуда приходят фактический egress-IP, флагwarp=onи дата-центр. - Прямой IP для сравнения. Отдельный РУ-маяк (yandex, резерв — 2ip) сообщает, какой IP выдал провайдер напрямую, — это динамическая часть denylist.
- Классификация.
warp=onили egress из allowlistexpectedIPs→ Protected. Egress совпал с адресом изforbiddenEgressIPs(ваши же VPN-серверы) → Leak «цепочка вышла напрямую с сервера». Egress совпал с прямым IP провайдера → Leak «полный обход VPN». Маяки недоступны → Offline (это не утечка). - Растяжка (tripwire). Постоянное TCP-соединение с heartbeat 15 с замечает обрыв туннеля за секунды — не дожидаясь следующей пробы.
- Реакция. Утечка подтверждается второй пробой (~2.5 с), затем привилегированный
helper включает назначенные rule groups (
littlesnitch rulegroup -e). Состояние восстановилось — группы выключаются сами. Обо всём — уведомление и запись в журнал. - Эскалация. Если утечка подтверждена, а helper или CLI недоступны, приложение
выключает Wi-Fi (
networksetup -setairportpower off) — лучше без сети, чем напрямую. - Режимы защиты. Реактивный (по умолчанию): группы включаются только при доказанной утечке. Строгий (fail-closed): открыто только при доказанном VPN — всё неопределённое закрывает. Эшелоны закрытия строгого режима: старт до первого вердикта, пропажа сети, пауза, ⌘Q, крэш приложения (dead-man's switch в helper), загрузка ОС до старта приложения и уход машины в сон — сон не гасит сеть (TCPKeepAlive), поэтому группы включаются до засыпания, а после пробуждения открываются только по свежему вердикту Protected.
Состояния Protected / Leak / Offline / Checking / Paused живут в state machine на чистом
Swift без Apple-фреймворков; таблица переходов из ТЗ покрыта тестами построчно.
Полное ТЗ: SPEC.md, артефакты разработки:
openspec/changes/archive/2026-07-30-build-ls-vpn-companion/.
Для запуска:
- macOS 15.7+ (поддерживаемый диапазон — 15.7–26.x)
- Little Snitch 6 в
/Applications/Little Snitch.app - rule group в Little Snitch с запрещающими правилами (по умолчанию ожидается имя «VPN down»; любое другое задаётся в настройках)
Для сборки — дополнительно:
- macOS 26.2+ с Xcode 26.x. Порог сборки выше порога запуска: иконка описана
Icon Composer-документом
App/AppIcon.icon, аactoolкомпилирует его только на macOS 26 — на Sequoia он падает. Сборка идёт против SDK 26 при deployment target 15.7, это штатная схема, отдельно ничего настраивать не нужно - XcodeGen (
brew install xcodegen) — проект генерируется изproject.yml, сам.xcodeprojв git не хранится
Проверено: сборка и тесты на macOS 26.5.2 (Xcode 26.6, Apple Silicon); тесты — ещё и в CI на образе macOS 15.7 с Xcode 26.x, по нижней границе поддерживаемого диапазона; живая эксплуатация — на 26.x (Apple Silicon) и на 15.7.8 (Intel).
Артефакт универсальный (arm64 + x86_64), поэтому Intel-машине собственный Xcode не
нужен: приложение собирается на Apple Silicon и переносится готовым бандлом — см.
«Установка из релиза» и «Перенос на другую
машину».
Собранный бандл лежит во вложениях релизов — Xcode и связка ключей автора для запуска не нужны: сертификат подписи встроен в сам бандл.
Распаковывать сразу в ~/Applications; запуск из ~/Downloads привяжет демона к
этому пути в базе BTM (см. «Грабли», п. 1):
ditto -x -k ~/Downloads/LSVPNCompanion-v1.0.zip ~/ApplicationsПриложение подписано самоподписанным сертификатом и не нотаризовано, поэтому на скачанном архиве стоит флаг карантина и первый запуск отклоняется:
xattr -dr com.apple.quarantine ~/Applications/"Little Snitch VPN Companion.app"Без командной строки то же самое делается после неудачной попытки запуска — Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть»; обход через Control-click → «Открыть» в Sequoia уже не работает.
Дальше — открыть приложение, пройти онбординг, одобрить объект входа в Системных настройках. Little Snitch, его rule group и доступ для CLI настраиваются отдельно (см. «Что понадобится»).
xcodegen generate && xcodebuild -project LittleSnitchVPNCompanion.xcodeproj -scheme LittleSnitchVPNCompanion -configuration Debug buildopen ~/Library/Developer/Xcode/DerivedData/LittleSnitchVPNCompanion-*/Build/Products/Debug/"Little Snitch VPN Companion.app"Приложение живёт только в строке меню (LSUIElement), в Dock не появляется.
Для повседневной работы приложение должно лежать в ~/Applications, а не в
каталоге сборки: путь внутри DerivedData меняется при смене конфигурации, а
System Settings — песочное приложение и не может прочитать оттуда бандл, из-за
чего в «Объектах входа» вместо иконки виден серый плейсхолдер.
./Scripts/install.shСкрипт собирает Release, копирует в ~/Applications, обновляет реестр
LaunchServices и запускает установленную копию. /Applications требует прав
администратора; если аккаунт обычный — ~/Applications штатная замена.
После первой установки (и после каждого обновления, меняющего helper) демон нужно перерегистрировать: Настройки → Общие → «Переустановить…», затем одобрить объект входа.
Xcode на целевой машине не нужен: артефакт универсальный (arm64 + x86_64), и
Intel-машина запускает его нативно. Для Intel это единственный путь — Xcode 26 в
App Store отдаётся сборкой под Apple Silicon.
Сертификат подписи переносить тоже не нужно: он встроен в подпись, а helper строит XPC-requirement из собственной подписи — «клиент подписан тем же сертификатом, что и я». Совпадение проверяется внутри бандла, связка ключей целевой машины ни при чём.
Собрать Release и упаковать (ditto сохраняет подпись и обе архитектуры):
xcodebuild -project LittleSnitchVPNCompanion.xcodeproj -scheme LittleSnitchVPNCompanion -configuration Release buildditto -c -k --sequesterRsrc --keepParent ~/Library/Developer/Xcode/DerivedData/LittleSnitchVPNCompanion-*/Build/Products/Release/"Little Snitch VPN Companion.app" ~/Downloads/LSVPNCompanion.zipНа целевой машине распаковывать сразу в ~/Applications — запуск из ~/Downloads
привяжет демона к этому пути в базе BTM (см. «Грабли», п. 1):
ditto -x -k ~/Downloads/LSVPNCompanion.zip ~/Applicationsxattr -dr com.apple.quarantine ~/Applications/"Little Snitch VPN Companion.app"/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f -R -trusted ~/Applications/"Little Snitch VPN Companion.app"Приложение не нотаризовано. Если архив приехал через AirDrop, браузер или почту, на нём
стоит флаг карантина — снимает его команда xattr выше. Без неё первый запуск отклоняется,
и в Sequoia обход через Control-click → «Открыть» больше не работает: остаётся
Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть».
Передача через scp, rsync или внешний диск карантина не ставит вовсе.
Дальше — как при обычной установке: открыть, пройти онбординг, одобрить объект входа. Little Snitch, его rule group и доступ для CLI на целевой машине настраиваются отдельно.
./Scripts/release.sh 1.1.0Скрипт собирает Release с MARKETING_VERSION=1.1.0, проверяет получившийся бандл,
пакует его в build/LSVPNCompanion-v1.1.0.zip и создаёт на GitHub черновик релиза
с этим вложением и инструкцией по установке в заметках. Тег v1.1.0 появится в
репозитории только после публикации черновика; --publish публикует сразу.
Собирать релиз можно только на машине с сертификатом из
Signing.xcconfig — шага сборки артефакта в CI нет намеренно.
Requirement helper построен на «клиент подписан тем же сертификатом, что и я», поэтому
приватный ключ этого сертификата — единственный гейт к привилегированному демону, и в
секретах Actions ему не место. Ad-hoc сборка из CI на роль релиза тоже не годится:
без сертификата проверка вырождается в один identifier, и к демону проходит любой
процесс с нужным bundle id.
До сборки скрипт останавливается на грязном рабочем дереве, незапушенном HEAD,
занятом теге и отсутствующем сертификате. После — проверяет, что приложение и
встроенный helper подписаны именно этим сертификатом (а не свалились на ad-hoc), что
оба бинаря универсальные, что launchd-plist демона на месте и что версия доехала до
Info.plist.
Все адреса в репозитории — заполнители из документационных диапазонов RFC 5737; свои значения вносятся в настройках приложения:
forbiddenEgressIPs— IP ваших собственных VPN-серверов. Если egress совпал с одним из них, значит цепочка «схлопнулась» и трафик выходит напрямую с сервера.expectedIPs— allowlist выходных адресов. Детектор считает состояние защищённым приwarp=on(Cloudflare WARP как последнее звено) или при egress из этого списка — если ваша цепочка не через WARP, просто внесите сюда свои выходные IP.- Маппинг групп — какие rule groups включать при утечке.
- В самом Little Snitch включите доступ для CLI: Little Snitch → Настройки → Безопасность (см. «Грабли», п. 2).
xcodebuild -project LittleSnitchVPNCompanion.xcodeproj -scheme LittleSnitchVPNCompanion -configuration Debug test130 тестов в четырёх бандлах: DomainTests (state machine и классификатор — таблицы §4.1
и §5 SPEC.md покрыты построчно), ApplicationTests (use cases на фейках портов),
HelperTests (толерантный разбор модели LS), InfrastructureTests (журнал, настройки,
парсеры маяка и networksetup).
Проверка чистоты доменного слоя (Domain не должен импортировать Apple-фреймворки):
./Scripts/check-domain-purity.shОбе утилиты собираются из доменного слоя и трогают сеть только на чтение (два GET по сотне байт).
swiftc -swift-version 6 Domain/*.swift Scripts/live-check.swift -o /tmp/live-check && /tmp/live-checkПечатает ответ маяка, прямой IP от РУ-маяка, вердикт по фактическому egress и контрольные подмены debug-сценариев §14 (2, 13, 14).
swiftc -swift-version 6 Domain/*.swift Application/Ports.swift Application/AppSettings.swift Infrastructure/TripwireConnection.swift Infrastructure/SystemPathMonitor.swift Scripts/live-tripwire.swift -o /tmp/live-tripwire && /tmp/live-tripwireДержит растяжку 14 с с heartbeat 3 с и печатает обрывы и события сетевого пути.
App/ Presentation: SwiftUI-экраны, AppModel, composition root
App/DesignSystem/ токены → атомы → молекулы (§8 SPEC.md)
Application/ use cases и порты (протоколы)
Domain/ чистый Swift без Apple-фреймворков: state machine, классификатор, политика
Infrastructure/ проберы, растяжка, XPC-клиент, журнал, настройки, Wi-Fi
Helper/ привилегированный демон (root) + XPC-контракт
Tests/ DomainTests, ApplicationTests, HelperTests, InfrastructureTests
Scripts/ живые проверки детектора, установка, проверка чистоты Domain
Зависимости идут только внутрь: Domain не знает ни о чём, Application — только о
Domain и своих портах, Infrastructure и App подставляют реализации в
App/CompositionRoot.swift.
Единственный компонент с правами root: только он вызывает littlesnitch CLI
(rulegroup -e/-d, export-model). Контракт — три XPC-операции, произвольные команды
не проходят по построению. Helper валидирует подключающегося XPC-клиента по
code-signing requirement и отклоняет посторонних (в том числе сам Little Snitch).
Установка — шаг 1 онбординга (кнопка «Установить helper…») или тумблер в настройках.
После SMAppService.daemon(...).register() macOS требует одобрения в
System Settings → Основные → Объекты входа. Пока helper не одобрен, приложение
работает в режиме деградации: детектор и журнал живут, группы LS не переключаются,
в поповере горит строка с диагнозом.
Проект подписывается самоподписанным сертификатом (см.
Signing.xcconfig — там же пошаговая инструкция, как создать свой
в Keychain Access). Тогда XPC-requirement строится строго: identifier "dev.sunnyday.lsvpncompanion" and certificate leaf = H"<sha1 сертификата>" — helper
пускает только приложение, подписанное тем же сертификатом.
Важно: ENABLE_DEBUG_DYLIB = NO обязателен и уже прописан. Hardened Runtime
включает library validation, а у самоподписанного сертификата нет Team ID, поэтому
отладочная прослойка Xcode (<продукт>.debug.dylib, по умолчанию включена) не проходит
проверку и приложение падает при запуске с Library not loaded … different Team IDs.
С ad-hoc подписью дефект не проявляется — только с настоящим сертификатом.
Без сертификата (ad-hoc, CODE_SIGN_IDENTITY = -) собрать и запустить тоже можно, но
XPC-валидация клиента работает в ослабленном режиме: у ad-hoc подписи нет устойчивой
identity, поэтому helper проверяет только identifier и пишет предупреждение в лог.
Всё ниже проверено на живой машине — сэкономит вам вечер.
launchd привязывает демон к его подписи на момент регистрации (launch constraint,
LWCR). Пока меняется только код приложения, helper не перекомпилируется и всё работает.
Но как только меняются исходники в Helper/, бинарь становится другим и launchd
отказывается его запускать — в launchctl print system/dev.sunnyday.lsvpncompanion.helper
это видно как last exit code = 78: EX_CONFIG и needs LWCR update, а приложение
показывает «helper не ответил за 6 с».
Обычно лечится Настройки → Общие → «Переустановить…» (приложение делает то же само, если демон зарегистрирован, но не отвечает, — не чаще раза за запуск); macOS может попросить заново одобрить объект входа.
После любой регистрации демон поднимается не сразу: SMAppService.status отражает базу
Background Task Management, а не launchd, и между register() и первым ответом helper
проходит до ~30 с (замер 2026-09-09: новый демон стартовал через 30 с после последнего
register(), а каждый цикл unregister → register за это время сбрасывал ожидание
с нуля). Приложение ждёт ответа с бюджетом 60 с и растущим интервалом опроса, показывая
«ждём launchd… N с»; только по исчерпании бюджета делает один полный цикл снятия и
регистрации и ждёт ещё минуту, после чего показывает «зарегистрирован, но не отвечает».
Кнопка на время операции неактивна — нажимать её повторно не нужно и нельзя.
Регистрировать сразу после снятия нельзя (замер 2026-09-10): register() через секунду
после unregister() падает с SMAppServiceErrorDomain 1 «Operation not permitted», а
через четыре секунды «проходит», но регистрация остаётся мёртвой — статус enabled, а
job launchd не загружает ни за минуту. Регистрация после паузы поднимает демон мгновенно.
Приложение ждёт исчезновения job (launchctl print system/<label> без root: код 0 —
job есть, 113 — нет), выдерживает паузу и повторяет register() при временной ошибке.
Если job застрял окончательно (job state = spawn failed), launchd держит устаревший
constraint, и ни register(), ни unregister()+register() из приложения его не
обновляют. Снять запись может только root:
sudo launchctl bootout system/dev.sunnyday.lsvpncompanion.helperПосле этого приложение видит notRegistered и регистрирует демон заново само.
Проверенные тупики, чтобы не повторять: самозавершение helper при смене бинаря (launchd всё равно не запускает заменённый бинарь под старой регистрацией) и перерегистрация из приложения (constraint остаётся прежним).
Отдельный тумблер внутри самого Little Snitch. Пока он выключен, littlesnitch
даже под root отвечает:
Error: command line tool is not authorized to make changes.
Please enable access in Little Snitch.app > Preferences > Security.
Включается в Little Snitch → Настройки → Безопасность. Приложение распознаёт
именно эту ошибку и показывает отдельную подсказку с кнопкой «Открыть Little Snitch…».
А без root littlesnitch печатает littlesnitch must be run as root! и выходит
с кодом 14 — поэтому CLI и живёт в helper.
Структура export-model не документирована; снята с живой машины (2026-07-30 и
2026-09-09, Little Snitch 6.5) и закреплена тестами
(Tests/HelperTests/LittleSnitchModelTests.swift):
"groups": {
"aaaaac": {"type": "builtinMacOSServices", "isActive": true},
"aaaaad": {"type": "builtinICloudServices", "isActive": true},
"ghoGzc": {"type": "loadedFromFile", "userProvidedName": "Require VPN Services Local",
"creationDate": "…"},
"pykpmH": {"type": "loadedFromURL", "factoryName": "Require VPN Services",
"loadFromURL": "https://example.com/require-vpn.lsrules",
"lastSuccessfulUpdate": "…", "updateInterval": 86400}
}- имя берётся по цепочке полей
userProvidedName→customName→factoryName: локальная группа хранит его вuserProvidedName, подписка по URL (type: loadedFromURL) — имя из файла.lsrulesвfactoryName, переименованная пользователем подписка — вcustomName(допущение: в дампе ключ не встречался, свойство есть в бинаре CLI). У встроенных имени нет вовсе, их опознаётtype(builtinMacOSServices→ «macOS Services»); - на
typeлокальных групп опираться нельзя: в июльском снимке ключа не было, в сентябрьском появилсяloadedFromFile. Пропускается только запись без имени в любом из полей — правило «неизвестный тип пропускаем» до сентября 2026 как раз и прятало подписки; isActiveприсутствует только у включённых групп любого вида: отсутствие ключа означает «выключена»;- ключи словаря (
aaaaac) — внутренние идентификаторы LS, наружу они не отдаются; rulegroup -e/-dпринимает имя подписки как есть (проверено:-d "Require VPN Services"→ код 0); флаг-uне нужен — root видит подписки в общей модели;- helper отдаёт приложению вид группы полем
kind(builtin/local/remote). Поле необязательное: ответ без него читается, а во вкладке «Группы» подписка помечается словом «подписка».
Две ловушки подписок:
- имя подписки живёт на сервере: если
nameв.lsrulesизменится, группа выпадет из маппинга, и reconcile начнёт писать «группа … не найдена» — отметьте её заново; - одноимённые локальная группа и подписка для CLI неотличимы: он принимает только имя. Приложение покажет одну строку и предупредит в журнале; держите имена уникальными.
Если формат сменится с версией LS, парсер переходит к общему поиску по форме объекта, а при неудаче сообщает в журнал фактические ключи и форму — по ним разбор дописывается без гадания.
- Версия helper включает отпечаток его бинаря, поэтому приложение замечает устаревший
демон. Путь к своему бинарю helper берёт через
_NSGetExecutablePath:argv[0]у launchd-демона путём не является, аBundle.mainвнутри бандла приложения указывает на бинарь приложения — оба варианта давали версиюbuild unknown. - Устаревший демон
register()поверх не перезапускает: constraint обновляется, а процесс в памяти продолжает отвечать старой версией. Приложение замечает это по ответу и один раз за запуск перезапускает демон полным циклом снятия и регистрации. SMAppService.unregister()завершается асинхронно: вызватьregister()сразу нельзя — снятие регистрации отменит её.HelperLifecycleдожидается фактического.notRegisteredперед повторной регистрацией.- XPC-вызовы ограничены таймаутом 6 с: зарегистрированный, но не одобренный демон не отвечает и не сообщает об ошибке — без таймаута запрос висел бы молча. К незагруженному сервису вызов, наоборот, падает мгновенно («No such process»), поэтому бюджет ожидания считается по часам, а не по числу попыток.
- Замена бандла (
install.sh, обновление через Finder) сбрасывает запись BTM: статус становитсяnotRegisteredпри живом старом демоне. Если онбординг пройден и helper не удалял пользователь, приложение регистрирует демон заново само и ждёт ответа, как после любой другой регистрации. - Вся логика регистрации — в
Application/HelperLifecycle.swift: одно состояние helper на все экраны, не больше одной операции за раз, кнопка «Установить…» / «Переустановить…» / «Удалить…» по состоянию. Тесты —HelperLifecycleTests. - «Удалить…» снимает регистрацию без подтверждения в любом режиме, предварительно
передав helper неактивный failsafe-конфиг; факт удаления запоминается
(
helperRemovedByUserв defaults), и авто-восстановление его уважает до следующего «Установить…». В строгом режиме без helper группы не переключаются, а эскалация по Wi-Fi при утечке сохраняется — об этом пишется в журнал.
defaults write dev.sunnyday.lsvpncompanion debugIgnoreWarp -bool trueИгнорировать warp=on — приложение должно за ≤5 с зафиксировать Leak и включить
группы, а при снятии флага само вернуться в Protected.
defaults write dev.sunnyday.lsvpncompanion debugFakeEgressIP -string "198.51.100.10"Подменить egress маяка: адрес из вашего forbiddenEgressIPs даёт диагноз «цепочка
вышла напрямую с сервера», текущий прямой РУ-IP — «полный обход VPN».
Снять рычаги:
defaults delete dev.sunnyday.lsvpncompanion debugIgnoreWarp; defaults delete dev.sunnyday.lsvpncompanion debugFakeEgressIPИзменения подхватываются на следующей пробе — перезапуск не нужен: проберы читают настройки в момент запроса.
~/Library/Application Support/LittleSnitchVPNCompanion/journal.jsonl — JSONL, ротация
7 дней. Окно журнала (поповер → «Журнал…») даёт фильтры Все/Переходы/Действия/Ошибки,
экспорт в текст и очистку.
Нарисована в design/app.pen (лист «Иконка приложения») и визуально перекликается с
иконкой Little Snitch: синий градиентный квадрат, белая орбитальная разметка и
двухлопастной янтарный ротор. Центральный замок передаёт назначение компаньона:
безопасная работа сети и блокировка при отсутствии VPN. Артворк собственный — PNG
Objective Development не используются. Все размеры 16–1024 px собраны из одного
финального мастера; превью в начале README использует тот же AppIcon-набор.
- Диапазон macOS — 15.7–26.x, обе границы проверены живой эксплуатацией: macOS 26.x на Apple Silicon и macOS 15.7.8 (Sequoia) на Intel, где приложение работает несколько дней без отличий в поведении. Сборка и тесты на обеих границах прогоняются в CI.
- Заточено под конкретный сценарий: цепочка с Cloudflare WARP последним звеном и
российский провайдер как «прямой» путь. Другие конфигурации работают через
expectedIPs, но именно WARP-эвристика (warp=on) — основной сигнал «защищён». - РУ-маяки (
yandex.ru,2ip.ru) имеют смысл, когда «прямой» выход — в России; URL обоих маяков меняются в настройках. - Little Snitch — не песочница: rule groups фильтруют новые соединения, но уже установленные могут дожить до своего таймаута.
Правила — в CONTRIBUTING.md. Главное: main принимает только PR
с зелёным CI, а любое изменение логики идёт через Spec-Driven Development
(OpenSpec) и обновляет спеки в openspec/specs/.
MIT. Little Snitch — продукт Objective Development; этот проект с ними не аффилирован.