17 KiB
01. Архитектура — Montana Android v6.5.0
Высокоуровневая схема
┌─────────────────────────────────────────────────────────────────────┐
│ Android-устройство │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ quest.montana.vpn (APK 32 MB) │ │
│ │ ┌──────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ MainActivity │◀▶│ WebView │ │ │
│ │ │ (Kotlin) │ │ ↳ app.html + JS │ │ │
│ │ │ - JS bridge │ │ ↳ BIP39 derivation │ │ │
│ │ │ - VPN permission │ │ ↳ localStorage (seed) │ │ │
│ │ └──────────────────────┘ └────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ MontanaVpnService (foreground service) │ │ │
│ │ │ - tun0 интерфейс (172.19.0.1/30, default route) │ │ │
│ │ │ - xray-core (vless+reality outbound) │ │ │
│ │ │ - hev-socks5-tunnel (TUN ↔ SOCKS5) │ │ │
│ │ │ - HeartbeatThread (раз в 5 сек, через xray SOCKS5) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │
│ default route → tun0 │ heartbeat → SOCKS5 → Reality
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Helsinki (91.132.142.42:443) — публичный вход VPN │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ haproxy (TCP L4) │ │
│ │ ↳ stick-table type ip size 200k expire 24h │ │
│ │ ↳ balance leastconn │ │
│ │ ↳ распределяет на один из 3 backend │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ :40443 (fi) :40444 (fra) :40445 (us) │
│ xray-pinned-fi xray-pinned-fra xray-pinned-us │
│ ↓ freedom ↓ cascade ↓ cascade │
│ internet via via Reality via Reality │
│ 91.132.142.42 →89.19.208.158 →86.104.72.12 │
└─────────────────────────────────────────────────────────────────────┘
│ │
│ user traffic exits │ heartbeat exits
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Moscow (176.124.208.93) — координатор балансов (НЕ exit, НЕ VPN) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ nginx (443/8443) │ │
│ │ ↳ /api/vpn/heartbeat → :5008 │ │
│ │ ↳ /vpn/sub → :5008 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ montana-vpn-balance.service (Flask + gunicorn) │ │
│ │ ↳ /var/lib/montana-vpn-balance/balances.json │ │
│ │ ↳ atomic LOCK_EX через balances.json.lock │ │
│ │ ↳ purge inactive >30 days via /api/vpn/admin/purge │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ montana-node.service (TimeChain validator) │ │
│ │ ↳ ПОКА не интегрирован с балансами VPN (см. 07) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Компоненты Android-приложения
MainActivity.kt
Точка входа. Создаёт WebView, загружает app.html через loadDataWithBaseURL("https://montana.local/", ...) (синтетический base URL, наружу запросы не идут).
Связь с native:
MontanaApp.bip39()— выдаёт wordlist BIP39 EN (с SHA-256 integrity check, см.03-Криптография.md§2)MontanaApp.startVPN(address)— запрашивает разрешение VPN, стартуетMontanaVpnServiceMontanaApp.stopVPN()— останавливает сервисMontanaApp.isVpnRunning()/getBalance()/getSeconds()/getNode()/getStatus()— read-only state
WebView-debug включён только в DEBUG-сборках через BuildConfig.DEBUG.
MontanaVpnService.kt (foreground service)
Реализует Android VpnService. При старте:
- Запрашивает default network через
ConnectivityManager.requestNetwork()— дляsetUnderlyingNetworks(...)при смене WiFi/cellular. - Строит
VpnService.Builder:- MTU 1500
- Адрес
172.19.0.1/30 - DNS
1.1.1.1,8.8.8.8 - Default route
0.0.0.0/0 addDisallowedApplication(packageName)— критично: само приложение исключается из своего же VPN (иначе TUN-петля, см.07F-петля)
- Поднимает libv2ray (xray-core) с конфигом vless+reality к
cdn.montana.quest:443. - Запускает
HevSocks5TunnelServiceдля TUN ↔ SOCKS5 (127.0.0.1:10808). - HeartbeatThread раз в 5 сек: SOCKS5 → SSL (ALPN=
http/1.1) → POST/api/vpn/heartbeatна montana.quest. Heartbeat идёт через Reality cascade — backend видит exit-IP как источник.
app.html (WebView UI + кошелёк)
Single-page приложение. Три экрана: s-main, s-auth, s-recover, s-account.
Логика кошелька — целиком JS:
generateMnemonic()— 256 бит энтропии черезcrypto.getRandomValues, BIP39 24 слова с checksummnemonicToEntropy(words)— обратная операция + checksum validationmnemonicToSeed(mnemonic, "")— PBKDF2-HMAC-SHA512, 2048 итераций, 512 бит выводderiveAddr(words)—SHA-256("montana-v1:" || seed)[0..20]
Подробности — 03-Криптография.md §3.
Компоненты серверной части
Helsinki — публичный вход VPN
haproxy на 0.0.0.0:443 (TCP mode):
balance leastconn— новый клиент попадает на наименее загруженный backendstick-table type ip size 200k expire 24h, stick on src— закрепление source-IP за backend на 24 часа
Три локальных xray-инстанса:
xray-pinned-fiна127.0.0.1:40443— outboundfreedom, exit с Helsinkixray-pinned-fraна127.0.0.1:40444— outbound vless+reality cascade к89.19.208.158:443(Frankfurt)xray-pinned-usна127.0.0.1:40445— outbound vless+reality cascade к86.104.72.12:443(NewYork)
Все три inbound используют identical Reality params (privateKey, sni, shortId) → клиент не различает на стороне приложения.
Moscow — координатор балансов
Roles: API REST для heartbeat и subscription. Не выпускает VPN-трафик, не является exit-нодой.
montana-vpn-balance.service (Flask + gunicorn 2 workers × 4 threads):
POST /api/vpn/heartbeat— приём heartbeat, начисление 0.001 Ɉ/секGET /api/vpn/balance— read-only балансGET /vpn/sub— универсальный VLESS URL (base64)POST /api/vpn/admin/purge— localhost-only cleanup inactive
State: /var/lib/montana-vpn-balance/balances.json (JSON-файл).
Frankfurt, NewYork — exit-узлы каскада
Только принимают cascade traffic из Helsinki через Reality:443, выполняют freedom outbound в открытый интернет. Не общаются с Android-клиентом напрямую.
Потоки данных
Поток 1: Стандартный VPN-трафик
Browser/любое приложение Android
│
▼
Default route (Android system)
│
▼ (не если приложение исключено через addDisallowedApplication)
tun0 (172.19.0.1)
│
▼
hev-socks5-tunnel
│
▼ TCP/UDP → SOCKS5
127.0.0.1:10808 (xray inbound на устройстве)
│
▼ vless+reality
cdn.montana.quest:443 (= 91.132.142.42)
│
▼
Helsinki haproxy (stick on src)
│
▼
xray-pinned-{fi|fra|us}
│
▼ (для fra/us — vless+reality cascade)
Exit IP открытый интернет
Поток 2: Heartbeat кошелька
HeartbeatThread (Kotlin)
│
▼ socksConnect через 127.0.0.1:10808
│
▼ Тот же канал что Поток 1
Helsinki → backend exit → internet
│
▼ POST montana.quest:443/api/vpn/heartbeat
nginx (Moscow:443)
│
▼ proxy_pass
gunicorn (127.0.0.1:5008)
│
▼ atomic LOCK_EX read-modify-write
balances.json
│
▼ HTTP/1.1 200 application/json
{"balance": ..., "node": "newyork", "node_ip": "86.104.72.12", "via_montana": true}
│
▼ org.json.JSONObject (Kotlin)
MontanaVpnService.lastBalance / lastStatusText / connectedNode
│
▼ getBalance() / getStatus() bridge
WebView UI
Поток 3: Создание кошелька
User: tap "Войти в Монтану" → "Создать ключ"
│
▼ JS createKey()
crypto.getRandomValues(Uint8Array(32)) ← 256 бит энтропии
│
▼ entropyToMnemonic(entropy)
│ ↳ SHA-256(entropy) → checksum bits (первые 8 бит)
│ ↳ 264 бита / 11 = 24 слова
│ ↳ Каждое 11-битное число → index в BIP39 EN wordlist
│ (wordlist получен через MontanaApp.bip39() с SHA-256 verify)
│
▼ 24 слова
deriveAddr(words)
│ ↳ mnemonicToEntropy(words) — validate checksum
│ ↳ PBKDF2-HMAC-SHA512(words, salt="mnemonic", iter=2048) → 64B seed
│ ↳ SHA-256("montana-v1:" || seed) → 32B hash
│ ↳ first 20 bytes → hex → 40-символьный address
│
▼ (seed_words, address)
localStorage["m.seed"] = words.join(" ")
localStorage["m.addr"] = address ← внимание: оба в plain text!
Границы доверия
| Граница | Что доверяем | Что не доверяем |
|---|---|---|
| Android OS | Изоляция приложений (data dir 0700), Permission model, KeyStore (не используется!) | Root-доступ, malicious apps с MANAGE_EXTERNAL_STORAGE, forensic image |
| WebView Chromium | SubtleCrypto детерминизм (SHA-256, PBKDF2, NFKD) | Гарантии константного времени, защиту от подмены кода через debug |
| haproxy stick-table | Sticky-pin по source-IP до 24h | Защиту от смены клиентом IP (mobile network → IP меняется) |
| Reality TLS | Active probing resistance, fingerprint disguise | DPI который умеет анализировать SNI Echo, machine learning по timing |
| Moscow backend | Атомарный учёт balances.json | Подпись heartbeat (её сейчас нет — см. 07 F-2) |
| Cascade exit | Изоляция трафика разных пользователей | Логи трафика на exit-нодах (нет policy сейчас) |
Что приложение делает
- Создаёт / восстанавливает BIP39-кошелёк (24 слова английский wordlist).
- Запускает VPN через системный
VpnServiceAPI. - Поднимает локальный xray (libv2ray), пересылает весь TCP/UDP трафик устройства через Reality на Helsinki.
- Шлёт heartbeat backend для учёта времени онлайн → начисление 0.001 Ɉ/сек.
- Показывает баланс, статус, узел подключения, таймер сессии.
Что приложение НЕ делает
- Не интегрировано с консенсусом TimeChain (балансы у Moscow в JSON-файле, не у валидаторов).
- Не подписывает heartbeat криптографически (любой может слать heartbeat от чужого имени с правильного exit-IP).
- Не шифрует seed в localStorage (plain text 24 слова).
- Не использует Android KeyStore.
- Не требует backup confirmation при создании кошелька.
- Не имеет proper logout flow с предупреждением о необратимой потере.
Все эти ограничения — в 07-Известные-ограничения.md с severity и closure paths.