14 KiB
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
Поведение:
- Новое TCP-соединение с уникальным source-IP →
leastconnвыбирает backend с минимальной активной нагрузкой → запись в stick-table. - Следующее соединение с тем же source-IP в течение 24 часов → routes на тот же backend (sticky).
- 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):
{
"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 (одинакова в трёх):
{"type": "field", "inboundTag": ["reality-in"], "outboundTag": "cascade-{fi|fra|us}"}
§5. Reality cascade: ключи нижних exit-узлов
Helsinki cascade outbound к Frankfurt:
{
"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:
{
"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→ pubkeyEkTs2aGKnFNg…gD8 - Frankfurt: privkey
6BhPiN…nUw→ pubkey8MYYI4RX…iiM - NewYork: privkey
oJlJ1i…fGY→ pubkeySl4UZi0RTTYe…VA8
Все три privkey хранятся в /usr/local/etc/xray/config.json на соответствующем узле, права 0640 owner xray, не публичны.
§6. Subscription endpoint /vpn/sub
Реализация /opt/montana-vpn-balance/app.py:
@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():
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:
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-tunnelinbounds.http.port = 10809— резервinbounds.dns-in.port = 10853— DNS-через-VPNoutbounds.proxy.protocol = vlessкcdn.montana.quest:443outbounds.direct.protocol = freedom(для private IPs)outbounds.block.protocol = blackhole(для bittorrent)outbounds.dns-out.protocol = dns(DNS to 1.1.1.1)
Routing:
{"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 нового устройства:
- Source-IP клиента появляется в stick-table впервые
- haproxy выбирает least-loaded backend (например, fra)
- Запись:
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 трафика
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-узлов:
- haproxy TCP-check (
inter 3s fall 2) обнаружит за ~6 секунд - backend помечается DOWN
option redispatch— новые TCP с sticked source-IP → перенаправляются на следующий best (по leastconn)- 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 и генерирует все конфиги.