# 09. Инвентаризация кода — Montana Android v6.5.0 ## Структура проекта ``` Montana/Android/MontanaApp/ ├── app/ │ ├── build.gradle.kts (50 строк) — версии, deps, signing │ ├── src/main/ │ │ ├── AndroidManifest.xml (50 строк) — permissions, service decl │ │ ├── java/quest/montana/app/ │ │ │ ├── MainActivity.kt (90 строк) — WebView host │ │ │ └── MontanaVpnService.kt (450 строк) — VPN service + heartbeat │ │ ├── assets/ │ │ │ ├── app.html (~400 KB, основная масса = base64 images) │ │ │ ├── bip39-en.txt (13 KB) │ │ │ ├── geoip.dat, geosite.dat (xray data) │ │ │ ├── symbol.jpg, juno.jpg, juno-back.jpg │ │ ├── jniLibs/ (native xray + hev-socks5-tunnel) │ │ └── res/ │ │ ├── values/strings.xml │ │ ├── values/themes.xml │ │ └── mipmap-*/ic_launcher.* └── settings.gradle.kts ``` ## Файл-за-файлом разбор ### MainActivity.kt **Назначение:** Activity-точка входа. Создаёт WebView, грузит app.html, прокидывает JS bridge `MontanaBridge`. **Ключевые элементы:** ```kotlin class MainActivity : AppCompatActivity() { companion object { @Volatile var instance: MainActivity? = null } lateinit var web: WebView private val PREP_VPN = 991 override fun onCreate(savedInstanceState: Bundle?) { // создаёт WebView, JavaScriptEnabled, добавляет MontanaBridge, // загружает app.html через loadDataWithBaseURL("https://montana.local/", ...) // WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) } override fun onDestroy() { // tearDown WebView корректно: stopLoading, removeJavascriptInterface, // detach view, web.destroy(), clear instance } fun requestVpnPermissionAndStart(address: String) { ... } fun startVpnService(address: String) { ... } fun stopVpnService() { ... } } class MontanaBridge(activity: MainActivity) { @JavascriptInterface fun version(): String @JavascriptInterface fun bip39(): String // с SHA-256 integrity check @JavascriptInterface fun platform(): String = "android" @JavascriptInterface fun isNative(): Boolean = true @JavascriptInterface fun isVpnRunning(): Boolean @JavascriptInterface fun getBalance(): Double @JavascriptInterface fun getSeconds(): Double @JavascriptInterface fun getStatus(): String @JavascriptInterface fun getNode(): String @JavascriptInterface fun isOnline(): Boolean @JavascriptInterface fun startVPN(address: String): Boolean @JavascriptInterface fun stopVPN(): Boolean } ``` **Security-relevant методы:** - `bip39()` — integrity check SHA-256 (см. `03-Криптография.md` §2) - `startVPN(address)` — единственная точка инициации VPN, требует Android VPN permission ### MontanaVpnService.kt **Назначение:** `VpnService` который поднимает TUN-интерфейс, запускает xray + hev-socks5-tunnel, шлёт heartbeats. **Структура:** ```kotlin class MontanaVpnService : VpnService() { companion object { // constants: TAG, NOTI_ID, CHAN_ID, ACTION_START/STOP, EXTRA_ADDRESS // SOCKS_HOST = "127.0.0.1", SOCKS_PORT = 10808 // BACKEND_HOST = "montana.quest", BACKEND_PORT = 443 // @Volatile state: isRunning, lastBalance, lastSeconds, lastStatusText, connectedNode, viaMontana } private var tun: ParcelFileDescriptor? = null private var core: CoreController? = null // xray-core private var hbThread: Thread? = null private var address: String = "" private var netCallbackRegistered = false private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager } private val defaultNetworkRequest by lazy { NetworkRequest.Builder()... } private val defaultNetworkCallback = object : NetworkCallback() { ... } override fun onCreate() { ensureNotiChannel(); Libv2ray.initCoreEnv(...); } override fun onStartCommand(...) { startVpn() либо stopVpn() } override fun onDestroy() { stopVpn() } override fun onRevoke() { stopVpn() } private fun startVpn() { // 1. requestNetwork для setUnderlyingNetworks // 2. VpnService.Builder с addDisallowedApplication(self) // 3. builder.establish() → tunFd // 4. libv2ray startLoop с xray config // 5. hev-socks5-tunnel start // 6. HeartbeatThread spawn } private fun stopVpn() { ... корректный teardown в reverse order ... } private fun socksConnect(host: String, port: Int, timeoutMs: Int = 10000): Socket { // SOCKS5 handshake к локальному xray:10808 // 1. TCP connect // 2. Send 0x05 0x01 0x00 (no auth) // 3. Receive 0x05 0x00 // 4. Send 0x05 0x01 0x00 0x03 // 5. Parse response, return connected Socket } private fun sendHeartbeat() { // socksConnect montana.quest:443 // SSL wrap с ALPN pin http/1.1 // POST /api/vpn/heartbeat // org.json.JSONObject parse response // Update Companion state } private fun buildXrayConfig(): String { ... возвращает JSON ... } private fun ensureNotiChannel() { ... } private fun buildNotification(text: String): Notification { ... } private fun startForegroundNoti(text: String) { ... } private fun updateNoti(text: String) { mgr.notify(NOTI_ID, ...) } } ``` **Security-relevant функции:** - `startVpn()` — `addDisallowedApplication(packageName)` критично для разрыва TUN-петли - `sendHeartbeat()` — через SOCKS5 в локальный xray, ALPN pin = http/1.1, JSON parse через org.json - `socksConnect()` — ручной SOCKS5 handshake без библиотек (auditable) ### app.html (single-page) Не приложу полностью (400 KB включая base64 images). Ключевые JS-функции: ```javascript // Constants const L_S='m.seed', L_A='m.addr'; const RATE = 12.04; // Ɉ → ₽ visual rate const isNative = !!window.MontanaApp; // State let st = {addr: null, online: false, bal: 0, sec: 0}; let BIP39_WORDS = null; // Screen navigation function show(n) { ... } // BIP39 async function loadBip39() { ... через MontanaApp.bip39() + check size 2048 } async function entropyToMnemonic(entropy) { ... } async function mnemonicToEntropy(words) { ... checksum validate } async function mnemonicToSeed(mnemonic, passphrase) { ... PBKDF2-HMAC-SHA512 } async function deriveAddr(words) { ... SHA-256("montana-v1:" || seed)[0:20] } async function generateMnemonic() { ... 256-bit entropy } // Wallet operations async function createKey() { ... } async function recover() { ... } function showSeed() { ... render 24 numbered words } function hideSeed() { ... } function logoutAcc() { ... localStorage.removeItem ... } // UI updates function render() { ... bn, bf elements } function setUI(on, msg) { ... .power class, lbl, coin .on } function applyAuth() { ... login-btn label } function onLoginTap() { show('auth') либо show('account') } // Polling function pollUI() { ... } function pollSync() { ... } function tick() { // counter +0.001/sec + uptime hh:mm:ss } function startPoll() / startTick() { setInterval } // Boot (async() => { let addr = localStorage.getItem(L_A); if (addr) st.addr = addr; applyAuth(); show('main'); startPoll(); startTick(); })(); // Auto-VPN если ещё не запущен if (isNative) setTimeout(() => { if (!MontanaApp.isVpnRunning()) toggle() }, 700); ``` **Security-relevant:** - `loadBip39()` через native bridge с integrity check - `mnemonicToEntropy()` — единственное место validate BIP39 checksum - `mnemonicToSeed()` — WebCrypto PBKDF2 (не custom) - `deriveAddr()` — детерминирован, проверка checksum включена ### AndroidManifest.xml ```xml ``` **Permissions justification:** - INTERNET — heartbeat + xray outbound - ACCESS/CHANGE_NETWORK_STATE — `setUnderlyingNetworks` для cellular/wifi switching - FOREGROUND_SERVICE_SPECIAL_USE — Android 14+ требует для VPN - POST_NOTIFICATIONS — Android 13+ требует для foreground noti - WAKE_LOCK — keep VPN active при screen off - RECEIVE_BOOT_COMPLETED — `usesCleartextTraffic` — нужно для WebView local content (synthetic `https://montana.local/`) **Не используются:** - ACCESS_FINE_LOCATION — не нужно - READ_EXTERNAL_STORAGE — не пишем во внешнее хранилище - READ_PHONE_STATE — не получаем IMEI / device IDs ### app/build.gradle.kts Минимальный, см. `08-Воспроизводимая-сборка.md` §2. Версионирование вручную: ```kotlin defaultConfig { applicationId = "quest.montana.vpn" minSdk = 24 targetSdk = 34 versionCode = 60500 versionName = "6.5.0" buildConfigField("String", "APP_URL", "\"https://montana.quest/vpn/app/\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") } } ``` ## Серверная часть (отдельно от Android) ### /opt/montana-vpn-balance/app.py (Moscow) ~280 строк Flask. Endpoints: - `GET /api/vpn/balance?address=X` — баланс read-only - `POST /api/vpn/heartbeat` — основной endpoint начисления - `GET /api/vpn/check` — диагностика IP/via_montana - `GET /api/vpn/stats` — агрегаты - `GET /vpn/sub` — universal VLESS link (base64) - `POST /api/vpn/admin/purge` — localhost-only cleanup ### Конфиги Helsinki - `/etc/haproxy/haproxy.cfg` — see `04-Сетевой-слой.md` §3 - `/etc/xray-pinned/{fi,fra,us}.json` — 3 backend xray-pinned - `/etc/systemd/system/xray-pinned@.service` — systemd template - `/etc/systemd/system/haproxy.service` — systemd unit ### Конфиги Frankfurt, NewYork - `/usr/local/etc/xray/config.json` — single inbound, cascade-only receiver - `/etc/systemd/system/xray.service` — systemd unit ## Точки риска при code review 1. **`MontanaVpnService.startVpn()`** — пропуск `addDisallowedApplication` = TUN петля. 2. **`socksConnect()`** — ручной SOCKS5 protocol, проверить byte-exact handshake. 3. **`sendHeartbeat()`** — ALPN pin критичен для HTTP/1.1 parser. 4. **`deriveAddr()` в app.html** — `domain = "montana-v1:"` префикс должен оставаться immutable, иначе все адреса другие. 5. **`bip39()` в MainActivity** — fail-closed при integrity mismatch. 6. **Helsinki haproxy `stick on src`** — критично для sticky pin. 7. **`with_state_lock()` в backend** — атомарность read-modify-write.