montana/Android/Внешний-аудит/04-Сетевой-слой.md
2026-05-18 22:11:45 +03:00

408 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <privкey>`:
- 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` и генерирует все конфиги.