# 04. Сетевой слой VPN — Montana Android v6.5.0 ## §1. Топология Три узла обеспечивают exit, один (Helsinki) — публичный вход. Узел Moscow — координатор балансов, **не часть VPN-слоя**. | Узел | IP | Юрисдикция | Роль | Software | |------|-----|------------|------|----------| | Helsinki | 91.132.142.42 | Финляндия (THE.Hosting) | публичный вход + exit | haproxy + xray-pinned-{fi/fra/us} | | Frankfurt | 89.19.208.158 | Германия (Timeweb) | cascade exit | xray Reality :443 | | NewYork | 86.104.72.12 | США (THE.Hosting, Secaucus NJ) | cascade exit | xray Reality :443 | | Moscow | 176.124.208.93 | Россия | координатор балансов | nginx + Flask + montana-vpn-balance | **Публичный endpoint:** `cdn.montana.quest` → A `91.132.142.42` (Helsinki). --- ## §2. Универсальный VLESS-ключ Один ключ навсегда, для всех клиентов: ``` vless://e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d@cdn.montana.quest:443 ?flow=xtls-rprx-vision &type=tcp &headerType=none &security=reality &fp=chrome &sni=www.googletagmanager.com &pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8 &sid=302805bc0c25e504 #Ɉ Монтана ``` Опубликован также через `GET https://montana.quest/vpn/sub` (base64-encoded). --- ## §3. Helsinki haproxy — sticky pinning **Конфиг** `/etc/haproxy/haproxy.cfg`: ``` frontend reality_in bind :443 default_backend pinned_exits backend pinned_exits balance leastconn stick-table type ip size 200k expire 24h stick on src option redispatch retries 2 option tcp-check server fi 127.0.0.1:40443 check inter 3s fall 2 rise 2 server fra 127.0.0.1:40444 check inter 3s fall 2 rise 2 server us 127.0.0.1:40445 check inter 3s fall 2 rise 2 ``` **Поведение:** 1. Новое TCP-соединение с уникальным source-IP → `leastconn` выбирает backend с минимальной активной нагрузкой → запись в stick-table. 2. Следующее соединение с тем же source-IP в течение 24 часов → routes на тот же backend (sticky). 3. Backend помечается DOWN если 2 TCP-check fail подряд → новые соединения с этого IP → redispatch на следующий best. **Empirical verification** (10 запросов с одного IP подряд): ``` 1 89.19.208.158 ← Frankfurt 2 89.19.208.158 3 89.19.208.158 4 89.19.208.158 5 89.19.208.158 6 89.19.208.158 7 89.19.208.158 8 89.19.208.158 9 89.19.208.158 10 89.19.208.158 ``` 10/10 на Frankfurt. Sticky pin работает корректно. --- ## §4. Три xray-pinned инстанса **Общий inbound** (identical в трёх файлах `/etc/xray-pinned/{fi,fra,us}.json`): ```json { "tag": "reality-in", "listen": "127.0.0.1", "port": 40443 /* или 40444, 40445 */, "protocol": "vless", "settings": { "clients": [ {"id": "e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d", "email": "universal", "flow": "xtls-rprx-vision"} ], "decryption": "none" }, "streamSettings": { "network": "tcp", "security": "reality", "realitySettings": { "show": false, "dest": "www.googletagmanager.com:443", "xver": 0, "serverNames": ["www.googletagmanager.com"], "privateKey": "cL7D6FCqH5nWcQlHCKH9uNr-RNwCt5peRAqt8tl9mXs", "shortIds": ["302805bc0c25e504"] } } } ``` **Различающиеся outbound:** | Backend | Port | Outbound | Exit IP | |---------|------|----------|---------| | fi | 40443 | `freedom` | 91.132.142.42 (сам Helsinki) | | fra | 40444 | vless+reality cascade к `89.19.208.158:443` | 89.19.208.158 | | us | 40445 | vless+reality cascade к `86.104.72.12:443` | 86.104.72.12 | **Routing rule** (одинакова в трёх): ```json {"type": "field", "inboundTag": ["reality-in"], "outboundTag": "cascade-{fi|fra|us}"} ``` --- ## §5. Reality cascade: ключи нижних exit-узлов Helsinki cascade outbound к Frankfurt: ```json { "address": "89.19.208.158", "port": 443, "users": [{"id": "e80af4df-8d46-413a-ad28-0f6bf2a300b8", "encryption": "none", "flow": "xtls-rprx-vision"}], "realitySettings": { "publicKey": "8MYYI4RX3Ra8ICkuqwexMhA5q1EWi87M1G0tX-h0iiM", "shortId": "97688ead4874632a", "serverName": "www.googletagmanager.com", "fingerprint": "chrome", "spiderX": "/" } } ``` Helsinki cascade outbound к NewYork: ```json { "address": "86.104.72.12", "port": 443, "users": [{"id": "b17dd919-772d-4268-a724-9b866b92d12b", "encryption": "none", "flow": "xtls-rprx-vision"}], "realitySettings": { "publicKey": "Sl4UZi0RTTYemu7-NAm-bI3M1DUzidqa_jn2eVqvVA8", "shortId": "b4a1b7eada8a4949" } } ``` **Reality public keys** — derived from private keys через `xray x25519 -i `: - Helsinki: privkey `cL7D6F…uXs` → pubkey `EkTs2aGKnFNg…gD8` - Frankfurt: privkey `6BhPiN…nUw` → pubkey `8MYYI4RX…iiM` - NewYork: privkey `oJlJ1i…fGY` → pubkey `Sl4UZi0RTTYe…VA8` Все три privkey хранятся в `/usr/local/etc/xray/config.json` на соответствующем узле, права 0640 owner xray, **не публичны**. --- ## §6. Subscription endpoint `/vpn/sub` Реализация `/opt/montana-vpn-balance/app.py`: ```python @app.route("/vpn/sub", methods=["GET"]) def vpn_sub_universal(): link = ( "vless://e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d@cdn.montana.quest:443" "?flow=xtls-rprx-vision&type=tcp&headerType=none&security=reality" "&fp=chrome&sni=www.googletagmanager.com" "&pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8" "&sid=302805bc0c25e504" "#" + quote("Ɉ Монтана") ) enc = base64.b64encode(link.encode()).decode() resp = app.response_class(enc, mimetype="text/plain; charset=utf-8") resp.headers["Cache-Control"] = "no-store" return resp ``` nginx proxy_pass: `location = /vpn/sub` → `http://127.0.0.1:5008`. Отдаёт **тот же** ключ всем клиентам. Sticky pinning делается на haproxy stick-table, не на уровне ключа. --- ## §7. Маршрутизация Android default route → tun0 При старте `MontanaVpnService.startVpn()`: ```kotlin val builder = Builder() .setSession("Montana") .setMtu(1500) .addAddress("172.19.0.1", 30) .addDnsServer("1.1.1.1") .addDnsServer("8.8.8.8") .addRoute("0.0.0.0", 0) builder.addDisallowedApplication(packageName) // critical: не петля if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { builder.setMetered(false) } val tunFd = builder.establish() ?: throw IllegalStateException(...) ``` Результат — Android помещает default route через `tun0` (172.19.0.1) **для всех приложений кроме quest.montana.vpn**. **Empirical verification:** ``` $ adb shell ip route show table all | grep tun0 default dev tun0 table 1119 proto static scope link 172.19.0.0/30 dev tun0 table 1119 proto static scope link ``` --- ## §8. TUN ↔ SOCKS5 bridge (hev-socks5-tunnel) `HevSocks5TunnelService.TProxyStartService(yaml_config_path, tun_fd)` запускает JNI-обёртку над hev-socks5-tunnel. YAML config: ```yaml tunnel: mtu: 1500 ipv4: 172.19.0.1 socks5: port: 10808 address: 127.0.0.1 udp: 'udp' misc: tcp-read-write-timeout: 300000 udp-read-write-timeout: 60000 log-level: warn ``` Каждый TCP/UDP пакет из TUN → конвертируется в SOCKS5 CONNECT → передаётся локальному xray (127.0.0.1:10808) → Reality outbound. --- ## §9. Локальный xray на устройстве (libv2ray) Конфиг см. `09-Инвентаризация-кода.md` §3. Ключевые поля: - `inbounds.socks.port = 10808` — для hev-socks5-tunnel - `inbounds.http.port = 10809` — резерв - `inbounds.dns-in.port = 10853` — DNS-через-VPN - `outbounds.proxy.protocol = vless` к `cdn.montana.quest:443` - `outbounds.direct.protocol = freedom` (для private IPs) - `outbounds.block.protocol = blackhole` (для bittorrent) - `outbounds.dns-out.protocol = dns` (DNS to 1.1.1.1) Routing: ```json {"type": "field", "inboundTag": ["dns-in"], "outboundTag": "dns-out"}, {"type": "field", "port": "53", "outboundTag": "dns-out"}, {"type": "field", "ip": ["geoip:private"], "outboundTag": "direct"}, {"type": "field", "protocol": ["bittorrent"], "outboundTag": "block"} ``` По умолчанию (нет matching rule) → первый outbound `proxy` (Reality к Helsinki). --- ## §10. Heartbeat path **Source:** `MontanaVpnService.sendHeartbeat()` в HeartbeatThread (раз в 5 сек). ``` Kotlin Socket TCP к 127.0.0.1:10808 (локальный xray SOCKS5) │ SOCKS5 CONNECT request: montana.quest:443 ▼ xray inbound socks принимает CONNECT │ routing → outbound proxy (Reality к cdn.montana.quest:443) ▼ Reality TLS-1.3 handshake (X25519 + SHAKE) │ inner: vless framing ▼ Helsinki haproxy → stick-on-src → один из {fi/fra/us} │ для fra/us: дополнительный Reality cascade ▼ Exit узел: freedom outbound │ TCP к montana.quest:443 (= 176.124.208.93) │ source-IP = exit IP (91.132.142.42 / 89.19.208.158 / 86.104.72.12) ▼ nginx (Moscow:443) → proxy_pass → gunicorn :5008 │ /api/vpn/heartbeat ▼ flask: проверяет request.remote_addr ∈ MONTANA_NODES │ да → начисляет 0.001 × elapsed_seconds Ɉ │ нет → возвращает {ok:false, reason:"not_via_montana_vpn"} ▼ JSON response → exit → cascade back → Helsinki → Reality → xray → SOCKS5 → Kotlin │ org.json.JSONObject parse ▼ MontanaVpnService.lastBalance / lastStatusText / connectedNode ``` **ALPN pinned:** `arrayOf("http/1.1")` (см. `03-Криптография.md` §7). --- ## §11. Sticky vs cascade поведение При **первом** heartbeat нового устройства: 1. Source-IP клиента появляется в stick-table впервые 2. haproxy выбирает least-loaded backend (например, fra) 3. Запись: `IP → fra, expire = now + 24h` При **последующих** heartbeats: 4. Источник тот же IP → лукап в stick-table → fra → routes на 127.0.0.1:40444 5. xray-pinned-fra → cascade к Frankfurt → exit IP 89.19.208.158 6. backend всегда видит источник 89.19.208.158 → `node="frankfurt"` в response **Если клиент сменил IP** (Wi-Fi → cellular): 7. Новый IP отсутствует в stick-table → шаги 1-3 повторяются 8. Возможно landing на другой backend → exit IP другой Это **не баг**, это design. Закрепление до 24 часов с момента последнего соединения. --- ## §12. Backend защита от не-Montana трафика ```python MONTANA_NODES = { "91.132.142.42": ("helsinki", "Хельсинки"), "89.19.208.158": ("frankfurt", "Франкфурт"), "86.104.72.12": ("newyork", "Нью-Йорк"), } def heartbeat(): ip = client_ip() # X-Forwarded-For или request.remote_addr entry = MONTANA_NODES.get(ip) if not entry: return jsonify({ "ok": False, "reason": "not_via_montana_vpn", "via_montana": False, ... }), 200 ... ``` Если клиент пытается слать heartbeat не через VPN — backend отказывает в начислении. **Слабость:** это **не cryptographic authentication**. Атакующий может поднять Montana VPN и слать с любого address (см. `02` атакующий E и `07` F-2 блокер). Не решается на уровне sticky pinning — требует Falcon-подписи в M-VPN-3. --- ## §13. Disabled VPN cascade Moscow **На 2026-04-29** старый xray на Moscow (отдельный сервис `xray@config.service`) был отключён: ``` $ systemctl is-active xray@config.service inactive ``` Moscow **не** маршрутизирует VPN-трафик. На Helsinki/Frankfurt/US в конфигах xray **нет** ссылок на `176.124.208.93`: ``` $ ssh montana-finland 'grep "176.124.208.93" /usr/local/etc/xray/*.json' (пусто) ``` --- ## §14. Failover поведение Если падает один из exit-узлов: 1. haproxy TCP-check (`inter 3s fall 2`) обнаружит за ~6 секунд 2. backend помечается DOWN 3. `option redispatch` — новые TCP с sticked source-IP → перенаправляются на следующий best (по leastconn) 4. Stick-table entry обновляется на новый backend Существующие connections к упавшему backend разрываются → клиентский xray делает reconnect → новое TCP → новый backend. Recovery: когда узел снова up → 2 успешных TCP-check (`rise 2`) → возвращается в pool. **Empirically не тестировалось** в этой сессии — рекомендуется failure injection тест перед mainnet (см. `10-Покрытие-тестами.md`). --- ## §15. Сводка SSOT нарушений (см. `07` CF-4) UUID `e6d355e2…`, publicKey `EkTs2aGKn…`, shortId `302805bc…` встречаются в: | Локация | Hits | Назначение | |---------|------|------------| | Kotlin xray inbound config (MontanaVpnService.kt) | 3 | клиентский конфиг | | Helsinki `/usr/local/etc/xray/config.json` (legacy) | 4 | старый xray (сейчас не используется) | | Helsinki `/etc/xray-pinned/{fi,fra,us}.json` | 9 (3×3) | три backend inbound | | Moscow `/opt/montana-vpn-balance/app.py` | 3 | `/vpn/sub` endpoint | **Итого: 19 копий одних и тех же параметров.** При ротации ключа требуется одновременное обновление 19 мест. Closure path — Phase 2 `mt-vpn-config-gen` rust binary читает SSOT из `mt-genesis::ProtocolParams::reality_keys` и генерирует все конфиги.