408 lines
14 KiB
Markdown
408 lines
14 KiB
Markdown
|
|
# 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` и генерирует все конфиги.
|