# macOS VPN · per-app роутинг `05 · macos-vpn · Personal tool` Per-app macOS-tool для маршрутизации с fail-shut pf-gate, launchd boot persistence и live-мониторингом. **Скоуп:** Соло · в работе **Роль:** macOS системная инженерия · CLI-тулинг --- ## Контекст > Kill-switch — это не watchdog. Это отсутствие маршрута. Developer-тулинг, которому нужен стабильный per-app egress IP — защищённое приложение всегда выходит через один и тот же proxy-узел, всё остальное на машине идёт direct. Off-the-shelf VPN-клиенты тянут весь трафик в туннель; CIDR- или DNS-based split-tunneling не помогают, когда тот же destination нужен через proxy для одного процесса и direct для другого. Per-process granularity в нужной комбинации не встречается ни в одном популярном consumer GUI. Для защищённой полосы fail-open после падения туннеля заметнее самого падения — одна секунда direct egress флагирует receiving-side. Watchdog kill-switch оставляет race-окно между exit'ом sing-box и реакцией watchdog'а; fail-open в этом окне — режим отказа, который защищённая полоса не может терпеть. ## Факты | | | |---|---| | **Скоуп** | Соло · daily driver | | **Поверхности** | macOS CLI · self-installing pf-anchor · launchd boot persistence | | **Роутинг** | sing-box TUN + process_name dispatch · 3 protected app groups | | **Протоколы** | VLESS+Reality (TCP stealth) · Shadowsocks (TCP+UDP) — выбор на старте сессии | | **Мониторинг** | Clash API @ 127.0.0.1:9090 · per-app speed · 3-tier sparklines (4 мин · 1 час · сессия) | | **Статус** | Personal tool · daily driver | ## Архитектура ### Per-app роутинг через TUN ```text Защищённые процессы все остальные процессы (3 app-группы · process_name match) │ │ │ ▼ ▼ sing-box TUN-режим (utun99 · 172.19.0.1/30) │ перехватывает ВЕСЬ системный трафик │ │ route.rules: │ if process_name in VPN_PROCESSES → outbound: proxy │ else → outbound: direct ▼ ┌─── proxy outbound ──── VPN provider ──── интернет │ └─── direct outbound ──── en0 ──── интернет ``` **Dispatch по процессу, не по destination.** Routing rules в sing-box (route.rules) матчат на process_name, не на destination IP или domain. Два «сетевых пространства» делят один хост: защищённые apps всегда выходят через proxy, всё остальное — direct, даже когда оба идут на тот же destination. **TUN перехватывает всё.** sing-box работает в TUN-режиме (auto_route: true) — каждый пакет от каждого процесса проходит через utun99. Без этого dispatch по process_name невозможен: kernel route tables работают по destination, не по владельцу процесса. **DNS split внутри sing-box.** Защищённые процессы получают DoH через proxy-dns (1.1.1.1 over HTTPS через proxy); всё остальное идёт на direct-dns (1.1.1.1 plain UDP). DNS-hijack route rule перехватывает системные DNS-запросы до того, как они уйдут с хоста. Без per-process DNS резолвинг VPN-host'ов утекал бы через локальный resolver. ### Negative-space pf gate · матрица состояний ```text state en0 traffic owner fires outcome ───── ───────────────── ───── ─────── VPN running sing-box (root) pass quick user 0 OK · proxy + direct работают VPN crashed user-app (uid≠0) block drop default нет интернета для user boot, no mvpn user-app (uid≠0) block drop default нет интернета для user mvpn (root) mvpn (root) pass quick user 0 подписка / пинги работают rules в /etc/pf.conf через anchor "singbox-killswitch": pass quick on lo0 all pass quick on utun99 all pass quick on { en0 en4 en5 en6 } from any to any user 0 pass quick on { en0 en4 en5 en6 } proto { tcp udp } to any port 53 # DNS pass quick on { en0 en4 en5 en6 } proto udp from any port 68 to 67 # DHCP pass quick on { en0 en4 en5 en6 } proto udp to 224.0.0.251 port 5353 # mDNS block drop on { en0 en4 en5 en6 } proto { tcp udp } all # default-deny ``` **Kill-switch — это отсутствие, не действие.** Нет watchdog-процесса. Нет цикла «monitor sing-box, then call pfctl block». Block rule загружено один раз; срабатывает каждый раз, когда non-root socket пытается писать в физический интерфейс. Когда sing-box падает — user-apps fail-shut по умолчанию, реагировать нечему. **Discovery-уровень explicit-allows.** lo0 + TUN + DNS + DHCP + mDNS pass-through нужны для первого подключения. Без DNS первый sudo mvpn не смог бы зарезолвить VPN-host; без DHCP свежий boot не получил бы IP. Block применяется только к non-root TCP/UDP на Ethernet — discovery-примитивы остаются открытыми. **Multi-interface match.** Правила применяются к en0 + en4 + en5 + en6 — Wi-Fi плюс три слота USB-Ethernet адаптеров. Подключённый tethered-телефон или USB-C хаб не обходят gate. ### Выбор протокола · kill-switch active ![Экран выбора протокола — VLESS+Reality (TCP-only stealth) против Outline / Shadowsocks (TCP+UDP). Default: Outline.](https://ilyadev.xyz/private/macos-vpn-start.webp) *Каждый старт: stealth или full transport* ![Баннер при остановке sing-box — "INTERNET BLOCKED — kill switch active" с recovery-командами ниже.](https://ilyadev.xyz/private/macos-vpn-killswitch.webp) *Fail-shut surface'ится явно* **Default: Shadowsocks.** Outline (Shadowsocks) тянет full TCP+UDP — игры, голос, video calls работают. VLESS+Reality — stealth-выбор, когда полный transport не нужен (см. §03 Decisions для trade-off'а). Выбор per session, никогда не автоматически. **Recovery — три команды.** sudo mvpn — реконнект (re-fetch подписки, re-выбор сервера). sudo mvpn kill-apps — force-close защищённых процессов (SIGTERM → SIGKILL) до открытия gate. sudo mvpn disable — снять pf-anchor; интернет возвращается direct, но если защищённое приложение ещё запущено, его трафик пойдёт direct. **Disable confirms перед открытием.** sudo mvpn disable проверяет live защищённые процессы перед снятием anchor'а. Если какие-то запущены — спрашивает «Kill? [y/N]». Отказ означает, что пользователь должен закрыть их вручную до открытия gate. Без этого prompt'а disable незаметно перевёл бы защищённые apps на direct-путь. ### Boot lifecycle ```text macOS boot │ ▼ launchd (RunAtLoad: true) │ com.mvpn.killswitch.plist → /Library/LaunchDaemons/ │ ProgramArguments: │ pfctl -a singbox-killswitch -f killswitch.pf.conf ▼ pf anchor "singbox-killswitch" loaded │ rules active · нет интернета для user-apps ▼ user runs sudo mvpn │ fetch подписки (root → pass quick) │ parallel TCP ping → выбор лучшего сервера │ generate config.json → sing-box check → start ▼ sing-box up (PIDFILE написан, TUN online) │ process_name dispatch live │ Clash API на :9090 готов ▼ live-status loop (urllib → /connections → render) ``` **enable vs mvpn — ортогональные команды.** sudo mvpn enable выполняется один раз на машину: копирует com.mvpn.killswitch.plist в /Library/LaunchDaemons/, регистрирует launchd daemon, ставит pf-anchor в /etc/pf.conf. sudo mvpn — каждая сессия: подписка → пинг → старт. После enable каждый reboot блокирует интернет до sudo mvpn. **Self-installing pf-anchor.** pf.py:_ensure_anchor_in_main() читает /etc/pf.conf, добавляет anchor "singbox-killswitch" если отсутствует, выполняет pfctl -f /etc/pf.conf для reload. Идемпотентно — повторный enable no-op, если строка уже есть. Снимается через disable → _remove_anchor_from_main(). **Boot-time pf-лог.** launchd пишет pfctl stdout/stderr в pf-launch.log — первое, куда смотреть, если anchor не загрузился на boot (битый config, syntax error после ручного редактирования). ## Ключевые инженерные решения ### 01 · Negative-space pf gate как kill-switch **Решение.** pf rules: pass quick on en0/en4-6 user 0 (root-трафик, включая sing-box) + block drop on en0/en4-6 default для всего остального, с explicit pass-through для lo0, utun99, DNS, DHCP, mDNS. Поведение kill-switch — side-effect от default-deny rule; нет watchdog'а, нет liveness-check, нет реактивного block-шага. **Почему.** Реактивный watchdog kill-switch («monitor sing-box; if dead, call pfctl block») оставляет race-окно — между exit'ом sing-box и реакцией watchdog'а user-apps падают сразу на en0. Для защищённой полосы, где любая утечка громче самого падения, gate должен работать как property системы, а не как action триггерящийся по событию. Default-deny + explicit allows сжимает безопасность в саму routing table: ничему не нужно «работать правильно» для применения block — отсутствие маршрута и есть block. **Цена.** pf rules требуют root и живут в /etc/pf.conf через self-install path (_ensure_anchor_in_main()); debug «почему нет интернета» проходит через pfctl -s rules, не через app-логи. Нет per-app fail-open — gate бинарный по защищённому набору. Discovery-примитивы (DNS, DHCP, mDNS) требуют explicit pass-through, иначе ломается first-connect; pf rule list уже не тривиально-короткий. ### 02 · process_name dispatch в TUN-режиме (не CIDR / DNS split-tunnel) **Решение.** sing-box работает в TUN-режиме (auto_route: true) — каждый пакет каждого процесса идёт через utun99. Два route rule диспатчат: process_name in VPN_PROCESSES → outbound: proxy; иначе final: direct. Оба rule видят один и тот же destination set; единственное отличие — владелец процесса. **Почему.** Use case — per-app static IP, не «трафик к host X через VPN». CIDR- или DNS-based split-tunneling ломаются, когда тому же домену нужен proxy от процесса A и direct от процесса B — оба rule матчат, выигрывает один, второй процесс либо утекает, либо не работает. process_name dispatch — единственный механизм, который держит две разные egress-полосы для двух локальных процессов, бьющих в один destination. **Цена.** TUN перехватывает всё — если sing-box зависает, system networking зависает с ним. Dispatch — per-packet (overhead малый, но измеримый на heavy traffic). VPN_PROCESSES — manual list в config.py; добавление приложения требует ps -eo comm | grep и ручного редактирования. Нет GUI, нет auto-discovery. ### 03 · Два протокола на старте сессии (VLESS+Reality vs Shadowsocks) **Решение.** Каждый sudo mvpn спрашивает: VLESS+Reality (TCP-only, выглядит как HTTPS для DPI) или Shadowsocks (TCP+UDP, проще обфускация). Default — Shadowsocks. Выбор сохраняется на сессию; смена протокола — stop + restart. **Почему.** Stealth и UDP в этом стеке взаимно исключаются. VLESS+Reality с flow=xtls-rprx-vision — TCP-only; Reality не умеет проксировать UDP вообще. Route rule в VLESS-режиме fallback'ит UDP от защищённых процессов на outbound: direct (видно в singbox.py:69-78). Для use case с UDP-чувствительностью (gaming, voice) это утечка реального IP; для TCP-only use case stealth VLESS'а стоит UDP-direct fallback'а. Shadowsocks проксирует и то, и другое. Один протокол не покрывает обе формы; auto-switch спрятал бы security-relevant решение за эвристикой. **Цена.** Две outbound-ветки в singbox.py — _make_vless_outbound и _make_shadowsocks_outbound. UX-cost: extra prompt на каждый старт сессии. Пользователь должен помнить, что выбрал — mvpn status показывает активный протокол, но picker — единственный checkpoint, где это решение принимается. ### 04 · launchd-mounted pf-anchor с self-install **Решение.** sudo mvpn enable (один раз на машину) ставит com.mvpn.killswitch.plist в /Library/LaunchDaemons/ и pf-anchor в /etc/pf.conf. После enable каждый reboot перезагружает pf-rules до того, как любой user-space app поднимется. sudo mvpn — отдельная per-session команда: fetch подписки, выбор сервера, старт sing-box. **Почему.** Post-reboot — danger window: auto-launch приложения могут добраться до networking до того, как пользователь введёт sudo mvpn. Run-on-demand kill-switch (load rules на старте, drop на стопе) оставляет защищённые apps fail-open до тех пор, пока пользователь не вспомнит запустить VPN. Mounting anchor'а на boot инвертирует default — интернет заблокирован by default; включение его — сознательный шаг. Разделение enable от mvpn делает per-session команду короткой, а per-machine setup — явным. **Цена.** enable пишет в /etc/pf.conf и /Library/LaunchDaemons/ — оба требуют sudo и документируют установку как known surprise («нет интернета после reboot до sudo mvpn»). Plist хардкодит абсолютный путь до killswitch.pf.conf — не portable на другие аккаунты без templating'а. ### 05 · Per-process DNS split внутри sing-box **Решение.** sing-box config определяет два DNS resolver'а — proxy-dns (DoH, 1.1.1.1 over HTTPS, через proxy) и direct-dns (1.1.1.1 over plain UDP, direct). DNS rule отправляет запросы от VPN_PROCESSES через proxy-dns; всё остальное падает на direct-dns как final. Отдельный route rule (protocol: dns, action: hijack-dns) перехватывает каждый DNS-запрос, который система пытается сделать, и редиректит его на внутренний sing-box resolution. **Почему.** Один resolver — даже быстрый и надёжный — утекает resolution path. Если защищённые процессы резолвят VPN-host'ы через локальный системный DNS (роутер, ISP, public 1.1.1.1 plain UDP), receiving-side видит lookup с реального IP хоста до любого proxy-подключения. DoH-через-proxy держит и резолвинг, и подключение в одном egress path — proxy единственная network-surface, которая видит активность защищённых процессов. **Цена.** sing-box DNS-hijacking может конфликтовать с приложениями, которые pin'ят свои DoH-эндпойнты (браузеры с ECH, например) — hijack action редиректит запрос, но in-app DoH-клиент может его не уважать. DoH через proxy добавляет round-trip latency на каждый cold lookup по сравнению с plain UDP. Два resolver'а в config'е удваивают surface, который должен оставаться здоровым. ## Стек | | | |---|---| | **Runtime** | Python 3.12+ · macOS pf · launchd · sing-box (TUN-режим) | | **Protocols** | VLESS+Reality (TCP stealth) · Shadowsocks (TCP+UDP, Outline-формат) | | **Dispatch** | sing-box route.rules · process_name per-app match · DNS hijack | | **Monitoring** | Clash API @ 127.0.0.1:9090 · /connections poll · custom rendering | | **Persistence** | launchd plist · self-installing pf-anchor в /etc/pf.conf | | **Scale** | ~2k LOC Python · 9 модулей · 32-строчный pf ruleset · 22-строчный launchd plist | ## Уроки и статус ### Оставлю как есть - Negative-space gate как design-pattern — безопасность выраженная как отсутствие маршрута, не как watchdog. Default-deny + explicit allows бьёт реактивный watch-and-block по каждой важной метрике: race window, complexity, surfaces, которые должны «работать правильно». - Три независимых ring-buffer'а для sparklines (4 мин · 1 час · сессия, при 1pt на 3s / 45s / 3600s) — каждый tier отвечает на свой вопрос. Один buffer с downsampling'ом либо теряет granularity на коротких диапазонах, либо compression на длинных; три buffer'а, каждый под свой time scale, дают лучшее чтение на любом zoom'е. - enable отделён от session-start команды — boot persistence ортогональное property, не side-effect connect-команды. Один install — каждая сессия остаётся тривиальной. ### Поменял бы - Fetch-once-at-start был неверным допущением про стабильность подписки — server URL это часть того, что provider rotate'ит, а не константа на сессию. Health-check на stalled downloads с re-fetch — форма, которую я бы построил первой сейчас: subscription-watch, не subscription-fetch. - APP_GROUPS — manual dict в config.py. Добавление приложения — это ps -eo comm | grep и ручное редактирование файла. Дешёвый upgrade: интерактивный mvpn add-app, который показывает запущенные процессы, даёт пометить, какие гнать через VPN, и переписывает dict. - launchd plist хардкодит абсолютный путь до killswitch.pf.conf. Работает для одного пользователя. Правильная форма — enable подставляющий $HOME в plist-template до копирования; стоит несколько строк, делает тулинг portable на любой аккаунт. Personal tool · daily driver. Скоуп намеренно ограничен; auto-recovery для ротации подписки и multi-host failover остаётся возможным следующим срезом. Code walkthrough по запросу. --- Источник: https://ilyadev.xyz/cases/macos-vpn (HTML) · /cases/macos-vpn.ru.txt (этот файл) Назад: 04 — Bullet Reign · Roblox → https://ilyadev.xyz/cases/roblox-game.ru.txt Дальше: 06 — Сайт-портфолио → https://ilyadev.xyz/cases/portfolio-site.ru.txt Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.txt