265 lines
17 KiB
Markdown
265 lines
17 KiB
Markdown
|
|
# 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, стартует `MontanaVpnService`
|
|||
|
|
- `MontanaApp.stopVPN()` — останавливает сервис
|
|||
|
|
- `MontanaApp.isVpnRunning()` / `getBalance()` / `getSeconds()` / `getNode()` / `getStatus()` — read-only state
|
|||
|
|
|
|||
|
|
WebView-debug включён только в DEBUG-сборках через `BuildConfig.DEBUG`.
|
|||
|
|
|
|||
|
|
### MontanaVpnService.kt (foreground service)
|
|||
|
|
|
|||
|
|
Реализует Android `VpnService`. При старте:
|
|||
|
|
|
|||
|
|
1. **Запрашивает default network** через `ConnectivityManager.requestNetwork()` — для `setUnderlyingNetworks(...)` при смене WiFi/cellular.
|
|||
|
|
2. **Строит `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-петля, см. `07` F-петля)
|
|||
|
|
3. **Поднимает libv2ray (xray-core)** с конфигом vless+reality к `cdn.montana.quest:443`.
|
|||
|
|
4. **Запускает `HevSocks5TunnelService`** для TUN ↔ SOCKS5 (127.0.0.1:10808).
|
|||
|
|
5. **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 слова с checksum
|
|||
|
|
- `mnemonicToEntropy(words)` — обратная операция + checksum validation
|
|||
|
|
- `mnemonicToSeed(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` — новый клиент попадает на наименее загруженный backend
|
|||
|
|
- `stick-table type ip size 200k expire 24h, stick on src` — закрепление source-IP за backend на 24 часа
|
|||
|
|
|
|||
|
|
Три локальных xray-инстанса:
|
|||
|
|
- `xray-pinned-fi` на `127.0.0.1:40443` — outbound `freedom`, exit с Helsinki
|
|||
|
|
- `xray-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 сейчас) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Что приложение делает
|
|||
|
|
|
|||
|
|
1. Создаёт / восстанавливает BIP39-кошелёк (24 слова английский wordlist).
|
|||
|
|
2. Запускает VPN через системный `VpnService` API.
|
|||
|
|
3. Поднимает локальный xray (libv2ray), пересылает весь TCP/UDP трафик устройства через Reality на Helsinki.
|
|||
|
|
4. Шлёт heartbeat backend для учёта времени онлайн → начисление 0.001 Ɉ/сек.
|
|||
|
|
5. Показывает баланс, статус, узел подключения, таймер сессии.
|
|||
|
|
|
|||
|
|
## Что приложение НЕ делает
|
|||
|
|
|
|||
|
|
1. Не интегрировано с консенсусом TimeChain (балансы у Moscow в JSON-файле, не у валидаторов).
|
|||
|
|
2. Не подписывает heartbeat криптографически (любой может слать heartbeat от чужого имени с правильного exit-IP).
|
|||
|
|
3. Не шифрует seed в localStorage (plain text 24 слова).
|
|||
|
|
4. Не использует Android KeyStore.
|
|||
|
|
5. Не требует backup confirmation при создании кошелька.
|
|||
|
|
6. Не имеет proper logout flow с предупреждением о необратимой потере.
|
|||
|
|
|
|||
|
|
Все эти ограничения — в `07-Известные-ограничения.md` с severity и closure paths.
|