# macOS VPN · توجيه لكل تطبيق

`05 · macos-vpn · Personal tool`

أداة routing لكل تطبيق على macOS مع fail-shut pf gate وlaunchd boot persistence وlive traffic monitoring.

**النطاق:** Solo · مستمر  
**الدور:** macOS systems engineering · CLI tooling

---

## السياق

> الـ kill-switch ليس watchdog. إنه غياب route.

Developer tooling يحتاج egress IP ثابت لكل تطبيق — التطبيق المحمي يجب أن يخرج دائما عبر proxy node نفسها، وكل process آخر يبقى direct. VPN clients الجاهزة تنفق كل شيء؛ split-tunneling حسب CIDR/DNS لا يساعد عندما يحتاج نفس destination إلى proxy لتطبيق وdirect لآخر.

للمسار المحمي، fail-open بعد سقوط tunnel أخطر من السقوط نفسه. watchdog kill-switch يترك race window بين خروج sing-box ورد فعل watchdog؛ هذه النافذة لا يحتملها المسار المحمي.

## حقائق

| | |
|---|---|
| **النطاق** | Solo · daily driver |
| **الأسطح** | macOS CLI · self-installing pf anchor · launchd boot persistence |
| **Routing** | sing-box TUN + process_name dispatch · 3 protected app groups |
| **Protocols** | VLESS+Reality (TCP stealth) · Shadowsocks (TCP+UDP) — اختيار لكل session |
| **Monitoring** | Clash API @ 127.0.0.1:9090 · per-app speed · 3-tier sparklines |
| **الحالة** | أداة شخصية · daily driver |

## المعمارية

### Per-app routing عبر TUN

```text
   Protected processes                    every other process
   (3 app groups · process_name match)        │
        │                                     │
        ▼                                     ▼
   sing-box  TUN-mode  (utun99 · 172.19.0.1/30)
        │  intercepts ALL system traffic
        │
        │  route.rules:
        │    if process_name in VPN_PROCESSES  →  outbound: proxy
        │    else                              →  outbound: direct
        ▼
   ┌─── proxy outbound ──── VPN provider ──── internet
   │
   └─── direct outbound ──── en0 ──── internet
```

**Dispatch حسب process لا destination.** قواعد sing-box تطابق `process_name`، لا IP ولا domain. فضاءان شبكيان يتشاركان host واحدا: التطبيقات المحمية تخرج دائما عبر proxy، والبقية direct — حتى إذا وصلا إلى destination نفسه.

**TUN يعترض كل شيء.** sing-box يعمل في TUN mode مع `auto_route: true` — كل packet من كل process تمر عبر utun99. بدون هذا، dispatch حسب process_name غير ممكن: kernel route tables تفكر في destination لا في owner العملية.

**DNS split داخل sing-box.** العمليات المحمية تستخدم DoH عبر proxy-dns، والبقية direct-dns. قاعدة DNS hijack تعترض system DNS قبل أن يغادر الجهاز. بدون DNS per-process، resolution للـ VPN host يتسرب عبر resolver المحلي.

### Negative-space pf gate · state matrix

```text
  state             en0 traffic owner    fires                outcome
  ─────             ─────────────────    ─────                ───────
  VPN running       sing-box (root)      pass quick user 0    OK · proxy + direct work
  VPN crashed       user-app (uid≠0)     block drop default   no internet for user
  boot, no mvpn     user-app (uid≠0)     block drop default   no internet for user
  mvpn  (root)      mvpn (root)          pass quick user 0    subscription / pings work

  rules in /etc/pf.conf via 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 ولا loop يراقب sing-box ثم يستدعي pfctl. قاعدة block محملة مسبقا؛ تعمل كلما حاول socket غير root الكتابة إلى interface فعلي. عندما يموت sing-box تفشل user-apps مغلقة by default.

**Explicit allows لمرحلة discovery.** lo0 وTUN وDNS وDHCP وmDNS يجب أن تمر حتى يبدأ أول اتصال. بدون DNS لا يستطيع `sudo mvpn` الأول حل host، وبدون DHCP لا يحصل boot جديد على IP. الحظر يخص TCP/UDP غير root على Ethernet.

**مطابقة عدة interfaces.** القواعد تطبق على en0 + en4 + en5 + en6 — Wi-Fi وثلاث فتحات USB-Ethernet. توصيل هاتف tethered أو USB-C hub لا يتجاوز gate.

### Protocol picker · kill-switch active

![Protocol selection screen — VLESS+Reality (TCP-only stealth) vs Outline / Shadowsocks (TCP+UDP). Default: Outline.](https://ilyadev.xyz/private/macos-vpn-start.webp)

*Every start: stealth or full transport*

![Banner shown when sing-box stops — "INTERNET BLOCKED — kill switch active" with recovery commands listed below.](https://ilyadev.xyz/private/macos-vpn-killswitch.webp)

*Fail-shut state surfaced explicitly*

**الافتراضي: Shadowsocks.** Outline/Shadowsocks يدعم TCP+UDP — gaming وvoice وvideo calls تعمل. VLESS+Reality خيار stealth عندما لا يلزم transport كامل. الاختيار لكل session وليس تلقائيا.

**Recovery بثلاث أوامر.** `sudo mvpn` يعيد الاتصال ويجلب subscription ويختار server. `sudo mvpn kill-apps` يغلق العمليات المحمية قبل فتح gate. `sudo mvpn disable` يزيل pf anchor — internet يعود direct، لكن أي app محمي سيخرج direct إذا بقي مفتوحا.

**Disable يؤكد قبل الفتح.** `sudo mvpn disable` يفحص العمليات المحمية الحية قبل إزالة anchor. إذا وجدها يسأل عن قتلها؛ الرفض يعني أن المستخدم يغلقها يدويا قبل فتح gate. بدون prompt، disable كان سينقل traffic المحمي إلى 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 · no internet for user-apps
        ▼
   user runs  sudo mvpn
        │  fetch subscription  (root → pass quick)
        │  parallel TCP ping → pick best server
        │  generate config.json  →  sing-box check  →  start
        ▼
   sing-box up  (PIDFILE written, TUN online)
        │  process_name dispatch live
        │  Clash API on :9090 ready
        ▼
   live-status loop  (urllib → /connections → render)
```

**enable وmvpn أمران مستقلان.** `sudo mvpn enable` يعمل مرة لكل جهاز: ينسخ plist إلى LaunchDaemons، ويسجل launchd daemon، ويثبت pf anchor في `/etc/pf.conf`. `sudo mvpn` يعمل لكل session: subscription → ping → start. بعد enable، كل reboot يحجب internet حتى تشغيل `sudo mvpn`.

**pf anchor يثبت نفسه.** `pf.py:_ensure_anchor_in_main()` يقرأ `/etc/pf.conf` ويضيف anchor إذا غاب ثم يعيد تحميل pfctl. العملية idempotent؛ إعادة enable لا تغير شيئا إذا السطر موجود. disable يزيله عبر `_remove_anchor_from_main()`.

**سجل pf وقت boot.** launchd يكتب stdout/stderr من pfctl إلى `pf-launch.log` — أول مكان للفحص إذا فشل تحميل anchor عند boot بسبب config تالف أو syntax error بعد تعديل يدوي.

## قرارات هندسية رئيسية

### 01 · Negative-space pf gate كـ kill-switch

**القرار.** pf rules تسمح لـ root traffic وlo0/utun/DNS/DHCP/mDNS، وتحظر default على en0/en4-6 لبقية TCP/UDP. kill-switch هو side-effect للـ default-deny، لا watchdog.

**لماذا.** watchdog reactive يترك race window. default-deny يجعل الأمان خاصية للنظام: لا شيء يجب أن يعمل حتى يطبق block.

**الكلفة.** pf يحتاج root ويعيش في /etc/pf.conf؛ debug no-internet يمر عبر pfctl لا app logs.

### 02 · process_name dispatch في TUN mode

**القرار.** sing-box TUN intercepts all traffic؛ route.rules ترسل VPN_PROCESSES إلى proxy والباقي direct.

**لماذا.** الحالة per-app static IP لا traffic-to-host. نفس domain يحتاج proxy من process وdirect من آخر؛ process_name هو discriminator الصحيح.

**الكلفة.** TUN intercepts everything وقائمة VPN_PROCESSES manual.

### 03 · بروتوكولان عند session start

**القرار.** كل sudo mvpn يختار VLESS+Reality أو Shadowsocks. default Shadowsocks، والتبديل stop+restart.

**لماذا.** Stealth وUDP متعارضان في هذا stack. VLESS TCP-only؛ Shadowsocks يدعم TCP+UDP. القرار security-relevant فلا يجب إخفاؤه.

**الكلفة.** فرعان outbound وprompt إضافي كل session.

### 04 · launchd-mounted pf anchor مع self-install

**القرار.** sudo mvpn enable يثبت plist في LaunchDaemons وpf anchor في /etc/pf.conf؛ كل reboot يعيد تحميل القواعد قبل user apps.

**لماذا.** الخطر بعد reboot؛ default يجب أن يكون blocked حتى يبدأ المستخدم VPN بوعي.

**الكلفة.** يكتب في /etc وplist hardcodes path؛ يحتاج templating ليصبح portable.

### 05 · Per-process DNS split داخل sing-box

**القرار.** proxy-dns عبر DoH through proxy للعمليات المحمية وdirect-dns للباقي، مع hijack-dns لكل system DNS.

**لماذا.** resolver واحد يسرّب مسار resolution. DoH through proxy يبقي lookup والconnection في نفس egress path.

**الكلفة.** قد يصطدم مع apps لديها DoH خاص، ويضيف latency لكل cold lookup.

## التقنيات

| | |
|---|---|
| **Runtime** | Python 3.12+ · macOS pf · launchd · sing-box (TUN mode) |
| **Protocols** | VLESS+Reality (TCP stealth) · Shadowsocks (TCP+UDP, Outline format) |
| **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 in /etc/pf.conf |
| **Scale** | ~2k LOC Python · 9 modules · 32-line pf ruleset · 22-line launchd plist |

## الدروس والحالة

### ما سأحمله للأمام

- Negative-space gate كdesign pattern — security كغياب route لا كوجود watchdog.
- ثلاث ring-buffers مستقلة للـ sparklines (4 min · 1 hour · session)؛ كل tier يجيب سؤالا مختلفا.
- enable منفصل عن session-start — boot persistence خاصية مستقلة لا side-effect للاتصال.

### ما سأغيره

- fetch-once-at-start افتراض خاطئ لاستقرار subscription؛ الشكل الصحيح subscription-watch مع re-fetch عند stalled downloads.
- APP_GROUPS dict يدوي. ترقية رخيصة: mvpn add-app interactive يعرض processes ويعيد كتابة dict.
- launchd plist hardcodes absolute path. enable يجب أن يملأ template بـ $HOME قبل النسخ.

أداة شخصية · daily driver. النطاق محدود عمدا؛ auto-recovery للدوران في subscription وmulti-host failover شريحة لاحقة ممكنة. Code walkthrough عند الطلب.

---

المصدر: https://ilyadev.xyz/cases/macos-vpn (HTML) · /cases/macos-vpn.ar.md (هذا الملف)
السابق: 04 — Bullet Reign · Roblox → https://ilyadev.xyz/cases/roblox-game.ar.md
التالي: 06 — Portfolio Site → https://ilyadev.xyz/cases/portfolio-site.ar.md
الفهرس: https://ilyadev.xyz/llms-ar.txt — قائمة دراسات الحالة الكاملة
المؤلف: إليا كازانتسيف — https://ilyadev.xyz/index.ar.md
