Add decoy SNI plumbing, bundle wintun.dll, fix flag icons, add docs
- Прокидываем sni (decoy-хост) по обновлённому UDL через весь стек: guest-js -> Tauri-команда -> desktop/mobile -> Kotlin spawnSession. Список серверов пока статичный (backend еще не отдает sni), но логика похода в реальный /api/v1/nodes оставлена под флагом на будущее. - Бандлим wintun.dll в Windows-инсталлятор через bundle.resources в tauri.windows.conf.json - раньше он копировался только для локального cargo run/tauri dev, но не попадал в NSIS/MSI. - Чиним падение мобильной сборки: гитигнорённые UniFFI Kotlin-биндинги отстали от обновленного UDL (не хватало параметра sni). - Флаги стран - локальные SVG вместо react-country-flag в CDN-режиме: тот бился об CSP и не грузился офлайн. - Добавляем дерево README.md по структуре проекта и точечные комментарии в неочевидных местах кода. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
fn main() {
|
||||
netrunner_logger::Logger::init(None, false);
|
||||
netrunner_logger::Logger::global().set_level("info");
|
||||
netrunner_logger::Logger::global().set_level("error");
|
||||
netrunner_app::run();
|
||||
}
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
# Tauri Plugin vpn
|
||||
# `tauri-plugin-vpn`
|
||||
|
||||
Кастомный Tauri-плагин — единственная точка входа к VPN-движку из фронтенда.
|
||||
Сам движок (`netrunner_client`, UniFFI-крейт из приватного репозитория
|
||||
`netrunner-core`) сюда не входит — плагин лишь адаптирует его под интерфейс
|
||||
Tauri-команд и различия desktop/mobile.
|
||||
|
||||
## Слои
|
||||
|
||||
```
|
||||
guest-js/index.ts — JS API, которым пользуется фронтенд (invoke Tauri-команд)
|
||||
src/commands.rs — #[command]-обвязка (start_vpn, stop_vpn, ...)
|
||||
src/lib.rs — trait VpnInterface + выбор desktop/mobile реализации
|
||||
src/desktop.rs ├─ вызывает netrunner_client::SessionManager напрямую
|
||||
src/mobile.rs └─ шлёт команды в нативный Android/iOS плагин
|
||||
src/models.rs — общие DTO (TunnelConfig, StartVpnRequest, ...)
|
||||
android/ — Kotlin-часть, см. android/README.md
|
||||
```
|
||||
|
||||
## Rust-файлы
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `src/lib.rs` | `trait VpnInterface<R>` — общий контракт (`start_vpn`, `stop_vpn`, `check_actual_vpn_status`, `get_installed_apps`, `launch_app`). `Vpn<R>` хранит `Box<dyn VpnInterface<R>>` и реализует `Deref` на него — благодаря этому `commands.rs` вызывает `vpn.start_vpn(...)`, не зная, десктопная это реализация или мобильная. `init()` в `setup()` выбирает `desktop::init` / `mobile::init` по `#[cfg(desktop)]`/`#[cfg(mobile)]`. |
|
||||
| `src/commands.rs` | Tauri-команды. `start_vpn` держит `AtomicBool is_running` с `compare_exchange` — защита от повторного запуска сессии при повторном вызове (двойной клик, React double-invoke), а не мьютекс, т.к. проверка/установка должны быть одной атомарной операцией. |
|
||||
| `src/desktop.rs` | Оборачивает `netrunner_client::SessionManager::spawn_session(remote_address, sni, tun_fd, cache_dir, killswitch, excluded_apps, excluded_domains)` — порядок аргументов **обязан** совпадать с `.udl`-описанием в `netrunner-core`. `tun_fd = None` (на десктопе TUN создаётся самим движком, fd не нужен). Эмитит `status-change`/`traffic-stats` через `AppHandle::emit`, статистика — в фоновом потоке с опросом раз в секунду. |
|
||||
| `src/mobile.rs` | Тонкий мост: сериализует те же параметры в `StartVpnRequest` (camelCase) и шлёт через `PluginHandle::run_mobile_plugin` в Kotlin/Swift. |
|
||||
| `src/models.rs` | DTO с `#[serde(rename_all = "camelCase")]` — формат обмена с JS/Kotlin-стороной. |
|
||||
|
||||
## `guest-js/index.ts`
|
||||
|
||||
Обёртка над `invoke("plugin:vpn|...")`. `startVpn(config, ip, port, sni)` —
|
||||
`sni` обязателен и приходит от выбранного узла (`ServerNode.sni` во
|
||||
фронтенде) — это decoy-домен, под который маскируется TLS ClientHello именно
|
||||
для этого сервера; **не константа**, для каждого сервера свой.
|
||||
|
||||
## Android
|
||||
|
||||
См. [android/README.md](android/README.md) — там же задокументирован
|
||||
известный источник поломки мобильной сборки (гитигнорённые UniFFI-биндинги).
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Android-часть плагина
|
||||
|
||||
Нативный Kotlin-код, к которому обращается `src/mobile.rs` через
|
||||
`PluginHandle::run_mobile_plugin`. Отдельный Gradle-модуль (`com.android.library`),
|
||||
подключается в `gen/android` как `:tauri-plugin-vpn` — см.
|
||||
`android/settings.gradle`.
|
||||
|
||||
## `src/main/java/.../VpnPlugin.kt`
|
||||
|
||||
Файл содержит два класса:
|
||||
|
||||
- **`TunVpnService`** (`android.net.VpnService`) — foreground-сервис,
|
||||
который реально держит туннель: строит `VpnService.Builder` (адрес/DNS/MTU),
|
||||
сплит-туннелинг (allow-list для Telegram sandbox-режима **или**
|
||||
disallow-list для обычного режима — взаимоисключающе), отдаёт `fd`
|
||||
созданного TUN-интерфейса в `SessionManager.spawnSession(...)`, крутит
|
||||
цикл статистики и рассылает статус/трафик через `sendBroadcast`. Вибро-фидбек
|
||||
при подключении/отключении.
|
||||
- **`VpnPlugin`** (`@TauriPlugin`) — мост Tauri-команд к сервису:
|
||||
`startVpn` запрашивает системное разрешение VPN (`VpnService.prepare`)
|
||||
перед стартом, `stopVpn`/`checkActualVpnStatus`/`getInstalledApps`/`launchApp`.
|
||||
Транслирует broadcast от `TunVpnService` в Tauri-события
|
||||
(`trigger("status-change" / "traffic-stats", ...)`), которые ловит
|
||||
`guest-js` на JS-стороне.
|
||||
|
||||
`AndroidManifest.xml` — разрешения `INTERNET`, `FOREGROUND_SERVICE`
|
||||
(`specialUse`), `POST_NOTIFICATIONS`, `VIBRATE`; `<queries>` для обнаружения
|
||||
установленных приложений (нужно для сплит-туннелинга).
|
||||
|
||||
## ⚠️ Обязательно прочитать до правок
|
||||
|
||||
**`SessionManager.spawnSession(...)` — не обычный Kotlin-класс, а UniFFI-биндинг
|
||||
поверх нативной `.so`-библиотеки.** Три вещи должны совпадать между собой
|
||||
всегда, иначе сборка падает (или, хуже, крашится в рантайме):
|
||||
|
||||
1. Сигнатура `spawn_session` в `.udl`/`lib.rs` крейта `netrunner_client`
|
||||
(репозиторий `netrunner-proxy`/`netrunner-core`).
|
||||
2. `android/src/main/java/uniffi/netrunner_client/netrunner_client.kt` —
|
||||
сгенерированные Kotlin-биндинги.
|
||||
3. `android/src/main/jniLibs/<abi>/libnetrunner_client.so` — скомпилированная
|
||||
нативная библиотека.
|
||||
|
||||
Все три файла из пп. 2–3 **гитигнорены** (`*uniffi`, `*jniLibs` в
|
||||
`.gitignore` плагина) — они не часть репозитория, их нужно вручную
|
||||
регенерировать/копировать при каждом изменении `.udl` в `netrunner-core`:
|
||||
|
||||
```bash
|
||||
# в репозитории netrunner-proxy
|
||||
make build-android # пересобирает .so под все ABI + генерит Kotlin-биндинги
|
||||
# (tools/bindgen — обёртка над uniffi_bindgen_main)
|
||||
# результат: gen/uniffi/netrunner_client/netrunner_client.kt
|
||||
# gen/<abi>/libnetrunner_client.so
|
||||
|
||||
# скопировать в netrunner-app:
|
||||
cp gen/uniffi/netrunner_client/netrunner_client.kt \
|
||||
<netrunner-app>/src-tauri/src/plugins/vpn-plugin/android/src/main/java/uniffi/netrunner_client/
|
||||
cp gen/<abi>/libnetrunner_client.so \
|
||||
<netrunner-app>/src-tauri/src/plugins/vpn-plugin/android/src/main/jniLibs/<abi>/
|
||||
```
|
||||
|
||||
Забытый шаг копирования — уже реальная причина падения сборки (Kotlin
|
||||
компилятор ругается на несовпадение сигнатуры `spawnSession`, если `.kt`
|
||||
отстал от `.so`/UDL). Если сборка падает быстро (~секунды, до NDK-этапа) —
|
||||
проверяйте это в первую очередь.
|
||||
@@ -66,16 +66,17 @@ class TunVpnService : VpnService() {
|
||||
"START_VPN" -> {
|
||||
val ip = intent.getStringExtra("ip") ?: ""
|
||||
val port = intent.getIntExtra("port", 0)
|
||||
val sni = intent.getStringExtra("sni") ?: ""
|
||||
val config = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra("config", TunnelConfig::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra("config")
|
||||
config?.let { startVpn(it, ip, port) }
|
||||
config?.let { startVpn(it, ip, port, sni) }
|
||||
}
|
||||
"STOP_VPN", ACTION_STOP_VPN -> stopServiceProperly()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startVpn(config: TunnelConfig, ip: String, port: Int) {
|
||||
private fun startVpn(config: TunnelConfig, ip: String, port: Int, sni: String) {
|
||||
val builder = Builder().addAddress(config.address, config.prefix)
|
||||
.addDnsServer(config.dns).addRoute("0.0.0.0", 0).setMtu(config.mtu)
|
||||
|
||||
@@ -115,8 +116,9 @@ class TunVpnService : VpnService() {
|
||||
try {
|
||||
val manager = SessionManager()
|
||||
activeSession = manager.spawnSession(
|
||||
"$ip:$port",
|
||||
fd,
|
||||
"$ip:$port",
|
||||
sni,
|
||||
fd,
|
||||
cacheDir.absolutePath,
|
||||
config.killswitchEnabled,
|
||||
config.excludedApps,
|
||||
@@ -419,6 +421,7 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
action = "START_VPN"
|
||||
putExtra("ip", args.ip)
|
||||
putExtra("port", args.port)
|
||||
putExtra("sni", args.sni)
|
||||
putExtra("config", args.config)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) activity.startForegroundService(intent)
|
||||
@@ -492,6 +495,6 @@ class VpnPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
trigger("status-change", JSObject().apply { put("status", status) })
|
||||
}
|
||||
|
||||
@InvokeArg class VpnArgs { var config: TunnelConfig? = null; var ip: String = ""; var port: Int = 0 }
|
||||
@InvokeArg class VpnArgs { var config: TunnelConfig? = null; var ip: String = ""; var port: Int = 0; var sni: String = "" }
|
||||
@InvokeArg class LaunchAppArgs { var packageName: String = "" }
|
||||
}
|
||||
@@ -23,8 +23,13 @@ export interface AppInfo {
|
||||
packageName: string;
|
||||
}
|
||||
|
||||
export async function startVpn(config: TunnelConfig, ip: string, port: number) {
|
||||
return await invoke("plugin:vpn|start_vpn", { config, ip, port });
|
||||
export async function startVpn(
|
||||
config: TunnelConfig,
|
||||
ip: string,
|
||||
port: number,
|
||||
sni: string,
|
||||
) {
|
||||
return await invoke("plugin:vpn|start_vpn", { config, ip, port, sni });
|
||||
}
|
||||
|
||||
export async function stopVpn() {
|
||||
|
||||
@@ -8,8 +8,12 @@ pub(crate) fn start_vpn<R: Runtime>(
|
||||
config: TunnelConfig,
|
||||
ip: String,
|
||||
port: i32,
|
||||
sni: String,
|
||||
) -> crate::Result<()> {
|
||||
let vpn = app.state::<Vpn<R>>();
|
||||
// Атомарный guard от повторного запуска (двойной клик, повторный invoke) —
|
||||
// compare_exchange проверяет и выставляет флаг одной операцией, обычная
|
||||
// проверка is_running() + set() гонки бы не исключила.
|
||||
if vpn
|
||||
.is_running
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
@@ -17,7 +21,7 @@ pub(crate) fn start_vpn<R: Runtime>(
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
match vpn.start_vpn(config, ip, port) {
|
||||
match vpn.start_vpn(config, ip, port, sni) {
|
||||
Ok(res) => Ok(res),
|
||||
Err(e) => {
|
||||
vpn.is_running.store(false, Ordering::SeqCst);
|
||||
|
||||
@@ -39,7 +39,7 @@ impl<R: Runtime> Clone for Vpn<R> {
|
||||
}
|
||||
|
||||
impl<R: Runtime> VpnInterface<R> for Vpn<R> {
|
||||
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32) -> crate::Result<()> {
|
||||
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32, sni: String) -> crate::Result<()> {
|
||||
let _ = self.app.emit(
|
||||
"status-change",
|
||||
StatusResponse {
|
||||
@@ -47,9 +47,11 @@ impl<R: Runtime> VpnInterface<R> for Vpn<R> {
|
||||
},
|
||||
);
|
||||
|
||||
// Передаем новые параметры конфига прямо в UniFFI-метод
|
||||
// Передаем новые параметры конфига прямо в UniFFI-метод.
|
||||
// sni — decoy-хост для ClientHello, tun_fd на десктопе не нужен (None).
|
||||
let session = SessionManager::new().spawn_session(
|
||||
format!("{}:{}", ip, port),
|
||||
sni,
|
||||
None,
|
||||
".".into(),
|
||||
config.killswitch_enabled,
|
||||
|
||||
@@ -17,9 +17,16 @@ mod error;
|
||||
mod models;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
// В lib.rs
|
||||
// Общий контракт для desktop.rs и mobile.rs — commands.rs вызывает эти методы,
|
||||
// не зная, какая реализация сейчас подставлена (см. impl Deref ниже).
|
||||
pub trait VpnInterface<R: Runtime>: Send + Sync + 'static {
|
||||
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32) -> crate::Result<()>;
|
||||
fn start_vpn(
|
||||
&self,
|
||||
config: TunnelConfig,
|
||||
ip: String,
|
||||
port: i32,
|
||||
sni: String,
|
||||
) -> crate::Result<()>;
|
||||
fn stop_vpn(&self) -> crate::Result<()>;
|
||||
fn check_actual_vpn_status(&self) -> crate::Result<StatusResponse>;
|
||||
fn get_installed_apps(&self) -> crate::Result<Vec<AppInfo>>;
|
||||
@@ -39,11 +46,14 @@ impl<R: Runtime> Vpn<R> {
|
||||
}
|
||||
}
|
||||
|
||||
// Deref позволяет звать vpn.start_vpn(...) напрямую на Vpn<R>, как будто это
|
||||
// сама реализация — без него пришлось бы писать vpn.inner.start_vpn(...) и
|
||||
// светить приватное поле наружу.
|
||||
impl<R: Runtime> Deref for Vpn<R> {
|
||||
type Target = dyn VpnInterface<R>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.inner // Теперь обращаемся к полю inner
|
||||
&*self.inner
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
pub struct Vpn<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> VpnInterface<R> for Vpn<R> {
|
||||
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32) -> crate::Result<()> {
|
||||
self.send("startVpn", StartVpnRequest { config, ip, port })
|
||||
fn start_vpn(&self, config: TunnelConfig, ip: String, port: i32, sni: String) -> crate::Result<()> {
|
||||
self.send("startVpn", StartVpnRequest { config, ip, port, sni })
|
||||
}
|
||||
|
||||
fn stop_vpn(&self) -> crate::Result<()> {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct StartVpnRequest {
|
||||
pub config: TunnelConfig,
|
||||
pub ip: String,
|
||||
pub port: i32,
|
||||
pub sni: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user