Ctrl K
ComunidadGuías…

src-tauri/src/config.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Генератор конфигурации sing-box из профиля UniGate.
//!
//! Phase 3 (proxy mode): локальный `mixed`-inbound на 127.0.0.1:<port>
//! + один 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::<std::net::IpAddr>().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<Value> = 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<String> = 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<Value> = 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` на `/<Name>.app/` покрывает ВСЕ процессы внутри бандла
/// независимо от каталога установки. Правила в списке объединяются по ИЛИ.
fn per_app_rules(apps: &[String], outbound: &str) -> Vec<Value> {
    let rules = vec![json!({ "process_name": apps, "outbound": outbound })];
    #[cfg(target_os = "macos")]
    let rules = {
        let mut rules = rules;
        let patterns: Vec<String> = 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<Value> {
    let rules = vec![json!({
        "process_name": apps,
        "action": "route",
        "server": server
    })];
    #[cfg(target_os = "macos")]
    let rules = {
        let mut rules = rules;
        let patterns: Vec<String> = apps.iter().map(|a| app_bundle_regex(a)).collect();
        rules.push(json!({
            "process_path_regex": patterns,
            "action": "route",
            "server": server
        }));
        rules
    };
    rules
}

/// Regex для пути внутри бандла `<name>.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<Transport>) {
    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::<std::net::IpAddr>().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);
    }
}