//! Генератор конфигурации sing-box из профиля UniGate. //! //! Phase 3 (proxy mode): локальный `mixed`-inbound на 127.0.0.1: //! + один outbound, описанный профилем. Трафик: приложения → локальный //! sing-box → сервер из профиля → интернет. //! //! Формат сверяется со схемой sing-box (зафиксированная версия — см. CLAUDE.md). use crate::models::{AppMode, Mode, Outbound, Profile, Routing, TlsOpts, Transport, TunStack}; use serde_json::{json, Value}; /// Порт локального Clash API (статистика трафика). Phase 5. pub const CLASH_API_PORT: u16 = 9090; /// Полный конфиг sing-box по режиму. `geoip_ru` — путь к geoip-ru.srs (для RU-обхода). /// `bootstrap_ips` — заранее разрезолвленные IP домена сервера (macOS TUN, /// см. комментарий про DNS-бутстрап в `generate_tun`); пустой список = нет. /// `tun_ipv6` — перехватывать ли IPv6 (только если у системы РЕАЛЬНО есть /// IPv6-маршрут, см. комментарий у tun-адреса в `generate_tun`). #[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))] pub fn generate( profile: &Profile, mode: Mode, local_port: u16, routing: &Routing, geoip_ru: Option<&str>, bootstrap_ips: &[std::net::IpAddr], tun_ipv6: bool, stack: TunStack, ) -> Value { generate_with_vpn_routes( profile, mode, local_port, routing, geoip_ru, bootstrap_ips, tun_ipv6, stack, &[], ) } /// Вариант [`generate`] с маршрутами уже активного стороннего VPN. /// Они исключаются из TUN UniGate только при явно включённой /// `vpn_compatibility`; default route сюда не передаётся. pub fn generate_with_vpn_routes( profile: &Profile, mode: Mode, local_port: u16, routing: &Routing, geoip_ru: Option<&str>, bootstrap_ips: &[std::net::IpAddr], tun_ipv6: bool, stack: TunStack, vpn_route_excludes: &[String], ) -> Value { match mode { Mode::Proxy => generate_proxy(profile, local_port), Mode::Tun => generate_tun( profile, routing, geoip_ru, bootstrap_ips, tun_ipv6, stack, vpn_route_excludes, ), } } /// Домен сервера outbound, если это именно домен (IP-литерал → None). /// AmneziaWG тоже участвует: awg-shim должен заранее узнать IP /// endpoint, чтобы его UDP-сокет гарантированно шёл direct и не зацикливался. pub fn server_domain(outbound: &Outbound) -> Option<&str> { let server = match outbound { Outbound::Socks { server, .. } | Outbound::Http { server, .. } | Outbound::Hysteria2 { server, .. } | Outbound::Shadowsocks { server, .. } | Outbound::Trojan { server, .. } | Outbound::Vless { server, .. } | Outbound::Vmess { server, .. } | Outbound::Tuic { server, .. } | Outbound::AmneziaWg { server, .. } => server, }; if server.parse::().is_ok() { None } else { Some(server) } } fn clash_api() -> Value { json!({ "clash_api": { "external_controller": format!("127.0.0.1:{CLASH_API_PORT}") } }) } /// Proxy mode: локальный `mixed`-inbound + системный прокси (без админа). fn generate_proxy(profile: &Profile, local_port: u16) -> Value { json!({ "log": { "level": "warn", "timestamp": true }, "inbounds": [{ "type": "mixed", "tag": "in", "listen": "127.0.0.1", "listen_port": local_port }], "outbounds": [ outbound_to_json(&profile.outbound) ], "experimental": clash_api() }) } /// TUN mode: виртуальный адаптер, весь трафик ОС идёт через профиль (нужен админ). /// Схема sing-box 1.14 (новый DNS, default_domain_resolver, sniff+hijack-dns). /// Правила split-tunneling строятся из `routing`. fn generate_tun( profile: &Profile, routing: &Routing, geoip_ru: Option<&str>, bootstrap_ips: &[std::net::IpAddr], tun_ipv6: bool, stack: TunStack, vpn_route_excludes: &[String], ) -> Value { #[cfg(target_os = "windows")] let _ = (bootstrap_ips, tun_ipv6); // Windows: проверенный конфиг, эти механизмы не используем let use_ru = routing.bypass_ru && geoip_ru.is_some(); let mut rules: Vec = vec![ json!({ "action": "sniff" }), json!({ "protocol": "dns", "action": "hijack-dns" }), ]; // обходы (напрямую, мимо туннеля) — до правил по приложениям if routing.bypass_lan { rules.push(json!({ "ip_is_private": true, "outbound": "direct" })); } if use_ru { rules.push(json!({ "rule_set": ["geoip-ru"], "outbound": "direct" })); rules.push(json!({ "domain_suffix": [".ru", ".рф", ".su"], "outbound": "direct" })); } // split по приложениям (у каждого режима свой список). DNS следует той же // развилке: в Only непроксируемые приложения резолвятся локально, в Except // выбранные direct-приложения получают локальный DNS отдельным правилом. // Иначе direct-трафик зависел бы от DoH через VPN и мог получать ответы для // другого региона либо вообще терять резолв при проблемах туннеля. let (final_out, dns_final, app_dns_rules) = match routing.app_mode { AppMode::Only if !routing.only_apps.is_empty() => { rules.extend(per_app_rules(&routing.only_apps, "proxy")); ( "direct", // остальные — напрямую "local", // и резолвятся через локальную сеть per_app_dns_rules(&routing.only_apps, "remote"), ) } AppMode::Except if !routing.except_apps.is_empty() => { rules.extend(per_app_rules(&routing.except_apps, "direct")); ( "proxy", // остальные — через туннель "remote", per_app_dns_rules(&routing.except_apps, "local"), ) } _ => ("proxy", "remote", Vec::new()), }; // DNS-серверы. `remote` — для проксируемого трафика (через туннель). // `local` — резолвер, ходящий мимо tun (бутстрап домена сервера + RU-домены). // // `remote` = DoH (`type:"https"`, 8.8.8.8, detour:"proxy") на ВСЕХ платформах. // Раньше на Windows тут был UDP:53 — но это самый хрупкий путь: при заторе // канала/стека UDP-пакеты молча теряются и резолв висит по 20-40 с (в логе — // «dns: exchange failed ... context deadline exceeded»), из-за чего под // нагрузкой «перестаёт открываться вообще всё». DoH — обычный TCP/TLS, // переживает потери куда лучше (ретраи, переиспользование соединения). // // `local`: // - Windows: `{type:"local"}` — системный резолвер, ходит мимо tun по // underlying-сети. Работает стабильно. // - macOS/прочие: явный публичный upstream (77.88.8.8, Яндекс.DNS) БЕЗ detour. // `{type:"local"}` там даёт дедлок при активном СТОРОННЕМ VPN (системный // резолвер = его локальный стаб, молчит, когда наш tun перехватил трафик). // DNS-серверы без detour ходят через default-dialer, привязанный // `auto_detect_interface` к физическому интерфейсу и минующий tun. // `detour:"direct"` тут НЕЛЬЗЯ — sing-box падает «detour to an empty // direct outbound makes no sense». // - `bootstrap` (только не-Windows) = hosts-сервер с заранее разрезолвленными // IP домена сервера (резолвим в Rust ДО запуска ядра). Подъём туннеля не // зависит от доступности 77.88.8.8 (сети с фильтрацией стороннего DNS). #[cfg(target_os = "windows")] let local_dns = json!({ "type": "local", "tag": "local" }); #[cfg(not(target_os = "windows"))] let local_dns = json!({ "type": "udp", "tag": "local", "server": "77.88.8.8" }); let remote_dns = json!({ "type": "https", "tag": "remote", "server": "8.8.8.8", "detour": "proxy" }); #[cfg_attr(target_os = "windows", allow(unused_mut))] let mut servers = vec![remote_dns, local_dns]; #[cfg_attr(target_os = "windows", allow(unused_mut))] let mut domain_resolver = "local"; #[cfg(not(target_os = "windows"))] if !bootstrap_ips.is_empty() { if let Some(host) = server_domain(&profile.outbound) { let ips: Vec = bootstrap_ips.iter().map(|ip| ip.to_string()).collect(); servers.push(json!({ "type": "hosts", "tag": "bootstrap", "predefined": { host: ips } })); domain_resolver = "bootstrap"; } } let mut route = json!({ "auto_detect_interface": true, "default_domain_resolver": { "server": domain_resolver }, "final": final_out, "rules": rules }); if use_ru { route["rule_set"] = json!([{ "type": "local", "tag": "geoip-ru", "format": "binary", "path": geoip_ru.unwrap() }]); } let mut dns = json!({ "servers": servers, "final": dns_final, "strategy": "ipv4_only" }); // Про кэш DNS при split: один и тот же домен может резолвиться разными // серверами для разных приложений, и ответ из remote-кэша не должен // попасть в local-запрос (и наоборот). До 1.14 это включал // `independent_cache`; с 1.14 кэш всегда раздельный по тегу сервера, а // опция объявлена deprecated (удаление в 1.16) — поэтому её здесь нет. let mut dns_rules: Vec = Vec::new(); // RU-домены резолвим напрямую (через `local`, мимо туннеля): адрес совпадает // с тем, куда пойдёт direct-трафик (route rule_set geoip-ru срабатывает), а // главное — резолв НЕ зависит от туннеля. Иначе .ru-домен (например, // gw-pvp.escapefromtarkov.ru) при заторе резолвится через забитый туннель и // висит по 20-40 с — игра не находит свой шлюз, хотя маршрут для неё direct. // Раньше было только на macOS; на Windows .ru молча резолвился через туннель. if use_ru { dns_rules.push(json!({ "domain_suffix": [".ru", ".рф", ".su"], "action": "route", "server": "local" })); } // Глобальные обходы имеют тот же приоритет, что и в route: RU сначала, // затем правила выбранных приложений. dns_rules.extend(app_dns_rules); if !dns_rules.is_empty() { dns["rules"] = json!(dns_rules); } // Адрес tun берём из зарезервированной benchmark-сети 198.18.0.0/15, а не // из RFC1918. Иначе обход корпоративной сети 172.16.0.0/12 (OpenVPN) // одновременно исключает собственный 172.18.0.0/30 и ломает весь TUN. // macOS/прочие — плюс IPv6, но ТОЛЬКО если у системы реально есть // IPv6-маршрут (`tun_ipv6`): // - есть IPv6: без перехвата auto_route забирает только IPv4-маршрут, а // IPv6-трафик (браузеры со своим DoH получают AAAA мимо нашего hijack-dns) // уходит в обход туннеля → заблокированные сайты «иногда не открываются»; // - нет IPv6 (IPv4-only сеть): перехватывать нельзя — фейковый IPv6-маршрут // заставляет приложения выбирать IPv6, а direct-обход (bypass_ru) не может // его никуда доставить → мгновенный разрыв (ERR_CONNECTION_CLOSED на // ya.ru при включённом RU-обходе). Утечь IPv6 тут и так не может. #[cfg(target_os = "windows")] let tun_address = json!(["198.18.0.1/30"]); #[cfg(not(target_os = "windows"))] let tun_address = if tun_ipv6 { json!(["198.18.0.1/30", "fdfe:dcba:9876::1/126"]) } else { json!(["198.18.0.1/30"]) }; let mut tun_inbound = json!({ "type": "tun", "tag": "tun-in", "address": tun_address, "auto_route": true, // На Windows strict_route ставит WFP-фильтр, который запрещает DNS:53 // через любой интерфейс кроме нашего TUN. Это несовместимо с OpenVPN // Connect: его DNS обязан идти через TAP/DCO. Отдельная опция // vpn_compatibility вместе с обходом LAN оставляет нативные маршруты // другого VPN и снимает только этот строгий DNS-фильтр; маршруты // UniGate по-прежнему задаёт auto_route. "strict_route": !(cfg!(target_os = "windows") && routing.bypass_lan && routing.vpn_compatibility), "stack": stack.as_singbox() }); if routing.bypass_lan { // Исключаем RFC1918 из маршрутов TUN на уровне ОС, а не только выбираем // direct-outbound после захвата. Так более специфичные маршруты других // VPN (OpenVPN/TAP/Wintun) остаются нативными и не переоткрываются // sing-box через физический интерфейс. В частности, это сохраняет // рабочие сети 10.240.0.0/24 и 172.16.0.0/12 из OpenVPN. let mut excludes = vec![ "10.0.0.0/8".to_string(), "100.64.0.0/10".to_string(), "169.254.0.0/16".to_string(), "172.16.0.0/12".to_string(), "192.168.0.0/16".to_string(), ]; // На Windows режим совместимости добавляет реальные маршруты // активного OpenVPN/WireGuard-клиента. Так работают не только // RFC1918, но и 100.64/10/публичные корпоративные подсети. if cfg!(target_os = "windows") && routing.vpn_compatibility { for prefix in vpn_route_excludes { if !excludes.contains(prefix) { excludes.push(prefix.clone()); } } } tun_inbound["route_exclude_address"] = json!(excludes); } json!({ "log": { "level": "warn", "timestamp": true }, "dns": dns, "inbounds": [tun_inbound], "outbounds": [ outbound_to_json(&profile.outbound), { "type": "direct", "tag": "direct" } ], "route": route, "experimental": clash_api() }) } /// Route-правила split по приложениям. Windows: точное `process_name` («Foo.exe»). /// macOS: сеть у бандла часто живёт НЕ в процессе с именем бандла — у /// Chromium/Electron это хелперы («Google Chrome Helper»), а исполняемый файл /// может называться иначе (Visual Studio Code.app → `Electron`), поэтому одного /// `process_name` мало. Добавляем правило по пути исполняемого файла: /// `process_path_regex` на `/.app/` покрывает ВСЕ процессы внутри бандла /// независимо от каталога установки. Правила в списке объединяются по ИЛИ. fn per_app_rules(apps: &[String], outbound: &str) -> Vec { let rules = vec![json!({ "process_name": apps, "outbound": outbound })]; #[cfg(target_os = "macos")] let rules = { let mut rules = rules; let patterns: Vec = apps.iter().map(|a| app_bundle_regex(a)).collect(); rules.push(json!({ "process_path_regex": patterns, "outbound": outbound })); rules }; rules } /// DNS-правила split по приложениям. Повторяют process-matching route-правил, /// но выбирают DNS-сервер вместо outbound. fn per_app_dns_rules(apps: &[String], server: &str) -> Vec { let rules = vec![json!({ "process_name": apps, "action": "route", "server": server })]; #[cfg(target_os = "macos")] let rules = { let mut rules = rules; let patterns: Vec = apps.iter().map(|a| app_bundle_regex(a)).collect(); rules.push(json!({ "process_path_regex": patterns, "action": "route", "server": server })); rules }; rules } /// Regex для пути внутри бандла `.app` (метасимволы имени экранируем). #[cfg(any(target_os = "macos", test))] fn app_bundle_regex(name: &str) -> String { let mut escaped = String::with_capacity(name.len()); for c in name.chars() { if matches!( c, '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' ) { escaped.push('\\'); } escaped.push(c); } format!("/{escaped}\\.app/") } /// Публичный маппинг Outbound → sing-box outbound-объект (для экспорта в JSON). pub fn outbound_to_singbox(outbound: &Outbound) -> Value { outbound_to_json(outbound) } /// Маппинг доменного `Outbound` в outbound-объект sing-box. fn outbound_to_json(outbound: &Outbound) -> Value { match outbound { Outbound::Socks { server, port, username, password, } => { let mut v = json!({ "type": "socks", "tag": "proxy", "server": server, "server_port": port, "version": "5" }); // sing-box: для socks аутентификация задаётся парой username/password if let (Some(u), Some(p)) = (username, password) { v["username"] = json!(u); v["password"] = json!(p); } v } Outbound::Http { server, port, username, password, tls, } => { let mut v = json!({ "type": "http", "tag": "proxy", "server": server, "server_port": port }); if let Some(u) = username { v["username"] = json!(u); } if let Some(p) = password { v["password"] = json!(p); } if *tls { v["tls"] = json!({ "enabled": true }); } v } Outbound::Hysteria2 { server, port, password, sni, insecure, obfs_password, up_mbps, down_mbps, } => { let mut tls = json!({ "enabled": true }); if let Some(s) = sni { tls["server_name"] = json!(s); } if *insecure { tls["insecure"] = json!(true); } let mut v = json!({ "type": "hysteria2", "tag": "proxy", "server": server, "server_port": port, "password": password, "tls": tls }); if let Some(op) = obfs_password { v["obfs"] = json!({ "type": "salamander", "password": op }); } if let Some(up) = up_mbps { v["up_mbps"] = json!(up); } if let Some(down) = down_mbps { v["down_mbps"] = json!(down); } v } Outbound::Shadowsocks { server, port, method, password, } => json!({ "type": "shadowsocks", "tag": "proxy", "server": server, "server_port": port, "method": method, "password": password }), Outbound::Trojan { server, port, password, tls, transport, } => { let mut v = json!({ "type": "trojan", "tag": "proxy", "server": server, "server_port": port, "password": password }); apply_tls(&mut v, tls); apply_transport(&mut v, transport); v } Outbound::Vless { server, port, uuid, flow, tls, transport, } => { let mut v = json!({ "type": "vless", "tag": "proxy", "server": server, "server_port": port, "uuid": uuid }); if let Some(f) = flow { v["flow"] = json!(f); } apply_tls(&mut v, tls); apply_transport(&mut v, transport); v } Outbound::Vmess { server, port, uuid, alter_id, security, tls, transport, } => { let mut v = json!({ "type": "vmess", "tag": "proxy", "server": server, "server_port": port, "uuid": uuid, "alter_id": alter_id }); if let Some(s) = security { v["security"] = json!(s); } apply_tls(&mut v, tls); apply_transport(&mut v, transport); v } Outbound::Tuic { server, port, uuid, password, congestion_control, tls, } => { let mut v = json!({ "type": "tuic", "tag": "proxy", "server": server, "server_port": port, "uuid": uuid, "password": password }); if let Some(c) = congestion_control { v["congestion_control"] = json!(c); } // TUIC всегда поверх TLS let mut tls = tls.clone(); tls.enabled = true; apply_tls(&mut v, &tls); v } // AmneziaWG идёт через собственный движок (amneziawg.exe), не через sing-box — // сюда поток не доходит (менеджер подключения ветвится раньше). Outbound::AmneziaWg { .. } => json!({}), } } /// Добавляет в outbound объект `tls`, если TLS включён. fn apply_tls(v: &mut Value, tls: &TlsOpts) { if !tls.enabled { return; } let mut t = json!({ "enabled": true }); if let Some(s) = &tls.sni { t["server_name"] = json!(s); } if tls.insecure { t["insecure"] = json!(true); } if !tls.alpn.is_empty() { t["alpn"] = json!(tls.alpn); } if let Some(fp) = &tls.fingerprint { t["utls"] = json!({ "enabled": true, "fingerprint": fp }); } if let Some(pbk) = &tls.reality_public_key { let mut r = json!({ "enabled": true, "public_key": pbk }); if let Some(sid) = &tls.reality_short_id { r["short_id"] = json!(sid); } t["reality"] = r; } v["tls"] = t; } /// Добавляет в outbound объект `transport`, если он задан (ws/grpc). fn apply_transport(v: &mut Value, transport: &Option) { let Some(transport) = transport else { return }; v["transport"] = match transport { Transport::Ws { path, host } => { let mut t = json!({ "type": "ws" }); if let Some(p) = path { t["path"] = json!(p); } if let Some(h) = host { t["headers"] = json!({ "Host": h }); } t } Transport::Grpc { service_name } => { let mut t = json!({ "type": "grpc" }); if let Some(s) = service_name { t["service_name"] = json!(s); } t } }; } #[cfg(test)] mod tests { use super::*; fn socks_profile() -> Profile { Profile { id: "x".into(), name: "t".into(), outbound: Outbound::Socks { server: "1.2.3.4".into(), port: 1080, username: None, password: None, }, subscription_id: None, } } #[test] fn tun_routing_ru_lan_apps_only() { let routing = Routing { bypass_lan: true, vpn_compatibility: true, bypass_ru: true, app_mode: AppMode::Only, only_apps: vec!["telegram.exe".into()], except_apps: vec![], }; let cfg = generate( &socks_profile(), Mode::Tun, 2080, &routing, Some("C:/x/geoip-ru.srs"), &[], false, TunStack::Gvisor, ); let s = serde_json::to_string(&cfg).unwrap(); assert!(s.contains("ip_is_private")); assert!(s.contains("geoip-ru")); assert!(s.contains("telegram.exe")); assert_eq!(cfg["route"]["final"], "direct"); // Only → остальные напрямую assert_eq!(cfg["inbounds"][0]["address"][0], "198.18.0.1/30"); #[cfg(target_os = "windows")] assert_eq!(cfg["inbounds"][0]["strict_route"], false); assert!(cfg["inbounds"][0]["route_exclude_address"] .as_array() .unwrap() .iter() .any(|value| value == "172.16.0.0/12")); assert!(cfg["route"]["rule_set"].is_array()); // RU-домены имеют приоритет и резолвятся напрямую; DNS выбранного // приложения идёт через VPN, DNS остальных — локально. assert_eq!(cfg["dns"]["final"], "local"); assert!(cfg["dns"]["independent_cache"].is_null()); assert_eq!(cfg["dns"]["rules"][0]["server"], "local"); assert_eq!(cfg["dns"]["rules"][1]["process_name"][0], "telegram.exe"); assert_eq!(cfg["dns"]["rules"][1]["server"], "remote"); } #[test] fn tun_routing_apps_except_splits_dns_too() { let routing = Routing { bypass_lan: false, vpn_compatibility: false, bypass_ru: false, app_mode: AppMode::Except, only_apps: vec![], except_apps: vec!["horizon-client.exe".into()], }; let cfg = generate( &socks_profile(), Mode::Tun, 2080, &routing, None, &[], false, TunStack::Gvisor, ); assert_eq!(cfg["route"]["final"], "proxy"); assert_eq!(cfg["dns"]["final"], "remote"); assert!(cfg["dns"]["independent_cache"].is_null()); assert_eq!( cfg["dns"]["rules"][0]["process_name"][0], "horizon-client.exe" ); assert_eq!(cfg["dns"]["rules"][0]["server"], "local"); } #[test] fn tun_stack_selectable() { // по умолчанию gvisor; system прокидывается в inbound let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &[], false, TunStack::Gvisor); assert_eq!(cfg["inbounds"][0]["stack"], "gvisor"); let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &[], false, TunStack::System); assert_eq!(cfg["inbounds"][0]["stack"], "system"); let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &[], false, TunStack::Mixed); assert_eq!(cfg["inbounds"][0]["stack"], "mixed"); } #[test] fn tun_routing_off_defaults_to_proxy() { let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &[], false, TunStack::Gvisor); assert_eq!(cfg["route"]["final"], "proxy"); assert_eq!(cfg["dns"]["final"], "remote"); assert!(cfg["route"].get("rule_set").is_none()); assert!(cfg["dns"].get("rules").is_none()); assert!(cfg["inbounds"][0].get("route_exclude_address").is_none()); assert_eq!(cfg["inbounds"][0]["strict_route"], true); } #[test] fn tun_lan_bypass_keeps_strict_dns_without_vpn_compatibility() { let routing = Routing { bypass_lan: true, ..Routing::default() }; let cfg = generate( &socks_profile(), Mode::Tun, 2080, &routing, None, &[], false, TunStack::Gvisor, ); assert_eq!(cfg["inbounds"][0]["strict_route"], true); assert!(cfg["inbounds"][0]["route_exclude_address"].is_array()); } #[test] fn vpn_compatibility_adds_detected_routes_without_replacing_unigate_default() { let routing = Routing { bypass_lan: true, vpn_compatibility: true, ..Routing::default() }; let detected = vec!["100.96.0.0/11".to_string(), "203.0.113.0/24".to_string()]; let cfg = generate_with_vpn_routes( &socks_profile(), Mode::Tun, 2080, &routing, None, &[], false, TunStack::Gvisor, &detected, ); let excludes = cfg["inbounds"][0]["route_exclude_address"] .as_array() .unwrap(); assert!(excludes.iter().any(|value| value == "100.64.0.0/10")); #[cfg(target_os = "windows")] { assert!(excludes.iter().any(|value| value == "100.96.0.0/11")); assert!(excludes.iter().any(|value| value == "203.0.113.0/24")); } assert!(!excludes.iter().any(|value| value == "0.0.0.0/0")); assert_eq!(cfg["route"]["final"], "proxy"); } #[test] fn old_routing_settings_default_vpn_compatibility_to_off() { let routing: Routing = serde_json::from_value(json!({ "bypassLan": true, "bypassRu": true, "appMode": "off", "onlyApps": [], "exceptApps": [] })) .unwrap(); assert!(!routing.vpn_compatibility); } #[test] fn app_bundle_regex_escapes_meta() { assert_eq!(app_bundle_regex("Google Chrome"), "/Google Chrome\\.app/"); assert_eq!(app_bundle_regex("Foo.Bar (1)+"), "/Foo\\.Bar \\(1\\)\\+\\.app/"); } #[test] fn tun_dns_bootstrap_goes_direct() { let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &[], false, TunStack::Gvisor); let servers = cfg["dns"]["servers"].as_array().unwrap(); // `remote` — через туннель assert_eq!(servers[0]["detour"], "proxy"); // `local` — явный публичный upstream БЕЗ detour (default-dialer мимо tun); // НЕ системный резолвер (дедлок при стороннем VPN) и НЕ detour:direct (fatal) #[cfg(not(target_os = "windows"))] { assert_eq!(servers[1]["type"], "udp"); assert!(servers[1]["server"].is_string()); } assert!(servers[1].get("detour").is_none()); assert_eq!(cfg["route"]["default_domain_resolver"]["server"], "local"); } /// Профиль с доменом сервера (не IP) — для проверки предрезолва. fn domain_profile() -> Profile { Profile { id: "x".into(), name: "t".into(), outbound: Outbound::Socks { server: "vpn.example.com".into(), port: 1080, username: None, password: None, }, subscription_id: None, } } #[test] fn server_domain_skips_ip_literals() { assert_eq!(server_domain(&domain_profile().outbound), Some("vpn.example.com")); assert_eq!(server_domain(&socks_profile().outbound), None); // 1.2.3.4 let awg = Outbound::AmneziaWg { config: String::new(), server: "awg.example.com".into(), port: 51820, }; assert_eq!(server_domain(&awg), Some("awg.example.com")); } #[cfg(not(target_os = "windows"))] #[test] fn tun_preresolved_bootstrap_uses_hosts_server() { let ips = vec!["5.6.7.8".parse::().unwrap()]; let cfg = generate(&domain_profile(), Mode::Tun, 2080, &Routing::default(), None, &ips, true, TunStack::Gvisor); let servers = cfg["dns"]["servers"].as_array().unwrap(); // remote — DoH через туннель (не UDP: тот хрупок поверх TCP-прокси) assert_eq!(servers[0]["type"], "https"); // hosts-сервер с предрезолвленным IP; бутстрап через него let hosts = servers.iter().find(|s| s["type"] == "hosts").unwrap(); assert_eq!(hosts["predefined"]["vpn.example.com"][0], "5.6.7.8"); assert_eq!(cfg["route"]["default_domain_resolver"]["server"], "bootstrap"); // tun перехватывает и IPv6 (иначе обход туннеля в IPv6-сетях) let addr = cfg["inbounds"][0]["address"].as_array().unwrap(); assert_eq!(addr.len(), 2); // IP-литерал сервера или пустой предрезолв → обычный фолбэк `local` let cfg = generate(&socks_profile(), Mode::Tun, 2080, &Routing::default(), None, &ips, true, TunStack::Gvisor); assert_eq!(cfg["route"]["default_domain_resolver"]["server"], "local"); // IPv4-only сеть (tun_ipv6=false): IPv6 НЕ перехватываем — фейковый // маршрут ломает direct-обход (ERR_CONNECTION_CLOSED на RU-сайтах) let cfg = generate(&domain_profile(), Mode::Tun, 2080, &Routing::default(), None, &ips, false, TunStack::Gvisor); let addr = cfg["inbounds"][0]["address"].as_array().unwrap(); assert_eq!(addr.len(), 1); } }