//! Менеджер подключения: запуск/останов sidecar sing-box, состояние, события. //! //! Состояние живёт в managed-state Tauri (`Connection`). При каждом изменении //! шлём фронту событие `connection-state`. Если sing-box падает сам — //! переводим в `Error` и снимаем системный прокси. use crate::models::{ConnectionState, Mode, Outbound}; use crate::{config, elevation, profiles, settings, stats, storage, sysproxy}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; /// Порт локального mixed-inbound (proxy mode). Phase 3 — фиксированный. pub const LOCAL_PORT: u16 = 2080; /// Порт локального SOCKS5 у awg-shim (userspace AmneziaWG). Фиксированный. pub const SHIM_PORT: u16 = 2081; /// Конфиг для awg-shim (тот же .conf, что у legacy-движка, но своё имя файла). const SHIM_CONF: &str = "awg-shim.conf"; const RUNNING_CONFIG: &str = "running-config.json"; const EVENT: &str = "connection-state"; const GEOIP_RU_FILE: &str = "geoip-ru.srs"; const GEOIP_RU_URL: &str = "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ru.srs"; const GEOIP_REFRESH_AFTER: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); static GEOIP_UPDATE_ATTEMPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Имя AmneziaWG-тоннеля (= имя .conf без расширения) для /installtunnelservice. /// Только Windows: на macOS движок (awg-quick) адресуется путём к `.conf`. #[cfg(target_os = "windows")] const AWG_TUNNEL: &str = "UniGate"; const AWG_CONF: &str = "UniGate.conf"; /// macOS TUN: единого «перезапуска с UAC» нет, поэтому sing-box поднимаем от root /// через osascript (нативный запрос пароля). Управляем им «сигнальным» файлом: /// GUI создаёт его перед запуском, а при отключении удаляет — root-watcher внутри /// той же привилегированной сессии убивает sing-box (disconnect без пароля). #[cfg(target_os = "macos")] const TUN_SENTINEL: &str = "tun-run.lock"; /// macOS AmneziaWG без root-приложения: тот же сигнальный механизм, что и у TUN /// (awg-quick up от root через osascript + root-watcher, снимающий тоннель при /// удалении файла — disconnect без повторного пароля). #[cfg(target_os = "macos")] const AWG_SENTINEL: &str = "awg-run.lock"; #[derive(Default)] pub struct ConnState { /// sing-box (proxy/tun). child: Option, /// активный AmneziaWG-тоннель (имя сервиса), если поднят legacy-движок. awg_tunnel: Option, /// awg-shim (userspace AmneziaWG → локальный SOCKS5), когда AWG-профиль /// идёт через sing-box. Держим stdin-пайп: выход UniGate = EOF = шим /// завершается сам, даже если мы не успели его убить. shim: Option, /// PID sing-box, запущенного от root на macOS в TUN-режиме (управляется /// сигнальным файлом, не через CommandChild). #[cfg(target_os = "macos")] tun_root_pid: Option, state: ConnectionState, } pub struct Connection(pub Mutex); #[cfg(target_os = "windows")] fn terminate_windows_process(pid: u32, name: &str) -> Result<(), String> { use windows_sys::Win32::{ Foundation::{CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0}, System::Threading::{ OpenProcess, TerminateProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, }, }; const WAIT_TIMEOUT_MS: u32 = 5_000; let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, 0, pid) }; if handle.is_null() { let code = unsafe { GetLastError() }; // Процесс мог успеть завершиться между чтением PID и OpenProcess. return if code == ERROR_INVALID_PARAMETER { Ok(()) } else { Err(format!("не удалось открыть процесс {name} (код {code})")) }; } if unsafe { WaitForSingleObject(handle, 0) } != WAIT_OBJECT_0 && unsafe { TerminateProcess(handle, 1) } == 0 { let code = unsafe { GetLastError() }; unsafe { CloseHandle(handle) }; return Err(format!("не удалось завершить {name} (код {code})")); } let result = unsafe { WaitForSingleObject(handle, WAIT_TIMEOUT_MS) }; unsafe { CloseHandle(handle) }; if result == WAIT_OBJECT_0 { Ok(()) } else { Err(format!("{name} не завершился за 5 секунд")) } } impl Connection { pub fn new() -> Self { Self(Mutex::new(ConnState::default())) } } /// На старте: если мы (ещё) не подключены, а системный прокси указывает на наш /// локальный порт — значит остался после нештатного выхода/краха. Снимаем его, /// чтобы не сломать пользователю интернет мёртвым прокси. pub fn reconcile_startup(_app: &AppHandle) { if sysproxy::is_enabled_for(LOCAL_PORT) { let _ = sysproxy::disable(); } // macOS: убираем «застрявшие» сигнальные файлы после жёсткого краша GUI — // тогда оставшийся root-watcher (если жив) сам завершит sing-box/awg-тоннель. #[cfg(target_os = "macos")] for sentinel in [TUN_SENTINEL, AWG_SENTINEL] { if let Ok(sent) = storage::path(_app, sentinel) { let _ = std::fs::remove_file(sent); } } // macOS: если каталог настроек создавался под sudo — чиним владельца, // иначе обычный (не-root) запуск не может ничего сохранять. #[cfg(target_os = "macos")] heal_config_dir_ownership(_app); } /// macOS: после запуска приложения через `sudo` файлы в App Support остаются /// root-owned — обычный запуск не может сохранять настройки/профили и создавать /// сигнальные файлы (и пользователь вынужден снова запускать от админа). /// Обнаруживаем это и чиним владельца одним запросом пароля. #[cfg(target_os = "macos")] fn heal_config_dir_ownership(app: &AppHandle) { use std::os::unix::fs::MetadataExt; if elevation::is_elevated() { return; } let Ok(probe) = storage::path(app, ".rw-probe") else { return }; let Some(dir) = probe.parent().map(|d| d.to_path_buf()) else { return }; let uid = unsafe { libc::getuid() }; let dir_broken = match std::fs::write(&probe, b"") { Ok(()) => { let _ = std::fs::remove_file(&probe); false } Err(e) => e.kind() == std::io::ErrorKind::PermissionDenied, }; // отдельные root-owned файлы при user-owned каталоге (запускали sudo ПОСЛЕ // обычных запусков): write_json переживёт (rename), а копирование geoip и // лог ядра — нет, поэтому тоже чиним let files_broken = std::fs::read_dir(&dir) .map(|rd| { rd.flatten() .any(|e| e.metadata().map(|m| m.uid() != uid).unwrap_or(false)) }) .unwrap_or(false); if !dir_broken && !files_broken { return; } let gid = unsafe { libc::getgid() }; let q = dir.to_string_lossy().replace('\'', "'\\''"); if let Err(e) = elevation::osascript_admin(&format!("/usr/sbin/chown -R {uid}:{gid} '{q}'")) { eprintln!("не удалось починить владельца каталога настроек: {e}"); } } /// Снимает прокси, убивает sing-box и снимает AmneziaWG-тоннель. /// Вызывается при выходе из приложения (best-effort). pub fn cleanup(app: &AppHandle) { let (child, awg, shim) = if let Some(conn) = app.try_state::() { if let Ok(mut guard) = conn.0.lock() { let t = (guard.child.take(), guard.awg_tunnel.take(), guard.shim.take()); guard.state = ConnectionState::Disconnected; t } else { (None, None, None) } } else { (None, None, None) }; if let Some(child) = child { #[cfg(target_os = "windows")] let _ = terminate_windows_process(child.pid(), "sing-box"); #[cfg(not(target_os = "windows"))] let _ = child.kill(); } if let Some(mut shim) = shim { let _ = shim.kill(); let _ = shim.wait(); } if let Some(handle) = awg { if let Err(e) = awg_tunnel_down(app, &handle) { eprintln!("не удалось снять AmneziaWG-тоннель: {e}"); } } // macOS TUN: удаляем сигнальный файл — root-watcher (переживёт выход GUI) убьёт sing-box #[cfg(target_os = "macos")] if let Ok(sent) = storage::path(app, TUN_SENTINEL) { let _ = std::fs::remove_file(sent); } let _ = sysproxy::disable(); } /// Путь к amneziawg.exe. ВАЖНО: запускаем именно ту копию, рядом с которой лежит /// `wintun.dll` — иначе сервис тоннеля не создаст Wintun-адаптер (exit 3). /// В dev это `src-tauri/binaries`, а НЕ `target/debug` (куда Tauri кладёт копию /// sidecar без wintun.dll). #[cfg(target_os = "windows")] fn awg_exe() -> Option { const TRIPLE: &str = "amneziawg-x86_64-pc-windows-msvc.exe"; const PLAIN: &str = "amneziawg.exe"; let mut candidates: Vec = Vec::new(); // dev: рядом с wintun.dll if let Ok(cwd) = std::env::current_dir() { candidates.push(cwd.join("binaries").join(TRIPLE)); candidates.push(cwd.join("src-tauri").join("binaries").join(TRIPLE)); } // prod: рядом с основным exe (туда бандлится и wintun.dll) if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(dir.join(TRIPLE)); candidates.push(dir.join(PLAIN)); } } candidates.into_iter().find(|p| p.exists()) } /// Находит файл в каталоге binaries (dev: src-tauri/binaries; prod: рядом с exe /// или в resources/). fn binaries_file(name: &str) -> Option { let mut candidates: Vec = Vec::new(); if let Ok(cwd) = std::env::current_dir() { candidates.push(cwd.join("binaries").join(name)); candidates.push(cwd.join("src-tauri").join("binaries").join(name)); } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(dir.join(name)); candidates.push(dir.join("resources").join(name)); // macOS .app: exe лежит в Contents/MacOS, а bundle.resources Tauri // кладёт в Contents/Resources — без этого кандидата установленное // приложение не находило geoip-ru.srs и RU-обход молча отключался #[cfg(target_os = "macos")] if let Some(contents) = dir.parent() { candidates.push(contents.join("Resources").join(name)); } } } candidates.into_iter().find(|p| p.exists()) } /// Путь к awg-shim — userspace AmneziaWG, торчащий локальным SOCKS5 /// (Windows: scripts/build-awg-shim.ps1; macOS: scripts/fetch-awg-macos.sh). /// В prod externalBin кладёт его в каталог исполняемых файлов без triple-суффикса. fn awg_shim_exe() -> Option { #[cfg(target_os = "windows")] { binaries_file("awg-shim-x86_64-pc-windows-msvc.exe") .or_else(|| binaries_file("awg-shim.exe")) } #[cfg(target_os = "macos")] { const NAMES: [&str; 3] = [ "awg-shim-aarch64-apple-darwin", "awg-shim-x86_64-apple-darwin", "awg-shim", ]; NAMES.into_iter().find_map(binaries_file) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { None } } /// Запускает awg-shim: пишет .conf, стартует процесс со stdin-пайпом (умер /// UniGate → закрылся пайп → шим выходит сам) и ждёт строку `READY` — к этому /// моменту endpoint разрезолвлен и SOCKS5 слушает. Ошибка старта — из stderr. async fn start_awg_shim(app: &AppHandle, conf: &str) -> Result { let exe = awg_shim_exe().ok_or("awg-shim не найден рядом с приложением")?; let conf_path = storage::path(app, SHIM_CONF)?; std::fs::write(&conf_path, conf) .map_err(|e| format!("не удалось записать {SHIM_CONF}: {e}"))?; let mut cmd = std::process::Command::new(&exe); cmd.arg("--conf") .arg(&conf_path) .arg("--listen") .arg(format!("127.0.0.1:{SHIM_PORT}")) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; cmd.creation_flags(CREATE_NO_WINDOW); } let mut child = cmd .spawn() .map_err(|e| format!("не удалось запустить awg-shim: {e}"))?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); let ready = tauri::async_runtime::spawn_blocking(move || { use std::io::{BufRead, BufReader, Read}; let mut line = String::new(); if let Some(out) = stdout { let _ = BufReader::new(out).read_line(&mut line); } if line.starts_with("READY") { return Ok(()); } // stdout закрылся без READY — шим упал на старте, причина в stderr let mut err = String::new(); if let Some(mut se) = stderr { let _ = se.read_to_string(&mut err); } let lines: Vec<&str> = err.trim().lines().collect(); let tail = lines[lines.len().saturating_sub(3)..].join("\n"); Err(if tail.is_empty() { line.trim().to_string() } else { tail }) }); match tokio::time::timeout(std::time::Duration::from_secs(10), ready).await { Ok(Ok(Ok(()))) => Ok(child), Ok(Ok(Err(msg))) => { let _ = child.kill(); Err(format!("awg-shim не запустился: {msg}")) } _ => { let _ = child.kill(); Err("awg-shim не ответил за 10 секунд".into()) } } } /// Доступен ли awg-shim: AmneziaWG тогда идёт через sing-box (split-tunneling /// и статистика работают). Иначе — legacy-движок, полный туннель. #[tauri::command] pub fn awg_shim_available() -> bool { awg_shim_exe().is_some() } /// Убивает awg-shim, если он ещё запущен (best-effort). fn kill_shim(app: &AppHandle) { if let Some(conn) = app.try_state::() { if let Ok(mut guard) = conn.0.lock() { if let Some(mut shim) = guard.shim.take() { let _ = shim.kill(); } } } } fn valid_geoip(bytes: &[u8]) -> bool { // sing-box rule-set binary: magic `SRS` + version. Ограничения // отсекают HTML-ошибку GitHub и случайно обрезанную загрузку. (10_000..=1_000_000).contains(&bytes.len()) && bytes.starts_with(b"SRS\x01") } fn valid_geoip_file(path: &std::path::Path) -> bool { std::fs::read(path) .map(|bytes| valid_geoip(&bytes)) .unwrap_or(false) } fn geoip_is_fresh(path: &std::path::Path) -> bool { std::fs::metadata(path) .and_then(|meta| meta.modified()) .ok() .and_then(|modified| modified.elapsed().ok()) .map(|age| age < GEOIP_REFRESH_AFTER) .unwrap_or(false) } /// Заменяет кэш через готовый temp-файл. На Windows `rename` не /// перезаписывает цель, поэтому держим backup до успешной замены. fn replace_geoip(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { let tmp = path.with_extension("srs.tmp"); std::fs::write(&tmp, bytes) .map_err(|e| format!("не удалось записать {}: {e}", tmp.display()))?; if !valid_geoip_file(&tmp) { let _ = std::fs::remove_file(&tmp); return Err("загруженный geoip-ru.srs не прошёл проверку".into()); } #[cfg(target_os = "windows")] if path.exists() { let backup = path.with_extension("srs.bak"); let _ = std::fs::remove_file(&backup); std::fs::rename(path, &backup) .map_err(|e| format!("не удалось подготовить замену geoip: {e}"))?; if let Err(e) = std::fs::rename(&tmp, path) { let _ = std::fs::rename(&backup, path); return Err(format!("не удалось заменить geoip: {e}")); } let _ = std::fs::remove_file(backup); return Ok(()); } std::fs::rename(&tmp, path) .map_err(|e| format!("не удалось установить geoip: {e}")) } /// Возвращает читаемую ядром копию GeoIP из app config dir. /// Раз в сутки пытаемся обновить её до актуальной SagerNet; при любой /// ошибке остаётся прежний кэш или встроенная в приложение база. /// Заодно это снимает macOS TCC-проблему с проектом на Desktop. async fn prepare_geoip(app: &AppHandle) -> Option { let cache = storage::path(app, GEOIP_RU_FILE).ok(); let cached_valid = cache.as_deref().map(valid_geoip_file).unwrap_or(false); let stale = !cached_valid || !cache.as_deref().map(geoip_is_fresh).unwrap_or(false); // Если GitHub недоступен, не задерживаем каждый reconnect // в том же запуске приложения; новая попытка будет после перезапуска. let should_refresh = stale && !GEOIP_UPDATE_ATTEMPTED.swap(true, std::sync::atomic::Ordering::Relaxed); if should_refresh { let download = async { let client = reqwest::Client::builder() .user_agent("UniGate") .timeout(std::time::Duration::from_secs(6)) .build() .map_err(|e| format!("http-клиент geoip: {e}"))?; let response = client .get(GEOIP_RU_URL) .send() .await .map_err(|e| format!("загрузка geoip: {e}"))?; if !response.status().is_success() { return Err(format!("geoip вернул статус {}", response.status())); } let bytes = response .bytes() .await .map_err(|e| format!("чтение geoip: {e}"))?; if !valid_geoip(&bytes) { return Err("ответ geoip не похож на sing-box rule-set".to_string()); } Ok::<_, String>(bytes) } .await; match (download, cache.as_deref()) { (Ok(bytes), Some(path)) => { if let Err(e) = replace_geoip(path, &bytes) { eprintln!("[geoip] {e}"); } } (Err(e), _) => eprintln!("[geoip] {e}; используем локальную базу"), _ => {} } } if let Some(path) = cache.filter(|path| valid_geoip_file(path)) { return Some(path.to_string_lossy().into_owned()); } let bundled = binaries_file(GEOIP_RU_FILE).filter(|path| valid_geoip_file(path)); if bundled.is_none() { eprintln!("geoip-ru.srs не найден — RU-обход не будет применён"); } bundled.map(|path| path.to_string_lossy().into_owned()) } fn parse_vpn_route_prefixes(output: &str) -> Vec { let mut routes = Vec::new(); for prefix in output.lines().map(str::trim).filter(|line| !line.is_empty()) { let Some((ip, bits)) = prefix.split_once('/') else { continue; }; let Ok(ip) = ip.parse::() else { continue; }; let Ok(bits) = bits.parse::() else { continue; }; // Не отдаём второму VPN default/half-default: обычный // интернет должен остаться за UniGate. Не исключаем также // loopback/multicast и служебную benchmark-сеть нашего TUN. let octets = ip.octets(); if !(8..=32).contains(&bits) || ip.is_loopback() || ip.is_multicast() || (octets[0] == 198 && matches!(octets[1], 18 | 19)) { continue; } let value = format!("{ip}/{bits}"); if !routes.contains(&value) { routes.push(value); } } routes } /// Конкретные IPv4-сети активных сторонних VPN-адаптеров. /// Имя самого подключения может быть любым; ищем также типичные /// описания драйверов OpenVPN/WireGuard/корпоративных VPN. #[cfg(target_os = "windows")] fn detect_other_vpn_routes() -> Vec { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; let script = r#" $pattern = '(?i)(openvpn|wireguard|wintun|tap-windows|\bdco\b|tailscale|zerotier|amnezia|anyconnect|fortinet|forticlient|globalprotect|pulse secure|juniper|sonicwall|check point|zscaler|netmotion|vpn|wan miniport.*(ikev2|l2tp|pptp|sstp))' $indexes = @(Get-NetAdapter -IncludeHidden -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'Up' -and $_.Name -ne 'tun0' -and ($_.Name -match $pattern -or $_.InterfaceDescription -match $pattern) } | Select-Object -ExpandProperty ifIndex) if ($indexes.Count -gt 0) { Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Alive' -and $indexes -contains $_.InterfaceIndex } | Select-Object -ExpandProperty DestinationPrefix -Unique } "#; let output = std::process::Command::new("powershell.exe") .args([ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, ]) .creation_flags(CREATE_NO_WINDOW) .output(); match output { Ok(output) if output.status.success() => { parse_vpn_route_prefixes(&String::from_utf8_lossy(&output.stdout)) } Ok(output) => { eprintln!( "[vpn-compat] не удалось прочитать маршруты: {}", String::from_utf8_lossy(&output.stderr).trim() ); Vec::new() } Err(e) => { eprintln!("[vpn-compat] не удалось запустить аудит маршрутов: {e}"); Vec::new() } } } /// Путь к бинарнику sing-box для ПРЯМОГО запуска (не через Tauri-sidecar). /// Нужно на macOS в TUN-режиме, где sing-box поднимается от root через osascript. /// dev: `src-tauri/binaries/sing-box-`; prod: рядом с exe (`sing-box`). #[cfg(target_os = "macos")] fn singbox_path() -> Option { const NAMES: [&str; 3] = [ "sing-box-aarch64-apple-darwin", "sing-box-x86_64-apple-darwin", "sing-box", ]; let mut dirs: Vec = Vec::new(); if let Ok(cwd) = std::env::current_dir() { dirs.push(cwd.join("binaries")); dirs.push(cwd.join("src-tauri").join("binaries")); } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { dirs.push(dir.to_path_buf()); dirs.push(dir.join("resources")); // установленный macOS bundle: executable = Contents/MacOS/unigate, // ресурсы Tauri = Contents/Resources if let Some(contents) = dir.parent() { dirs.push(contents.join("Resources")); } } } for d in dirs { for n in NAMES { let p = d.join(n); if p.exists() { return Some(p); } } } None } /// Запускает sing-box от root через osascript (нативный запрос пароля админа). /// Возвращает PID. В той же привилегированной сессии стартует root-watcher: он /// убьёт sing-box, как только исчезнет сигнальный файл `sentinel` — это даёт /// отключение без повторного запроса пароля (GUI просто удаляет файл). #[cfg(target_os = "macos")] fn spawn_singbox_root( singbox: &std::path::Path, cfg: &std::path::Path, log: &std::path::Path, sentinel: &std::path::Path, ) -> Result { // экранирование одинарных кавычек для sh: ' -> '\'' let q = |p: &std::path::Path| p.to_string_lossy().replace('\'', "'\\''"); let shell = format!( "'{sb}' run -c '{cfg}' > '{log}' 2>&1 & SB=$!; echo $SB; \ ( while kill -0 $SB 2>/dev/null && [ -e '{sent}' ]; do sleep 0.3; done; \ kill $SB 2>/dev/null ) >/dev/null 2>&1 &", sb = q(singbox), cfg = q(cfg), log = q(log), sent = q(sentinel), ); let out = elevation::osascript_admin(&shell)?; String::from_utf8_lossy(&out.stdout) .trim() .parse::() .map_err(|_| "не удалось получить PID sing-box (root)".to_string()) } /// Ждёт освобождения локального порта (проверка bind'ом), poll 150 мс. /// true — порт свободен; false — так и не освободился за timeout. async fn wait_port_free(port: u16, timeout: std::time::Duration) -> bool { let deadline = std::time::Instant::now() + timeout; loop { // bind тут же закрываем — нам нужен только факт доступности порта if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { return true; } if std::time::Instant::now() >= deadline { return false; } tokio::time::sleep(std::time::Duration::from_millis(150)).await; } } /// Последние строки лога ядра (`sing-box.log`) — чтобы показать причину падения /// в сообщении об ошибке, а не только «завершился с кодом N». fn log_tail(app: &AppHandle, max_lines: usize) -> Option { let path = storage::path(app, "sing-box.log").ok()?; let text = std::fs::read_to_string(path).ok()?; let mut lines: Vec<&str> = text.lines().rev().take(max_lines).collect(); lines.reverse(); if lines.is_empty() { return None; } Some(lines.join("\n")) } /// true, если процесс с данным PID жив. `kill -0` от пользователя к root-процессу /// возвращает EPERM — это тоже «жив»; окончательно мёртв только ESRCH. #[cfg(target_os = "macos")] fn pid_alive(pid: u32) -> bool { if unsafe { libc::kill(pid as i32, 0) } == 0 { return true; } std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } /// Запускает amneziawg.exe напрямую (как в консоли — без перенаправления stdio /// и CREATE_NO_WINDOW, иначе GUI-exe не стартует сервис тоннеля). #[cfg(target_os = "windows")] fn run_awg(args: &[&str]) -> Result<(), String> { let exe = awg_exe().ok_or("amneziawg.exe не найден рядом с приложением")?; let status = std::process::Command::new(&exe) .args(args) .status() .map_err(|e| format!("не удалось запустить amneziawg: {e}"))?; if !status.success() { return Err(format!("amneziawg завершился с кодом {:?}", status.code())); } Ok(()) } /// Каталог с движком AmneziaWG для macOS: `amneziawg-go` (userspace-датапас) + /// `awg` (UAPI-конфигуратор) + `awg-quick` (bash-обёртка, поднимает utun+адреса+ /// маршруты+DNS из `.conf`). Prebuilt-бинарников нет — собираются из исходников, /// см. scripts/fetch-awg-macos.sh; в dev кладутся в `src-tauri/binaries`. #[cfg(target_os = "macos")] fn awg_engine_dir() -> Option { let mut dirs: Vec = Vec::new(); if let Ok(cwd) = std::env::current_dir() { dirs.push(cwd.join("binaries")); dirs.push(cwd.join("src-tauri").join("binaries")); } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { dirs.push(dir.to_path_buf()); dirs.push(dir.join("resources")); if let Some(contents) = dir.parent() { dirs.push(contents.join("Resources")); } } } dirs.into_iter().find(|d| { d.join("amneziawg-go").exists() && d.join("awg").exists() && d.join("awg-quick").exists() }) } /// macOS: `awg-quick up|down ` от root. PATH дополняем каталогом движка, /// чтобы awg-quick нашёл `awg` и userspace-реализацию (`amneziawg-go`). #[cfg(target_os = "macos")] fn run_awg_quick(action: &str, conf: &str) -> Result<(), String> { let dir = awg_engine_dir() .ok_or("движок AmneziaWG (amneziawg-go/awg/awg-quick) не найден рядом с приложением")?; let path_env = match std::env::var("PATH") { Ok(p) => format!("{}:{}", dir.display(), p), Err(_) => format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", dir.display()), }; let status = std::process::Command::new("bash") .arg(dir.join("awg-quick")) .arg(action) .arg(conf) .env("PATH", path_env) .env("WG_QUICK_USERSPACE_IMPLEMENTATION", "amneziawg-go") .status() .map_err(|e| format!("не удалось запустить awg-quick: {e}"))?; if !status.success() { return Err(format!("awg-quick {action} завершился с кодом {:?}", status.code())); } Ok(()) } /// true, если у системы есть реальный IPv6-маршрут по умолчанию. /// `route -n get -inet6 default` возвращает 0 даже при отсутствии маршрута /// («not in table» уходит в stderr) — поэтому смотрим на stdout: у найденного /// маршрута там есть строка `interface: enX`. #[cfg(target_os = "macos")] fn has_ipv6_route() -> bool { std::process::Command::new("route") .args(["-n", "get", "-inet6", "default"]) .output() .map(|o| String::from_utf8_lossy(&o.stdout).contains("interface:")) .unwrap_or(false) } /// Резолвит домен сервера профиля системным резолвером (best-effort, таймаут 4 с). /// IPv4 — в начале списка (DNS-стратегия ядра ipv4_only). Пусто, если сервер /// задан IP-литералом или резолв не удался. async fn resolve_server_ips(outbound: &Outbound) -> Vec { use std::net::ToSocketAddrs; let Some(host) = config::server_domain(outbound) else { return Vec::new(); }; let host = host.to_string(); // getaddrinfo блокирующий и может висеть — уводим в blocking-пул с таймаутом let resolve = tauri::async_runtime::spawn_blocking(move || { (host.as_str(), 443) .to_socket_addrs() .map(|addrs| { let (mut v4, mut v6): (Vec<_>, Vec<_>) = addrs.map(|a| a.ip()).partition(|ip| ip.is_ipv4()); v4.append(&mut v6); v4.dedup(); v4 }) .unwrap_or_default() }); match tokio::time::timeout(std::time::Duration::from_secs(4), resolve).await { Ok(Ok(ips)) => ips, _ => Vec::new(), } } /// На время сеанса закрепляем тот же IP в конфиге шима, который исключили из /// TUN. Иначе повторный DNS-запрос шима может выбрать другой A-запись. fn pin_awg_endpoint( conf: &str, server: &str, port: u16, ip: std::net::IpAddr, ) -> Result { let expected = format!("{server}:{port}"); let replacement = std::net::SocketAddr::new(ip, port).to_string(); let mut result = String::with_capacity(conf.len()); let mut replaced = false; for line in conf.split_inclusive('\n') { let (body, newline) = if let Some(body) = line.strip_suffix("\r\n") { (body, "\r\n") } else if let Some(body) = line.strip_suffix('\n') { (body, "\n") } else { (line, "") }; if let Some((key, value)) = body.split_once('=') { if key.trim().eq_ignore_ascii_case("Endpoint") && value.trim() == expected { result.push_str(key); result.push_str("= "); result.push_str(&replacement); result.push_str(newline); replaced = true; continue; } } result.push_str(line); } if replaced { Ok(result) } else { Err("не удалось закрепить IP: Endpoint в AWG-конфиге не совпадает с профилем".into()) } } /// Исключение на уровне маршрутов ОС надёжнее одного process_name-правила: /// Windows TUN не всегда определяет процесс для UDP-пакетов awg-shim. #[cfg(target_os = "windows")] fn exclude_awg_endpoint_from_tun(cfg: &mut serde_json::Value, ips: &[std::net::IpAddr]) { let Some(tun) = cfg .get_mut("inbounds") .and_then(|v| v.as_array_mut()) .and_then(|inbounds| inbounds.iter_mut().find(|v| v["type"] == "tun")) else { return; }; let mut excludes = tun["route_exclude_address"] .as_array() .cloned() .unwrap_or_default(); for ip in ips { let prefix = if ip.is_ipv4() { 32 } else { 128 }; let cidr = serde_json::json!(format!("{ip}/{prefix}")); if !excludes.contains(&cidr) { excludes.push(cidr); } } tun["route_exclude_address"] = serde_json::Value::Array(excludes); } fn set_state(app: &AppHandle, state: ConnectionState) { if let Some(conn) = app.try_state::() { if let Ok(mut guard) = conn.0.lock() { guard.state = state.clone(); } } let _ = app.emit(EVENT, state); } #[tauri::command] pub fn get_connection_state(app: AppHandle) -> ConnectionState { app.state::() .0 .lock() .map(|g| g.state.clone()) .unwrap_or_default() } /// true, если текущее состояние — Connected (используется поллером статистики). pub fn is_connected(app: &AppHandle) -> bool { matches!(get_connection_state(app.clone()), ConnectionState::Connected) } /// Возвращает адрес локального прокси (для ручной проверки/отображения). #[tauri::command] pub fn local_proxy_addr() -> String { format!("127.0.0.1:{LOCAL_PORT}") } /// Windows + совместная работа с другим VPN: sing-box назначает TUN-адаптеру /// служебный DNS (следующий адрес после 198.18.0.1), и Windows ставит этот /// интерфейс первым. OpenVPN Connect с защитой от DNS-утечек блокирует запросы к /// чужому TUN-DNS, хотя его собственные DNS через TAP/DCO доступны. /// /// После создания адаптера очищаем его DNS и поднимаем interface metric: тогда /// Windows выбирает DNS активного OpenVPN (либо физической сети), а более /// специфичные auto_route-маршруты UniGate продолжают перехватывать обычный /// трафик. Настройки относятся к ActiveStore и исчезают вместе с адаптером. #[cfg(target_os = "windows")] async fn release_tun_dns_to_system() -> Result<(), String> { let script = r#" $ErrorActionPreference = 'Stop' $deadline = [DateTime]::UtcNow.AddSeconds(5) do { $tun = Get-NetIPAddress -AddressFamily IPv4 -IPAddress '198.18.0.1' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $tun) { Set-DnsClientServerAddress -InterfaceIndex $tun.InterfaceIndex -ResetServerAddresses Set-NetIPInterface -InterfaceIndex $tun.InterfaceIndex -AddressFamily IPv4 -InterfaceMetric 5000 exit 0 } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) Write-Error 'TUN interface 198.18.0.1 was not found' exit 1 "#; tauri::async_runtime::spawn_blocking(move || { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; let output = std::process::Command::new("powershell.exe") .args([ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script, ]) .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|e| format!("не удалось настроить DNS TUN: {e}"))?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); Err(if stderr.is_empty() { format!( "настройка DNS TUN завершилась с кодом {:?}", output.status.code() ) } else { format!("не удалось освободить DNS TUN: {stderr}") }) } }) .await .map_err(|e| format!("задача настройки DNS TUN завершилась с ошибкой: {e}"))? } #[tauri::command] pub async fn connect(app: AppHandle, profile_id: String) -> Result { { let conn = app.state::(); let guard = conn.0.lock().map_err(|e| e.to_string())?; if matches!( guard.state, ConnectionState::Connected | ConnectionState::Connecting ) { return Err("уже подключено".into()); } } let profile = profiles::list(&app)? .into_iter() .find(|p| p.id == profile_id) .ok_or_else(|| format!("профиль {profile_id} не найден"))?; // AmneziaWG: предпочтительный путь — awg-shim (userspace AWG → локальный // SOCKS5): sing-box остаётся мозгом маршрутизации, split-tunneling и // статистика работают как у остальных протоколов. Если шима нет рядом — // legacy-движок (amneziawg.exe / awg-quick): полный туннель без split. let awg_shim = matches!(profile.outbound, Outbound::AmneziaWg { .. }) && awg_shim_exe().is_some(); if let Outbound::AmneziaWg { config, .. } = &profile.outbound { if !awg_shim { return connect_amneziawg(&app, config).await; } } // настройки: режим + маршрутизация; TUN требует прав администратора. // На Windows/Linux нужен elevated-процесс приложения; на macOS так нельзя — // там сам sing-box поднимаем от root через osascript (см. connect_tun_macos), // поэтому здесь мак не блокируем. let settings = settings::load(&app)?; let mode = settings.mode; #[cfg(not(target_os = "macos"))] if mode == Mode::Tun && !elevation::is_elevated() { let msg = "TUN-режим требует прав администратора — перезапустите приложение от имени администратора" .to_string(); set_state(&app, ConnectionState::Error { message: msg.clone() }); return Err(msg); } set_state(&app, ConnectionState::Connecting); // Актуальная кэшированная geoip-ru.srs для RU-обхода. // При офлайне остаётся прежний кэш/встроенная база. let geoip = if mode == Mode::Tun && settings.routing.bypass_ru { prepare_geoip(&app).await } else { None }; // До создания tun0 снимаем снимок конкретных сетей уже // активного стороннего VPN. Его default route фильтруется — // обычный интернет по-прежнему перекрывает UniGate. #[cfg(target_os = "windows")] let vpn_route_excludes = if mode == Mode::Tun && settings.routing.bypass_lan && settings.routing.vpn_compatibility { detect_other_vpn_routes() } else { Vec::new() }; #[cfg(not(target_os = "windows"))] let vpn_route_excludes: Vec = Vec::new(); // macOS/прочие + TUN: предрезолв домена сервера для DNS-бутстрапа. // Windows + AWG shim + TUN: тот же IP нужен для исключения endpoint из // маршрута TUN, иначе UDP шима может зациклиться при доменном Endpoint. #[cfg(not(target_os = "windows"))] let bootstrap_ips = if mode == Mode::Tun { resolve_server_ips(&profile.outbound).await } else { Vec::new() }; #[cfg(target_os = "windows")] let bootstrap_ips = if mode == Mode::Tun && awg_shim { resolve_server_ips(&profile.outbound).await } else { Vec::new() }; let mut awg_endpoint_ips = Vec::new(); let mut shim_config = None; if awg_shim { if let Outbound::AmneziaWg { config: conf, server, port, } = &profile.outbound { if let Ok(ip) = server.parse::() { awg_endpoint_ips.push(ip); } else if mode == Mode::Tun { if let Some(&ip) = bootstrap_ips.first() { // Шим и исключение маршрута должны использовать один IP. let pinned = pin_awg_endpoint(conf, server, *port, ip).map_err(|e| { set_state(&app, ConnectionState::Error { message: e.clone() }); e })?; shim_config = Some(pinned); awg_endpoint_ips.push(ip); } else if cfg!(target_os = "windows") { let msg = format!("не удалось определить IP AWG-сервера {server} до запуска TUN"); set_state(&app, ConnectionState::Error { message: msg.clone() }); return Err(msg); } } } } // Перехватывать ли IPv6 в TUN: только если у системы реально есть // IPv6-маршрут. На IPv4-only сети фейковый IPv6 через tun ломает // direct-обход (bypass_ru): приложения выбирают IPv6, а доставить его // некуда → мгновенный разрыв. Windows не трогаем (флаг игнорируется). #[cfg(target_os = "macos")] let tun_ipv6 = mode == Mode::Tun && has_ipv6_route(); #[cfg(not(target_os = "macos"))] let tun_ipv6 = false; // Профиль для генератора: AmneziaWG через шим для sing-box — обычный // SOCKS5-outbound на localhost let mut gen_profile = profile.clone(); if awg_shim { gen_profile.outbound = Outbound::Socks { server: "127.0.0.1".into(), port: SHIM_PORT, username: None, password: None, }; } // генерируем и пишем рабочий конфиг let mut cfg = config::generate_with_vpn_routes( &gen_profile, mode, LOCAL_PORT, &settings.routing, geoip.as_deref(), &bootstrap_ips, tun_ipv6, settings.tun_stack, &vpn_route_excludes, ); #[cfg(target_os = "windows")] if awg_shim && mode == Mode::Tun { exclude_awg_endpoint_from_tun(&mut cfg, &awg_endpoint_ips); } // Трафик самого шима (UDP к AWG-серверу) обязан идти мимо TUN, иначе цикл: // правило по имени процесса + по IP endpoint. Доменный endpoint мы // предрезолвим до подъёма TUN; IP-литерал берём из профиля. // В proxy-режиме route-правил в конфиге нет — блок no-op. if awg_shim { if let Some(rules) = cfg .get_mut("route") .and_then(|r| r.get_mut("rules")) .and_then(|r| r.as_array_mut()) { let mut extra: Vec = Vec::new(); if let Some(name) = awg_shim_exe() .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) { extra.push(serde_json::json!({ "process_name": [name], "outbound": "direct" })); } for ip in &awg_endpoint_ips { let prefix = if ip.is_ipv4() { 32 } else { 128 }; extra.push(serde_json::json!({ "ip_cidr": [format!("{ip}/{prefix}")], "outbound": "direct" })); } // после sniff + hijack-dns (первые два правила), до обходов/per-app let at = 2.min(rules.len()); rules.splice(at..at, extra); } } // логи ядра в файл — уровень warn (без адресов подключений, для траблшутинга). // Файл обнуляем перед стартом, чтобы хвост лога относился к текущему запуску // (его показываем в сообщении об ошибке при падении ядра). if let Ok(log_path) = storage::path(&app, "sing-box.log") { let _ = std::fs::write(&log_path, b""); cfg["log"] = serde_json::json!({ "level": "warn", "timestamp": true, "output": log_path.to_string_lossy() }); } if let Err(e) = storage::write_json(&app, RUNNING_CONFIG, &cfg) { set_state(&app, ConnectionState::Error { message: e.clone() }); return Err(e); } let path = storage::path(&app, RUNNING_CONFIG)?; // Пре-флайт: ядро слушает Clash API (9090), а в proxy-режиме ещё и 2080. // Порт может быть занят предыдущим ядром, которое завершается асинхронно // (root-ядро macOS TUN убивает watcher с задержкой), или чужим процессом — // тогда sing-box падает с невнятным «код 1». Ждём до 3 с и говорим внятно. { let mut ports = vec![config::CLASH_API_PORT]; if mode == Mode::Proxy { ports.push(LOCAL_PORT); } if awg_shim { ports.push(SHIM_PORT); } for port in ports { if !wait_port_free(port, std::time::Duration::from_secs(3)).await { let msg = format!( "порт 127.0.0.1:{port} занят другим процессом (возможно, предыдущее ядро ещё завершается) — попробуйте ещё раз" ); set_state(&app, ConnectionState::Error { message: msg.clone() }); return Err(msg); } } } // AmneziaWG: шим поднимаем ДО ядра — он резолвит endpoint системным // резолвером (наш TUN ещё не активен) и начинает слушать SOCKS5 let mut shim_child: Option = None; if awg_shim { if let Outbound::AmneziaWg { config: conf, .. } = &profile.outbound { match start_awg_shim(&app, shim_config.as_deref().unwrap_or(conf)).await { Ok(c) => shim_child = Some(c), Err(e) => { set_state(&app, ConnectionState::Error { message: e.clone() }); return Err(e); } } } } // macOS + TUN: sing-box нужен root (создать utun + маршруты). // Важно попасть сюда уже ПОСЛЕ старта awg-shim: root sing-box получает // готовый SOCKS5 на 127.0.0.1:2081. Handle шима храним в state, потому // что root-процесс sing-box не является нашим CommandChild. #[cfg(target_os = "macos")] if mode == Mode::Tun && !elevation::is_elevated() { if let Some(shim) = shim_child.take() { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.shim = Some(shim); } let result = connect_tun_macos(&app, &path); if result.is_err() { kill_shim(&app); } return result; } // запускаем sing-box let spawn = app .shell() .sidecar("sing-box") .map_err(|e| format!("sidecar sing-box: {e}")) .and_then(|cmd| { cmd.args(["run", "-c", &path.to_string_lossy()]) .spawn() .map_err(|e| format!("не удалось запустить sing-box: {e}")) }); let (mut rx, child) = match spawn { Ok(pair) => pair, Err(e) => { if let Some(mut shim) = shim_child.take() { let _ = shim.kill(); } set_state(&app, ConnectionState::Error { message: e.clone() }); return Err(e); } }; { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.child = Some(child); guard.shim = shim_child; guard.state = ConnectionState::Connected; } // При явно включённой совместимости корпоративные сети и DNS должны // оставаться за нативным OpenVPN-адаптером. Делаем после spawn: только // теперь существует tun0. #[cfg(target_os = "windows")] if mode == Mode::Tun && settings.routing.bypass_lan && settings.routing.vpn_compatibility { if let Err(e) = release_tun_dns_to_system().await { // Best-effort: без OpenVPN само подключение UniGate остаётся рабочим, // поэтому не роняем сессию из-за системной настройки DNS. eprintln!("[tun-dns] {e}"); } } // системный прокси ставим только в proxy-режиме (в TUN роутит сам адаптер) if mode == Mode::Proxy { if let Err(e) = sysproxy::enable(LOCAL_PORT) { eprintln!("не удалось включить системный прокси: {e}"); } } let _ = app.emit(EVENT, ConnectionState::Connected); // запускаем опрос статистики (живёт, пока Connected) stats::start_polling(app.clone()); // следим за неожиданным завершением процесса let app_watch = app.clone(); tauri::async_runtime::spawn(async move { // последние строки stderr/stdout ядра: FATAL при старте (например, // занятый порт) печатается в stderr ДО настройки файлового лога — // без этого буфера ошибка в UI была бы просто «код 1» let mut stderr_tail: std::collections::VecDeque = std::collections::VecDeque::with_capacity(5); while let Some(event) = rx.recv().await { match event { CommandEvent::Stderr(bytes) | CommandEvent::Stdout(bytes) => { let line = String::from_utf8_lossy(&bytes); let line = line.trim(); if !line.is_empty() { eprintln!("[sing-box] {line}"); if stderr_tail.len() == 5 { stderr_tail.pop_front(); } stderr_tail.push_back(line.to_string()); } } CommandEvent::Terminated(payload) => { let conn = app_watch.state::(); let unexpected = { let mut guard = conn.0.lock().unwrap(); guard.child = None; !matches!( guard.state, ConnectionState::Disconnecting | ConnectionState::Disconnected ) }; // ядро умерло — шим больше не нужен (при штатном // disconnect его уже забрали, тогда это no-op) kill_shim(&app_watch); if unexpected { let _ = sysproxy::disable(); let mut msg = format!("sing-box завершился (код {:?})", payload.code); if let Some(tail) = log_tail(&app_watch, 5) { msg = format!("{msg}\n{tail}"); } else if !stderr_tail.is_empty() { let tail: Vec = stderr_tail.iter().cloned().collect(); msg = format!("{msg}\n{}", tail.join("\n")); } set_state(&app_watch, ConnectionState::Error { message: msg }); } break; } _ => {} } } }); Ok(ConnectionState::Connected) } /// macOS TUN: поднимаем sing-box от root через osascript. Системный прокси НЕ /// ставим (роутит сам utun). Статистика идёт через Clash API как обычно. #[cfg(target_os = "macos")] fn connect_tun_macos(app: &AppHandle, cfg_path: &std::path::Path) -> Result { let fail = |app: &AppHandle, msg: String| -> Result { set_state(app, ConnectionState::Error { message: msg.clone() }); Err(msg) }; let singbox = match singbox_path() { Some(p) => p, None => return fail(app, "не найден бинарник sing-box".into()), }; let log = storage::path(app, "sing-box.log")?; let sentinel = storage::path(app, TUN_SENTINEL)?; // создаём сигнальный файл ДО запуска — пока он есть, root-watcher держит sing-box if let Err(e) = std::fs::write(&sentinel, b"") { return fail(app, format!("не удалось создать сигнальный файл: {e}")); } let pid = match spawn_singbox_root(&singbox, cfg_path, &log, &sentinel) { Ok(pid) => pid, Err(e) => { let _ = std::fs::remove_file(&sentinel); return fail(app, e); } }; // Даём ядру время на старт и проверяем, что оно не упало сразу (FATAL на // конфиге/DNS/TCC): иначе покажем ложный «Connected», а ошибка останется // только в логе. При падении — показываем хвост sing-box.log. std::thread::sleep(std::time::Duration::from_millis(1500)); if !pid_alive(pid) { let _ = std::fs::remove_file(&sentinel); let mut msg = "sing-box (TUN) упал при старте".to_string(); if let Some(tail) = log_tail(app, 5) { msg = format!("{msg}:\n{tail}"); } return fail(app, msg); } { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.tun_root_pid = Some(pid); guard.state = ConnectionState::Connected; } let _ = app.emit(EVENT, ConnectionState::Connected); stats::start_polling(app.clone()); // Следим за root-процессом: CommandChild-событий у него нет, поэтому поллим // PID. Если ядро умерло, а мы всё ещё «Connected» — показываем ошибку с логом. let app_watch = app.clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let ours = { let conn = app_watch.state::(); let guard = match conn.0.lock() { Ok(g) => g, Err(_) => break, }; guard.tun_root_pid == Some(pid) }; if !ours { break; // штатный disconnect уже забрал PID } if !pid_alive(pid) { { let conn = app_watch.state::(); if let Ok(mut guard) = conn.0.lock() { guard.tun_root_pid = None; }; } if let Ok(sent) = storage::path(&app_watch, TUN_SENTINEL) { let _ = std::fs::remove_file(sent); } // root sing-box больше не использует локальный AWG SOCKS. kill_shim(&app_watch); let mut msg = "sing-box (TUN) неожиданно завершился".to_string(); if let Some(tail) = log_tail(&app_watch, 5) { msg = format!("{msg}:\n{tail}"); } set_state(&app_watch, ConnectionState::Error { message: msg }); break; } } }); Ok(ConnectionState::Connected) } /// Подключение через движок AmneziaWG. Windows — `amneziawg.exe /// /installtunnelservice`; macOS — `awg-quick up` (amneziawg-go userspace). /// Всегда требует прав администратора; статистики Clash API у него нет. #[cfg(any(target_os = "macos", test))] fn normalize_awg_config_for_macos(config: &str, keep_ipv6_routes: bool) -> String { let is_empty_concealment = |line: &str| { let Some((key, value)) = line.trim().split_once('=') else { return false; }; let key = key.trim().as_bytes(); let is_i1_i5 = key.len() == 2 && key[0].eq_ignore_ascii_case(&b'i') && matches!(key[1], b'1'..=b'5'); let value = value.trim(); is_i1_i5 && (value.is_empty() || value == "''" || value == "\"\"") }; let filter_allowed_ips = |line: &str| -> Option { if keep_ipv6_routes { return None; } let (key, value) = line.split_once('=')?; if !key.trim().eq_ignore_ascii_case("AllowedIPs") { return None; } let ipv4_routes = value .split(',') .map(str::trim) .filter(|route| !route.is_empty() && !route.contains(':')) .collect::>(); Some(if ipv4_routes.is_empty() { String::new() } else { format!("{}= {}", key, ipv4_routes.join(", ")) }) }; let mut normalized = String::with_capacity(config.len()); for segment in config.split_inclusive('\n') { let newline = if segment.ends_with("\r\n") { "\r\n" } else if segment.ends_with('\n') { "\n" } else { "" }; let line = segment .strip_suffix('\n') .unwrap_or(segment) .strip_suffix('\r') .unwrap_or_else(|| segment.strip_suffix('\n').unwrap_or(segment)); if is_empty_concealment(line) { continue; } if let Some(filtered) = filter_allowed_ips(line) { if !filtered.is_empty() { normalized.push_str(&filtered); normalized.push_str(newline); } } else { normalized.push_str(segment); } } normalized } async fn connect_amneziawg(app: &AppHandle, config: &str) -> Result { // macOS не блокируем: там awg-quick поднимается от root через osascript // (нативный запрос пароля), приложение может оставаться обычным. #[cfg(not(target_os = "macos"))] if !elevation::is_elevated() { let msg = "AmneziaWG требует прав администратора — перезапустите приложение от имени администратора".to_string(); set_state(app, ConnectionState::Error { message: msg.clone() }); return Err(msg); } set_state(app, ConnectionState::Connecting); let conf_path = storage::path(app, AWG_CONF)?; // awg(8) on Unix rejects explicit empty AWG 2.0 concealment fields (`I2 =`), // while an omitted field has the same zero-value semantics. Amnezia containers // commonly emit empty I2-I5, so remove only empty I1-I5 on macOS. When the // current network has no IPv6 default route, also omit IPv6 AllowedIPs: the // macOS route tool otherwise reports "Network is unreachable" during setup. // Filled concealment packets and the stored/exported profile remain untouched. #[cfg(target_os = "macos")] let config_to_write = normalize_awg_config_for_macos(config, has_ipv6_route()); #[cfg(not(target_os = "macos"))] let config_to_write = config; if let Err(e) = std::fs::write(&conf_path, config_to_write) { let m = format!("не удалось записать {AWG_CONF}: {e}"); set_state(app, ConnectionState::Error { message: m.clone() }); return Err(m); } #[cfg(target_os = "macos")] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&conf_path, std::fs::Permissions::from_mode(0o600)) .map_err(|e| format!("не удалось защитить {AWG_CONF}: {e}"))?; } let cp = conf_path.to_string_lossy().into_owned(); // Поднимаем тоннель движком под конкретную ОС; возвращённый «handle» кладём в // состояние для последующего снятия (Windows: имя сервиса; macOS: путь conf). let handle = match awg_tunnel_up(app, &cp) { Ok(h) => h, Err(e) => { set_state(app, ConnectionState::Error { message: e.clone() }); return Err(e); } }; { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.awg_tunnel = Some(handle); guard.state = ConnectionState::Connected; } let _ = app.emit(EVENT, ConnectionState::Connected); Ok(ConnectionState::Connected) } /// Поднимает AmneziaWG-тоннель из `.conf` движком под текущую ОС. Возвращает /// «handle» для снятия: Windows — имя сервиса тоннеля, macOS — путь к `.conf`. fn awg_tunnel_up(app: &AppHandle, conf: &str) -> Result { #[cfg(target_os = "windows")] { let _ = app; // снимаем возможный «зависший» тоннель с тем же именем (best-effort) let _ = run_awg(&["/uninstalltunnelservice", AWG_TUNNEL]); run_awg(&["/installtunnelservice", conf])?; Ok(AWG_TUNNEL.to_string()) } #[cfg(target_os = "macos")] { if elevation::is_elevated() { // приложение уже root — управляем движком напрямую let _ = run_awg_quick("down", conf); run_awg_quick("up", conf)?; } else { // обычный запуск: up от root через osascript + сигнальный файл spawn_awg_quick_root(app, conf)?; } Ok(conf.to_string()) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = (app, conf); Err("AmneziaWG на этой платформе не поддерживается".to_string()) } } /// macOS без root: `awg-quick up` через osascript (запрос пароля) + root-watcher, /// который снимет тоннель (`awg-quick down`), как только GUI удалит сигнальный /// файл — disconnect и выход из приложения не требуют повторного пароля. #[cfg(target_os = "macos")] fn spawn_awg_quick_root(app: &AppHandle, conf: &str) -> Result<(), String> { let dir = awg_engine_dir() .ok_or("движок AmneziaWG (amneziawg-go/awg/awg-quick) не найден рядом с приложением")?; let sentinel = storage::path(app, AWG_SENTINEL)?; std::fs::write(&sentinel, b"") .map_err(|e| format!("не удалось создать сигнальный файл: {e}"))?; // экранирование одинарных кавычек для sh: ' -> '\'' let q = |s: &str| s.replace('\'', "'\\''"); let dirq = q(&dir.to_string_lossy()); let confq = q(conf); let sentq = q(&sentinel.to_string_lossy()); let shell = format!( "export PATH='{dirq}':\"$PATH\"; export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go; \ bash '{dirq}/awg-quick' down '{confq}' >/dev/null 2>&1; \ bash '{dirq}/awg-quick' up '{confq}' && \ ( ( while [ -e '{sentq}' ]; do sleep 1; done; \ bash '{dirq}/awg-quick' down '{confq}' ) >/dev/null 2>&1 & )", ); if let Err(e) = elevation::osascript_admin(&shell) { let _ = std::fs::remove_file(&sentinel); return Err(e); } Ok(()) } /// Снятие AmneziaWG-тоннеля по сохранённому «handle» (Windows: имя сервиса; /// macOS: путь `.conf`). Best-effort — ошибку только логируем. fn awg_tunnel_down(app: &AppHandle, handle: &str) -> Result<(), String> { #[cfg(target_os = "windows")] { let _ = app; run_awg(&["/uninstalltunnelservice", handle])?; } #[cfg(target_os = "macos")] { if elevation::is_elevated() { run_awg_quick("down", handle)?; } else { // тоннель поднимал root-watcher — снимаем удалением сигнального файла if let Ok(sent) = storage::path(app, AWG_SENTINEL) { let _ = std::fs::remove_file(sent); } } } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = (app, handle); } Ok(()) } #[tauri::command] pub async fn disconnect(app: AppHandle) -> Result { { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.state = ConnectionState::Disconnecting; } let _ = app.emit(EVENT, ConnectionState::Disconnecting); let mut stop_errors = Vec::new(); // Windows updater немедленно завершает GUI после запуска установщика. // Поэтому сначала синхронно останавливаем и проверяем все принадлежащие // UniGate процессы. При ошибке оставляем handle в состоянии: повторное // нажатие снова попробует остановить тот же процесс и не пропустит установку. #[cfg(target_os = "windows")] { let (child_pid, shim_pid, awg) = { let conn = app.state::(); let guard = conn.0.lock().map_err(|e| e.to_string())?; ( guard.child.as_ref().map(CommandChild::pid), guard.shim.as_ref().map(std::process::Child::id), guard.awg_tunnel.clone(), ) }; if let Some(pid) = child_pid { match terminate_windows_process(pid, "sing-box") { Ok(()) => { let conn = app.state::(); if let Ok(mut guard) = conn.0.lock() { guard.child.take(); }; } Err(e) => stop_errors.push(e), } } if let Some(pid) = shim_pid { match terminate_windows_process(pid, "AWG-движок") { Ok(()) => { let conn = app.state::(); if let Ok(mut guard) = conn.0.lock() { if let Some(mut shim) = guard.shim.take() { let _ = shim.wait(); } }; } Err(e) => stop_errors.push(e), } } if let Some(handle) = awg { match awg_tunnel_down(&app, &handle) { Ok(()) => { let conn = app.state::(); if let Ok(mut guard) = conn.0.lock() { if guard.awg_tunnel.as_deref() == Some(handle.as_str()) { guard.awg_tunnel.take(); } }; } Err(e) => stop_errors.push(format!("не удалось остановить AmneziaWG: {e}")), } } } #[cfg(not(target_os = "windows"))] { let (child, awg, shim) = { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; (guard.child.take(), guard.awg_tunnel.take(), guard.shim.take()) }; if let Some(child) = child { let _ = child.kill(); } if let Some(mut shim) = shim { let _ = shim.kill(); if let Err(e) = shim.wait() { stop_errors.push(format!("не удалось дождаться остановки AWG-движка: {e}")); } } if let Some(handle) = awg { if let Err(e) = awg_tunnel_down(&app, &handle) { stop_errors.push(format!("не удалось остановить AmneziaWG: {e}")); } } } // macOS TUN: удаляем сигнальный файл — root-watcher сам убьёт sing-box (без // пароля). ВАЖНО дождаться реальной смерти ядра: оно держит порт Clash API // (9090), и мгновенный reconnect (например, в proxy-режиме) иначе падает с // «address already in use». #[cfg(target_os = "macos")] { let pid = { let conn = app.state::(); let mut guard = conn.0.lock().map_err(|e| e.to_string())?; guard.tun_root_pid.take() }; if let Some(pid) = pid { if let Ok(sent) = storage::path(&app, TUN_SENTINEL) { let _ = std::fs::remove_file(sent); } // watcher поллит сигнальный файл раз в 0.3 c; ждём до ~5 с for _ in 0..50 { if !pid_alive(pid) { break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } } if let Err(e) = sysproxy::disable() { eprintln!("не удалось снять системный прокси: {e}"); } if stop_errors.is_empty() { set_state(&app, ConnectionState::Disconnected); Ok(ConnectionState::Disconnected) } else { let message = stop_errors.join("; "); set_state( &app, ConnectionState::Error { message: message.clone(), }, ); Err(message) } } #[cfg(test)] mod tests { use super::{ normalize_awg_config_for_macos, parse_vpn_route_prefixes, pin_awg_endpoint, valid_geoip, }; #[test] fn awg_domain_endpoint_is_pinned_to_session_ip() { let conf = "[Interface]\r\nPrivateKey = TEST\r\n[Peer]\r\nEndpoint = vpn.example.com:45826\r\n"; let pinned = pin_awg_endpoint( conf, "vpn.example.com", 45826, "203.0.113.10".parse().unwrap(), ) .unwrap(); assert_eq!( pinned, "[Interface]\r\nPrivateKey = TEST\r\n[Peer]\r\nEndpoint = 203.0.113.10:45826\r\n" ); assert!( pin_awg_endpoint(conf, "wrong.example", 45826, "203.0.113.10".parse().unwrap()) .is_err() ); } #[cfg(target_os = "windows")] #[test] fn awg_endpoint_exclusion_keeps_existing_tun_exclusions() { let mut cfg = serde_json::json!({ "inbounds": [{ "type": "tun", "route_exclude_address": ["192.168.0.0/16"] }] }); let ip = "203.0.113.10".parse().unwrap(); super::exclude_awg_endpoint_from_tun(&mut cfg, &[ip, ip]); assert_eq!( cfg["inbounds"][0]["route_exclude_address"], serde_json::json!(["192.168.0.0/16", "203.0.113.10/32"]) ); } #[test] fn geoip_validation_rejects_html_and_truncated_data() { let mut valid = b"SRS\x01".to_vec(); valid.resize(10_000, 0); assert!(valid_geoip(&valid)); assert!(!valid_geoip(b"rate limited")); assert!(!valid_geoip(b"SRS\x01short")); } #[test] fn vpn_routes_keep_specific_networks_and_drop_default_hijacks() { let routes = parse_vpn_route_prefixes( "0.0.0.0/0\n0.0.0.0/1\n128.0.0.0/1\n10.44.0.0/16\n\ 100.96.0.0/11\n203.0.113.0/24\n198.18.0.0/15\n10.44.0.0/16\nbad\n", ); assert_eq!( routes, vec!["10.44.0.0/16", "100.96.0.0/11", "203.0.113.0/24"] ); } #[test] fn macos_awg_drops_only_empty_concealment_packets() { let config = "[Interface]\r\nI1 = \r\nI2 =\r\nI3 = ''\r\nI4 = \"\"\r\nI5 = \r\nJc = 5\r\n[Peer]\r\nPublicKey = PUB\r\n"; let normalized = normalize_awg_config_for_macos(config, true); assert!(normalized.contains("I1 = \r\n")); assert!(normalized.contains("I5 = \r\n")); assert!(normalized.contains("Jc = 5\r\n")); assert!(!normalized.contains("I2 =")); assert!(!normalized.contains("I3 =")); assert!(!normalized.contains("I4 =")); } #[test] fn macos_awg_preserves_config_without_empty_i_fields_exactly() { let config = "[Interface]\nI2 = \n[Peer]\nEndpoint = 1.2.3.4:1234"; assert_eq!(normalize_awg_config_for_macos(config, true), config); } #[test] fn macos_awg_drops_ipv6_routes_when_the_network_has_no_ipv6() { let config = "[Interface]\r\nAddress = 10.8.1.7/32\r\n[Peer]\r\nAllowedIPs = 0.0.0.0/0, ::/0, 10.0.0.0/8, 2001:db8::/32\r\nEndpoint = 192.0.2.1:51820\r\n"; let normalized = normalize_awg_config_for_macos(config, false); assert!(normalized.contains("AllowedIPs = 0.0.0.0/0, 10.0.0.0/8\r\n")); assert!(!normalized.contains("::/0")); assert!(!normalized.contains("2001:db8::/32")); assert!(normalized.contains("Endpoint = 192.0.2.1:51820\r\n")); } #[test] fn macos_awg_keeps_ipv6_routes_when_the_network_supports_ipv6() { let config = "[Peer]\nAllowedIPs = 0.0.0.0/0, ::/0\n"; assert_eq!(normalize_awg_config_for_macos(config, true), config); } }