# 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 <name> и ручного редактирования. Нет 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 <name> и ручное редактирование файла. Дешёвый 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.md (этот файл)
Назад: 04 — Bullet Reign · Roblox → https://ilyadev.xyz/cases/roblox-game.ru.md
Дальше: 06 — Сайт-портфолио → https://ilyadev.xyz/cases/portfolio-site.ru.md
Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов
Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.md
