montana/Android/Внешний-аудит/04-Сетевой-слой.md

408 lines
14 KiB
Markdown
Raw Normal View History

2026-05-18 22:11:45 +03:00
# 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` и генерирует все конфиги.