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

14 KiB
Raw Blame History

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):

{
  "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 → 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:

@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/subhttp://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-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:

{"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 трафика

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