Files
umbrix/WINDOWS_SPLIT_TUNNELING.md
Umbrix Developer 04eccff819
Some checks failed
Upload store MSIX to release / upload-store-msix-to-release (push) Has been cancelled
CI / run (push) Has been cancelled
feat: Android auto-update notifications with dialog
- Add auto-check updates for Android in bootstrap
- Show update dialog instead of toast notification
- Same UX as Desktop: dialog with 'Later' and 'Update' buttons
- Notifications appear 5 seconds after app launch

Part of v1.7.6
2026-01-20 19:36:33 +03:00

26 KiB
Raw Blame History

🔀 Split Tunneling для Windows (Per-App Proxy)

📋 Что такое Split Tunneling?

Split Tunneling (раздельное туннелирование) - функция которая позволяет:

  • Исключать определённые приложения из VPN (они идут напрямую)
  • Включать только выбранные приложения в VPN (остальные напрямую)

Примеры использования:

  • Исключить банковские приложения из VPN
  • Исключить локальные приложения (Steam, торренты)
  • Пускать через VPN только браузер
  • Пускать через VPN только мессенджеры

Текущее состояние

Android - ПОЛНОСТЬЮ РАБОТАЕТ

// android/app/src/main/kotlin/com/umbrix/app/bg/VPNService.kt

fun addIncludePackage(builder: Builder, packageName: String) {
    builder.addAllowedApplication(packageName)  // Android VPNService API
}

fun addExcludePackage(builder: Builder, packageName: String) {
    builder.addDisallowedApplication(packageName)  // Android VPNService API
}

UI готов: /settings/per-app-proxy

  • Список установленных приложений
  • Переключатель режимов: Все / Включить / Исключить
  • Фильтр системных приложений
  • Поиск по названию

Windows - ⚠️ НЕ РЕАЛИЗОВАНО

Проблема: На Windows нет аналога addAllowedApplication() из Android API.

Что есть:

  • UI уже готов (та же страница /settings/per-app-proxy)
  • Настройки сохраняются (per_app_proxy_mode, per_app_proxy_include_list, per_app_proxy_exclude_list)
  • sing-box поддерживает фильтрацию по процессам через process_name
  • НЕТ функции получения списка установленных приложений
  • НЕТ передачи списка процессов в libcore

🎯 Решение для Windows

Архитектура

┌─────────────────────────┐
│ Flutter UI              │
│ PerAppProxyPage         │ ← Уже готов!
└──────────┬──────────────┘
           │
           ↓
┌─────────────────────────┐
│ Platform Channel        │
│ get_installed_programs  │ ← Нужно создать
└──────────┬──────────────┘
           │
           ↓
┌─────────────────────────┐
│ C++ Windows Plugin      │
│ Enumerate processes     │ ← Нужно создать
└──────────┬──────────────┘
           │
           ↓
┌─────────────────────────┐
│ libcore config          │
│ process_name:           │ ← Нужно передавать
│   - chrome.exe          │
│   - firefox.exe         │
└─────────────────────────┘
           │
           ↓
┌─────────────────────────┐
│ sing-box routing        │
│ По имени процесса       │ ← Уже работает!
└─────────────────────────┘

Что нужно реализовать

1. Windows Plugin - получение списка программ ПРОСТО

Файл: windows/runner/flutter_window.cpp

#include <windows.h>
#include <tlhelp32.h>
#include <shlobj.h>
#include <atlbase.h>
#include <string>
#include <set>
#include <vector>

// Структура для хранения информации о программе
struct ProgramInfo {
    std::wstring name;         // Название (из описания .exe)
    std::wstring exePath;      // Полный путь к .exe
    std::wstring exeName;      // Только имя файла (chrome.exe)
};

// Получить описание .exe файла (название программы)
std::wstring GetFileDescription(const std::wstring& filePath) {
    DWORD dummy;
    DWORD size = GetFileVersionInfoSizeW(filePath.c_str(), &dummy);
    if (size == 0) return L"";
    
    std::vector<BYTE> data(size);
    if (!GetFileVersionInfoW(filePath.c_str(), 0, size, data.data())) {
        return L"";
    }
    
    struct LANGANDCODEPAGE {
        WORD language;
        WORD codePage;
    } *lpTranslate;
    
    UINT cbTranslate;
    if (!VerQueryValueW(data.data(), L"\\VarFileInfo\\Translation",
                        (LPVOID*)&lpTranslate, &cbTranslate)) {
        return L"";
    }
    
    wchar_t subBlock[50];
    swprintf(subBlock, 50, L"\\StringFileInfo\\%04x%04x\\FileDescription",
             lpTranslate[0].language, lpTranslate[0].codePage);
    
    LPWSTR lpBuffer;
    UINT   dwBytes;
    if (VerQueryValueW(data.data(), subBlock, (LPVOID*)&lpBuffer, &dwBytes)) {
        return std::wstring(lpBuffer);
    }
    
    return L"";
}

// Получить список всех запущенных процессов
std::set<std::wstring> GetRunningProcesses() {
    std::set<std::wstring> processes;
    
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnapshot == INVALID_HANDLE_VALUE) return processes;
    
    PROCESSENTRY32W pe32;
    pe32.dwSize = sizeof(PROCESSENTRY32W);
    
    if (Process32FirstW(hSnapshot, &pe32)) {
        do {
            processes.insert(pe32.szExeFile);
        } while (Process32NextW(hSnapshot, &pe32));
    }
    
    CloseHandle(hSnapshot);
    return processes;
}

// Сканировать папки с программами
std::vector<ProgramInfo> ScanInstalledPrograms() {
    std::vector<ProgramInfo> programs;
    std::set<std::wstring> runningProcs = GetRunningProcesses();
    
    // Папки для сканирования
    std::vector<std::wstring> folders = {
        L"C:\\Program Files",
        L"C:\\Program Files (x86)",
    };
    
    // Добавить AppData\\Local\\Programs
    wchar_t appDataPath[MAX_PATH];
    if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, appDataPath) == S_OK) {
        std::wstring localPrograms = std::wstring(appDataPath) + L"\\Programs";
        folders.push_back(localPrograms);
    }
    
    for (const auto& folder : folders) {
        WIN32_FIND_DATAW findData;
        HANDLE hFind = FindFirstFileW((folder + L"\\*").c_str(), &findData);
        
        if (hFind != INVALID_HANDLE_VALUE) {
            do {
                if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
                    if (wcscmp(findData.cFileName, L".") != 0 && 
                        wcscmp(findData.cFileName, L"..") != 0) {
                        
                        // Искать .exe в подпапке
                        std::wstring subfolder = folder + L"\\" + findData.cFileName;
                        WIN32_FIND_DATAW exeFind;
                        HANDLE hExe = FindFirstFileW((subfolder + L"\\*.exe").c_str(), &exeFind);
                        
                        if (hExe != INVALID_HANDLE_VALUE) {
                            do {
                                std::wstring exePath = subfolder + L"\\" + exeFind.cFileName;
                                std::wstring description = GetFileDescription(exePath);
                                
                                if (!description.empty()) {
                                    programs.push_back({
                                        description,
                                        exePath,
                                        exeFind.cFileName
                                    });
                                }
                            } while (FindNextFileW(hExe, &exeFind));
                            FindClose(hExe);
                        }
                    }
                }
            } while (FindNextFileW(hFind, &findData));
            FindClose(hFind);
        }
    }
    
    return programs;
}

// Flutter Method Channel Handler
void HandleGetInstalledPrograms(
    const flutter::MethodCall<flutter::EncodableValue>& method_call,
    std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
    
    auto programs = ScanInstalledPrograms();
    
    flutter::EncodableList programList;
    for (const auto& prog : programs) {
        flutter::EncodableMap programMap;
        
        // Конвертировать wstring в string (UTF-8)
        int nameLen = WideCharToMultiByte(CP_UTF8, 0, prog.name.c_str(), -1, NULL, 0, NULL, NULL);
        std::string name(nameLen - 1, 0);
        WideCharToMultiByte(CP_UTF8, 0, prog.name.c_str(), -1, &name[0], nameLen, NULL, NULL);
        
        int exeNameLen = WideCharToMultiByte(CP_UTF8, 0, prog.exeName.c_str(), -1, NULL, 0, NULL, NULL);
        std::string exeName(exeNameLen - 1, 0);
        WideCharToMultiByte(CP_UTF8, 0, prog.exeName.c_str(), -1, &exeName[0], exeNameLen, NULL, NULL);
        
        programMap[flutter::EncodableValue("name")] = flutter::EncodableValue(name);
        programMap[flutter::EncodableValue("packageName")] = flutter::EncodableValue(exeName);
        programMap[flutter::EncodableValue("isSystemApp")] = flutter::EncodableValue(false);
        
        programList.push_back(flutter::EncodableValue(programMap));
    }
    
    result->Success(flutter::EncodableValue(programList));
}

Регистрация Method Channel:

// В flutter_window.cpp, метод OnCreate()

#include <flutter/method_channel.h>
#include <flutter/standard_method_codec.h>

// В FlutterWindow::OnCreate() после создания flutter_controller_:

auto channel = std::make_unique<flutter::MethodChannel<>>(
    flutter_controller_->engine()->messenger(),
    "com.umbrix.app/platform",
    &flutter::StandardMethodCodec::GetInstance());

channel->SetMethodCallHandler(
    [](const auto& call, auto result) {
        if (call.method_name() == "get_installed_packages") {
            HandleGetInstalledPrograms(call, std::move(result));
        } else {
            result->NotImplemented();
        }
    });

2. Dart - адаптировать репозиторий ПРОСТО

Файл: lib/features/per_app_proxy/data/per_app_proxy_repository.dart

class PerAppProxyRepositoryImpl with InfraLogger implements PerAppProxyRepository {
  final _methodChannel = const MethodChannel("com.umbrix.app/platform");

  @override
  TaskEither<String, List<InstalledPackageInfo>> getInstalledPackages() {
    return TaskEither(
      () async {
        loggy.debug("getting installed packages info");
        
        // ✅ УЖЕ РАБОТАЕТ на Android
        // ✅ БУДЕТ РАБОТАТЬ на Windows после добавления C++ кода
        final result = await _methodChannel.invokeMethod<String>("get_installed_packages");
        
        if (result == null) return left("null response");
        return right(
          (jsonDecode(result) as List).map((e) {
            return InstalledPackageInfo.fromJson(e as Map<String, dynamic>);
          }).toList(),
        );
      },
    );
  }

  @override
  TaskEither<String, Uint8List> getPackageIcon(String packageName) {
    // Для Windows можно извлекать иконку из .exe файла
    // Или использовать placeholder иконку
    return TaskEither(
      () async {
        if (PlatformUtils.isDesktop) {
          // Временно возвращать пустую иконку
          return right(Uint8List(0));
        }
        
        // Android код остаётся без изменений
        loggy.debug("getting package [$packageName] icon");
        final result = await _methodChannel.invokeMethod<String>(
          "get_package_icon",
          {"packageName": packageName},
        );
        if (result == null) return left("null response");
        final Uint8List decoded;
        try {
          decoded = base64.decode(result);
        } catch (e) {
          return left("error parsing base64 response");
        }
        return right(decoded);
      },
    );
  }
}

3. libcore - передать список процессов СРЕДНЯЯ СЛОЖНОСТЬ

Файл: libcore/config/config.go

Нужно модифицировать функцию которая создаёт правила роутинга:

// Добавить в структуру HiddifyOptions
type HiddifyOptions struct {
    // ...существующие поля...
    
    // Новые поля для Per-App Proxy
    PerAppProxyMode        string   `json:"per_app_proxy_mode"`         // "off", "include", "exclude"
    IncludedApplications   []string `json:"included_applications"`      // ["chrome.exe", "firefox.exe"]
    ExcludedApplications   []string `json:"excluded_applications"`      // ["steam.exe", "uTorrent.exe"]
}

// В функции buildRouteRules() после существующих правил:

func buildRouteRules(options *HiddifyOptions) []option.Rule {
    var routeRules []option.Rule
    
    // ...существующие правила...
    
    // ====== PER-APP PROXY ДЛЯ WINDOWS/LINUX/MACOS ======
    if runtime.GOOS != "android" {  // Не Android
        if options.PerAppProxyMode == "include" && len(options.IncludedApplications) > 0 {
            // РЕЖИМ: Только выбранные приложения идут через VPN
            
            // 1. Выбранные приложения → VPN
            routeRules = append(routeRules, option.Rule{
                Type: C.RuleTypeDefault,
                DefaultOptions: option.DefaultRule{
                    ProcessName: options.IncludedApplications,  // ["chrome.exe", "firefox.exe"]
                    Outbound:    OutboundSelectTag,             // Через VPN
                },
            })
            
            // 2. Все остальные → Direct
            routeRules = append(routeRules, option.Rule{
                Type: C.RuleTypeDefault,
                DefaultOptions: option.DefaultRule{
                    Outbound: OutboundDirectTag,  // Напрямую
                },
            })
            
        } else if options.PerAppProxyMode == "exclude" && len(options.ExcludedApplications) > 0 {
            // РЕЖИМ: Исключённые приложения НЕ идут через VPN
            
            // 1. Исключённые приложения → Direct
            routeRules = append(routeRules, option.Rule{
                Type: C.RuleTypeDefault,
                DefaultOptions: option.DefaultRule{
                    ProcessName: options.ExcludedApplications,  // ["steam.exe", "uTorrent.exe"]
                    Outbound:    OutboundDirectTag,             // Напрямую
                },
            })
            
            // 2. Все остальные → VPN (это уже есть в default правилах)
        }
    }
    
    // Убедиться что Umbrix сам не идёт через VPN (чтобы избежать циклов)
    routeRules = append(routeRules, option.Rule{
        Type: C.RuleTypeDefault,
        DefaultOptions: option.DefaultRule{
            ProcessName: []string{
                "umbrix.exe", 
                "Umbrix.exe", 
                "UmbrixCli.exe", 
                "umbrix",     // Linux
                "Umbrix",     // macOS
            },
            Outbound: OutboundBypassTag,  // Всегда напрямую
        },
    })
    
    return routeRules
}

Файл: libcore/extension/interface.go

Передавать опции из Flutter:

func changeHiddifyOptions(jsonData string) error {
    var opts HiddifyOptions
    if err := json.Unmarshal([]byte(jsonData), &opts); err != nil {
        return err
    }
    
    // Логирование для отладки
    if opts.PerAppProxyMode != "off" {
        log.Printf("[Per-App] Режим: %s", opts.PerAppProxyMode)
        if opts.PerAppProxyMode == "include" {
            log.Printf("[Per-App] Включены приложения: %v", opts.IncludedApplications)
        } else if opts.PerAppProxyMode == "exclude" {
            log.Printf("[Per-App] Исключены приложения: %v", opts.ExcludedApplications)
        }
    }
    
    // Пересоздать конфигурацию с новыми правилами
    return recreateConfigWithOptions(opts)
}

4. Flutter - передавать список в libcore ПРОСТО

Файл: lib/singbox/model/singbox_config_option.dart

Добавить поля в SingboxConfigOption:

@freezed
class SingboxConfigOption with _$SingboxConfigOption {
  const SingboxConfigOption._();

  @JsonSerializable(fieldRename: FieldRename.kebab)
  const factory SingboxConfigOption({
    // ...существующие поля...
    required List<SingboxRule> rules,
    required SingboxMuxOption mux,
    required SingboxTlsTricks tlsTricks,
    required SingboxWarpOption warp,
    required SingboxWarpOption warp2,
    
    // ✨ НОВЫЕ ПОЛЯ
    @Default("off") String perAppProxyMode,              // "off", "include", "exclude"
    @Default([]) List<String> includedApplications,      // ["chrome.exe"]
    @Default([]) List<String> excludedApplications,      // ["steam.exe"]
  }) = _SingboxConfigOption;

  factory SingboxConfigOption.fromJson(Map<String, dynamic> json) => 
      _$SingboxConfigOptionFromJson(json);
}

Файл: lib/features/config_option/data/config_option_repository.dart

Добавить считывание настроек:

Future<SingboxConfigOption> getConfigOptions() async {
  final preferences = await ref.read(sharedPreferencesProvider.future);
  
  // ...существующие настройки...
  
  // ✨ Читать Per-App Proxy настройки
  final perAppProxyMode = preferences.getString("per_app_proxy_mode") ?? "off";
  final List<String> perAppList;
  
  if (perAppProxyMode == "include") {
    perAppList = preferences.getStringList("per_app_proxy_include_list") ?? [];
  } else if (perAppProxyMode == "exclude") {
    perAppList = preferences.getStringList("per_app_proxy_exclude_list") ?? [];
  } else {
    perAppList = [];
  }
  
  return SingboxConfigOption(
    // ...существующие параметры...
    
    // ✨ Передать в libcore
    perAppProxyMode: perAppProxyMode,
    includedApplications: perAppProxyMode == "include" ? perAppList : [],
    excludedApplications: perAppProxyMode == "exclude" ? perAppList : [],
  );
}

🚀 План реализации

Этап 1: Windows C++ Plugin (1-2 часа)

  • Создать функцию GetRunningProcesses()
  • Создать функцию ScanInstalledPrograms()
  • Создать функцию GetFileDescription()
  • Добавить Method Channel handler
  • Тестирование: список программ отображается

Этап 2: Dart адаптация (30 минут)

  • Обновить per_app_proxy_repository.dart для Desktop
  • Добавить placeholder иконки для Windows
  • Тестирование: UI показывает программы

Этап 3: libcore конфигурация (1 час)

  • Добавить поля в HiddifyOptions
  • Модифицировать buildRouteRules()
  • Добавить логирование
  • Пересобрать libcore для Windows

Этап 4: Flutter интеграция (30 минут)

  • Добавить поля в SingboxConfigOption
  • Обновить config_option_repository.dart
  • Генерация кода: flutter pub run build_runner build

Этап 5: Тестирование (1 час)

  • Режим "Все": все приложения через VPN
  • Режим "Включить": только Chrome через VPN
  • Режим "Исключить": Steam не через VPN
  • Проверить логи libcore
  • Проверить IP адрес в браузере vs Steam

Этап 6: Оптимизация (опционально)

  • Кэширование списка программ
  • Извлечение иконок из .exe файлов
  • Фоновое сканирование при запуске
  • Фильтр по папкам (не показывать system32)

🧪 Примеры использования

Пример 1: Только браузер через VPN

Настройки → Сеть → Исключения → вкладка "Приложения"

Режим: Включить [Proxy]
Выбрать:
  ✅ Google Chrome
  ✅ Mozilla Firefox
  ✅ Microsoft Edge

Результат:
  - Браузеры идут через VPN ✅
  - Все остальные программы напрямую ❌

Пример 2: Исключить торренты и игры

Настройки → Сеть → Исключения → вкладка "Приложения"

Режим: Исключить [Exclude]
Выбрать:
  ✅ uTorrent
  ✅ Steam
  ✅ Epic Games Launcher

Результат:
  - Торренты и игры идут напрямую ❌
  - Все остальные программы через VPN ✅

Пример 3: Исключить банковские приложения

Режим: Исключить [Exclude]
Выбрать:
  ✅ Sberbank Online
  ✅ Тинькофф

Результат:
  - Банки работают со своими серверами ❌
  - Остальное через VPN ✅

📊 Сравнение платформ

Функция Android Windows Linux macOS
Получить список приложений 🔨 🔨 🔨
Иконки приложений
Режим "Включить" 🔨 🔨 🔨
Режим "Исключить" 🔨 🔨 🔨
Фильтр по процессам

= Работает
🔨 = Нужно реализовать
= Опционально


🐛 Известные ограничения

Windows

  • Не все программы могут быть обнаружены (портативные версии)
  • UWP приложения из Microsoft Store могут требовать специального подхода
  • ⚠️ Фильтрация работает только по имени .exe (не по пути)
  • ⚠️ Если программа меняет имя процесса, фильтр не сработает

Sing-box

  • ⚠️ process_name работает только в режиме TUN
  • Не работает в режиме System Proxy
  • ⚠️ На Linux требуется /proc доступ

🔗 Полезные ссылки


Чеклист для разработчика

Перед началом работы убедитесь что:

  • Установлен Visual Studio 2022 с C++ Desktop Development
  • Есть доступ к windows/runner/flutter_window.cpp
  • Знакомы с Flutter Platform Channels
  • Понимаете как работает sing-box routing

После реализации проверьте:

  • Список программ загружается без ошибок
  • UI отзывчив (сканирование не блокирует интерфейс)
  • Настройки сохраняются после перезапуска
  • VPN работает с выбранными приложениями
  • Логи libcore показывают правильные правила
  • Нет утечек памяти при переключении режимов

💡 Альтернативные подходы

Подход 1: Windows Filtering Platform (WFP) - СЛОЖНО

Использовать Windows Firewall API для фильтрации по PID процесса.

  • Более надёжно (работает на уровне ядра)
  • Очень сложно в реализации
  • Требует драйвер или высокие привилегии

Подход 2: Прокси авторизация - СРЕДНЕ

Настроить прокси с авторизацией по процессу.

  • Не требует TUN режим
  • Не все приложения поддерживают прокси
  • Нужен специальный прокси сервер

Подход 3: Текущий (process_name в sing-box) - ПРОСТО

Использовать встроенную поддержку sing-box.

  • Уже реализовано в sing-box
  • Работает надёжно
  • Не требует дополнительных драйверов
  • ⚠️ Только в TUN режиме

Рекомендуемый подход: #3 (текущий через sing-box)

Время на полную реализацию: 3-4 часа
Сложность: Средняя (C++ + Dart + Go)
Приоритет: Высокий (очень востребованная функция)