From 9a2f1387f964384a9ac50b04b99568b95da291a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=95=D0=B1=D0=B0=D0=BA=D0=BB=D0=B0=D0=BA=D0=BE=D0=B2?= Date: Mon, 6 Apr 2026 14:07:11 +0300 Subject: [PATCH] first commit --- .gitignore | 5 + README.md | 129 +++++++++++++++ bin/.gitkeep | 8 + requirements.txt | 20 +++ src/__init__.py | 1 + src/config.py | 164 +++++++++++++++++++ src/macos_router.py | 156 ++++++++++++++++++ src/main.py | 343 +++++++++++++++++++++++++++++++++++++++ src/network_utils.py | 208 ++++++++++++++++++++++++ src/platform_router.py | 151 +++++++++++++++++ src/privileges.py | 123 ++++++++++++++ src/tun2socks_manager.py | 283 ++++++++++++++++++++++++++++++++ src/windows_router.py | 167 +++++++++++++++++++ 13 files changed, 1758 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/.gitkeep create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/config.py create mode 100644 src/macos_router.py create mode 100644 src/main.py create mode 100644 src/network_utils.py create mode 100644 src/platform_router.py create mode 100644 src/privileges.py create mode 100644 src/tun2socks_manager.py create mode 100644 src/windows_router.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25e79a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/tun2socks* +bin/wintun.dll +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..192e8f3 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# 🌐 Proxy Switcher + +**Кроссплатформенный прозрачный прокси** — направляет **100% трафика ОС** через заданный прокси-сервер с помощью TUN-интерфейса и [tun2socks](https://github.com/xjasonlyu/tun2socks). + +> В отличие от системных настроек прокси (которые игнорируются играми, CLI-утилитами, фоновыми сервисами), proxy-switcher работает на уровне сетевого стека ОС — перехватывает **весь** трафик, включая TCP и UDP. + +## ⚡ Быстрый старт + +### 1. Скачайте tun2socks + +```bash +# macOS (Apple Silicon) +curl -L -o tun2socks.zip https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-darwin-arm64.zip +unzip tun2socks.zip && mv tun2socks-darwin-arm64 bin/ && rm tun2socks.zip + +# macOS (Intel) +curl -L -o tun2socks.zip https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-darwin-amd64.zip +unzip tun2socks.zip && mv tun2socks-darwin-amd64 bin/ && rm tun2socks.zip + +# Windows — скачайте архив и wintun.dll: +# https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-windows-amd64.zip +# Извлеките tun2socks-windows-amd64.exe в папку bin/ +# https://www.wintun.net/ → извлеките wintun.dll в папку bin/ +``` + +### 2. Запустите + +```bash +# macOS / Linux (требует sudo) +sudo python -m src.main --proxy socks5://your-proxy-server:1080 + +# С авторизацией +sudo python -m src.main --proxy socks5://user:password@proxy:1080 + +# HTTP прокси +sudo python -m src.main --proxy http://proxy:8080 + +# Тестовый режим (показывает команды, не выполняет) +sudo python -m src.main --proxy socks5://proxy:1080 --dry-run +``` + +### 3. Отключение + +Нажмите `Ctrl+C` — все маршруты автоматически восстановятся. + +## 🏗 Архитектура + +``` +┌──────────────────────────────────────────────────────┐ +│ Python Manager (main.py) │ +│ ┌─────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ Privile-│ │ Network │ │ Platform │ │ +│ │ ges │ │ Utils │ │ Router (ABC) │ │ +│ │ check │ │ (gateway │ │ ┌────────────┐ │ │ +│ │ │ │ detection) │ │ │macOS Router│ │ │ +│ └─────────┘ └──────────────┘ │ │Win Router │ │ │ +│ │ └────────────┘ │ │ +│ ┌──────────────────────────┐ └────────────────┘ │ +│ │ Tun2socks Manager │ │ +│ │ (subprocess lifecycle) │ │ +│ └────────────┬─────────────┘ │ +└───────────────┼──────────────────────────────────────┘ + │ subprocess + ┌───────────▼───────────┐ + │ tun2socks binary │ + │ (Go + gVisor stack) │ + │ ┌─────────────────┐ │ + │ │ TUN Interface │◄─── All OS traffic + │ │ (utun / wintun) │ │ + │ └────────┬────────┘ │ + │ │ IP→SOCKS │ + │ ┌────────▼────────┐ │ + │ │ Proxy Server │──── Internet + │ └─────────────────┘ │ + └───────────────────────┘ +``` + +## 📋 CLI-параметры + +| Параметр | Описание | По умолчанию | +|---|---|---| +| `--proxy`, `-p` | URL прокси (обязательный) | — | +| `--tun-name` | Имя TUN-интерфейса | `utun123` (macOS) / `wintun` (Windows) | +| `--tun-ip` | IP-адрес TUN | `198.18.0.1` (macOS) / `192.168.123.1` (Win) | +| `--dns` | DNS-серверы | `8.8.8.8 8.8.4.4` | +| `--interface`, `-i` | Основной интерфейс ОС | auto-detect | +| `--log-level`, `-l` | Уровень логов | `info` | +| `--dry-run` | Только показать команды | `false` | + +## 🔒 Требования + +- **Python** 3.8+ +- **Права root/Administrator** (создание TUN + маршруты) +- **tun2socks** binary в `bin/` или PATH +- **Windows**: дополнительно `wintun.dll` + +## 📁 Структура проекта + +``` +proxy-switcher/ +├── bin/ # Бинарники tun2socks (скачиваются) +├── src/ +│ ├── __init__.py +│ ├── main.py # CLI + оркестратор +│ ├── config.py # Конфигурация (dataclass) +│ ├── privileges.py # Проверка прав admin/root +│ ├── network_utils.py # Детекция шлюза + DNS resolve +│ ├── platform_router.py # Абстракция маршрутизации +│ ├── macos_router.py # macOS: ifconfig + route +│ ├── windows_router.py # Windows: netsh +│ └── tun2socks_manager.py # Управление процессом tun2socks +├── requirements.txt +└── README.md +``` + +## ⚠ Важные ограничения + +1. **Прокси не на localhost**: Прокси-сервер должен быть на удалённом хосте. Если прокси на `127.0.0.1`, возникнет routing loop. +2. **Один экземпляр**: Не запускайте несколько экземпляров одновременно. +3. **Graceful shutdown**: Всегда используйте `Ctrl+C` для корректного отключения. При аварийном завершении маршруты могут остаться в ОС. + +## 📜 Лицензия + +MIT + +## 📝 TODO + +- [ ] Добавить отдельные CLI флаги `--user` и `--password` для прокси с авторизацией (сейчас поддерживается через передачу параметров внутри `--proxy URL`). +- [ ] Добавить скрипт или команду для автоматического скачивания нужного бинарника `tun2socks` под текущую ОС и архитектуру. diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..5947b37 --- /dev/null +++ b/bin/.gitkeep @@ -0,0 +1,8 @@ +# Place tun2socks binaries here. +# Download from: https://github.com/xjasonlyu/tun2socks/releases +# +# Expected files: +# tun2socks-darwin-arm64 (macOS Apple Silicon) +# tun2socks-darwin-amd64 (macOS Intel) +# tun2socks-windows-amd64.exe (Windows) +# wintun.dll (Windows only — from https://www.wintun.net/) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4bef66 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# proxy-switcher — dependencies +# +# Ядро приложения НЕ ТРЕБУЕТ внешних pip-зависимостей. +# Все модули используют только стандартную библиотеку Python 3.8+: +# - subprocess, os, sys, signal, atexit, platform, shutil +# - dataclasses, abc, logging, argparse, urllib.parse, socket, time +# - ctypes (для Windows admin check) +# - pathlib, re, typing +# +# Единственная внешняя зависимость — бинарник tun2socks (Go), +# который скачивается отдельно в папку bin/. +# +# Ниже — опциональные зависимости для расширений: +# +# --- Опционально: GUI (системный трей) --- +# pystray>=0.19 # Cross-platform system tray icon +# Pillow>=10.0 # Required by pystray for icon rendering +# +# --- Опционально: Авто-скачивание бинарников --- +# httpx>=0.27 # HTTP client for downloading tun2socks releases diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..0d20cb2 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# proxy-switcher — Cross-platform TUN-based proxy routing manager diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..5bdc187 --- /dev/null +++ b/src/config.py @@ -0,0 +1,164 @@ +""" +config.py — Модель конфигурации для proxy-switcher. + +Содержит dataclass ProxyConfig с валидацией параметров: + - proxy URL (socks5://host:port, http://host:port) + - TUN interface parameters (name, IP, mask) + - DNS servers для TUN-интерфейса + - Уровень логирования tun2socks +""" + +from __future__ import annotations + +import platform +import re +import socket +from dataclasses import dataclass, field +from typing import List, Optional +from urllib.parse import urlparse + + +# ============================================================================== +# Константы по умолчанию +# ============================================================================== + +# Подсеть 198.18.0.0/15 (RFC 5737 — зарезервирована для бенчмарков, безопасна +# как приватный адрес TUN-интерфейса, не пересекается с реальными сетями) +DEFAULT_TUN_IP = "198.18.0.1" +DEFAULT_TUN_MASK = "255.254.0.0" # /15 + +# Для Windows tun2socks использует wintun driver с несколько другой схемой адресации +DEFAULT_TUN_IP_WIN = "192.168.123.1" +DEFAULT_TUN_MASK_WIN = "255.255.255.0" # /24 + +# DNS-серверы, которые будут использоваться через TUN +DEFAULT_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"] + +# Поддерживаемые схемы прокси +SUPPORTED_PROXY_SCHEMES = {"socks5", "socks4", "http", "https", "ss", "ssh", "relay"} + + +@dataclass +class ProxyConfig: + """Полная конфигурация для proxy-switcher. + + Attributes: + proxy_url: URL прокси-сервера (e.g., socks5://user:pass@1.2.3.4:1080) + tun_name: Имя TUN-интерфейса (автоматически по платформе) + tun_ip: IP-адрес TUN-интерфейса + tun_mask: Маска подсети TUN + dns_servers: Список DNS-серверов для TUN-интерфейса + log_level: Уровень логирования tun2socks (debug/info/warn/error) + interface: Имя основного сетевого интерфейса (auto-detect если None) + dry_run: Если True — только печатает команды, не выполняет + """ + + proxy_url: str + tun_name: Optional[str] = None + tun_ip: Optional[str] = None + tun_mask: Optional[str] = None + dns_servers: List[str] = field(default_factory=lambda: list(DEFAULT_DNS_SERVERS)) + log_level: str = "info" + interface: Optional[str] = None + dry_run: bool = False + + def __post_init__(self) -> None: + """Валидация и автозаполнение полей после инициализации.""" + self._validate_proxy_url() + self._set_platform_defaults() + + # -------------------------------------------------------------------------- + # Валидация + # -------------------------------------------------------------------------- + + def _validate_proxy_url(self) -> None: + """Проверяет корректность proxy_url.""" + parsed = urlparse(self.proxy_url) + + # Проверяем схему + scheme = parsed.scheme.lower() + if scheme not in SUPPORTED_PROXY_SCHEMES: + raise ValueError( + f"Unsupported proxy scheme: '{scheme}'. " + f"Supported: {', '.join(sorted(SUPPORTED_PROXY_SCHEMES))}" + ) + + # Проверяем наличие хоста + if not parsed.hostname: + raise ValueError(f"Proxy URL must contain a hostname: {self.proxy_url}") + + # Проверяем наличие порта + if not parsed.port: + raise ValueError(f"Proxy URL must contain a port: {self.proxy_url}") + + # Предупреждение о localhost — routing loop! + if parsed.hostname in ("127.0.0.1", "localhost", "::1", "0.0.0.0"): + raise ValueError( + f"⚠ Proxy on localhost ({parsed.hostname}) will cause a " + f"routing loop! The proxy server must be on a remote host." + ) + + def _set_platform_defaults(self) -> None: + """Устанавливает значения по умолчанию в зависимости от платформы.""" + system = platform.system() + + if system == "Darwin": + if self.tun_name is None: + self.tun_name = "utun123" + if self.tun_ip is None: + self.tun_ip = DEFAULT_TUN_IP + if self.tun_mask is None: + self.tun_mask = DEFAULT_TUN_MASK + elif system == "Windows": + if self.tun_name is None: + self.tun_name = "wintun" + if self.tun_ip is None: + self.tun_ip = DEFAULT_TUN_IP_WIN + if self.tun_mask is None: + self.tun_mask = DEFAULT_TUN_MASK_WIN + else: + # Linux / другие — используем macOS-подобные значения + if self.tun_name is None: + self.tun_name = "tun0" + if self.tun_ip is None: + self.tun_ip = DEFAULT_TUN_IP + if self.tun_mask is None: + self.tun_mask = DEFAULT_TUN_MASK + + # -------------------------------------------------------------------------- + # Утилиты + # -------------------------------------------------------------------------- + + @property + def proxy_host(self) -> str: + """Извлекает hostname из proxy URL.""" + return urlparse(self.proxy_url).hostname or "" + + @property + def proxy_port(self) -> int: + """Извлекает port из proxy URL.""" + return urlparse(self.proxy_url).port or 0 + + @property + def proxy_ip(self) -> str: + """Резолвит hostname прокси в IP-адрес. + + Необходимо для добавления маршрута-bypass (чтобы трафик к самому + прокси шёл напрямую, а не через TUN → бесконечная петля). + """ + host = self.proxy_host + try: + # Если уже IP — вернётся как есть + socket.inet_aton(host) + return host + except socket.error: + # DNS resolve + return socket.gethostbyname(host) + + @property + def is_macos(self) -> bool: + return platform.system() == "Darwin" + + @property + def is_windows(self) -> bool: + return platform.system() == "Windows" diff --git a/src/macos_router.py b/src/macos_router.py new file mode 100644 index 0000000..7edd0d5 --- /dev/null +++ b/src/macos_router.py @@ -0,0 +1,156 @@ +""" +macos_router.py — Реализация маршрутизации для macOS (Darwin). + +Особенности macOS: + 1. tun2socks сам создаёт utun-интерфейс при запуске (не нужно создавать вручную) + 2. IP назначается через ifconfig: `ifconfig utun123 198.18.0.1 198.18.0.1 up` + 3. Маршруты добавляются через route: `route add -net X.0.0.0/Y 198.18.0.1` + 4. macOS не поддерживает `route add default` для TUN — используется split-routing + (набор подсетей, полностью покрывающих 0.0.0.0/0) + 5. Для предотвращения routing loop — отдельный маршрут для IP прокси-сервера + через оригинальный gateway + +Split-routing подсети (покрывают всё адресное пространство IPv4): + 1.0.0.0/8 — покрывает 1.x.x.x + 2.0.0.0/7 — покрывает 2.x.x.x – 3.x.x.x + 4.0.0.0/6 — покрывает 4.x.x.x – 7.x.x.x + 8.0.0.0/5 — покрывает 8.x.x.x – 15.x.x.x + 16.0.0.0/4 — покрывает 16.x.x.x – 31.x.x.x + 32.0.0.0/3 — покрывает 32.x.x.x – 63.x.x.x + 64.0.0.0/2 — покрывает 64.x.x.x – 127.x.x.x + 128.0.0.0/1 — покрывает 128.x.x.x – 255.x.x.x + 198.18.0.0/15 — подсеть самого TUN-интерфейса +""" + +from __future__ import annotations + +import logging + +from .platform_router import PlatformRouter + +logger = logging.getLogger(__name__) + +# Подсети для split-routing (покрывают 0.0.0.0/0 без замены default route) +SPLIT_ROUTES = [ + "1.0.0.0/8", + "2.0.0.0/7", + "4.0.0.0/6", + "8.0.0.0/5", + "16.0.0.0/4", + "32.0.0.0/3", + "64.0.0.0/2", + "128.0.0.0/1", + "198.18.0.0/15", +] + + +class MacOSRouter(PlatformRouter): + """Реализация маршрутизации для macOS. + + Полный цикл: + 1. setup_interface() → ifconfig utun123 up + 2. add_proxy_bypass_route() → route add -host + 3. add_routes() → route add -net (×9) + 4. cleanup() → route delete ... (в обратном порядке) + """ + + def setup_interface(self) -> None: + """Назначает IP-адрес utun-интерфейсу и поднимает его. + + На macOS TUN — point-to-point интерфейс, поэтому задаётся + и локальный, и удалённый адрес (оба одинаковые для TUN). + + Команда: sudo ifconfig utun123 198.18.0.1 198.18.0.1 up + """ + tun = self.config.tun_name + ip = self.config.tun_ip + + logger.info(f"⚙ Configuring interface {tun} with IP {ip}") + + self._run_cmd([ + "sudo", "ifconfig", tun, + ip, # local address + ip, # destination address (point-to-point) + "up", + ]) + + self._interface_configured = True + logger.info(f"✓ Interface {tun} is UP with IP {ip}") + + def add_proxy_bypass_route(self) -> None: + """Добавляет маршрут-исключение: трафик к прокси → оригинальный шлюз. + + Без этого маршрута пакеты к прокси-серверу пойдут через TUN → + tun2socks → прокси → TUN → ... (бесконечная петля). + + Команда: sudo route add -host + """ + logger.info( + f"⚙ Adding bypass route: {self.proxy_ip} → {self.original_gateway}" + ) + + self._run_cmd([ + "sudo", "route", "add", + "-host", self.proxy_ip, + self.original_gateway, + ]) + + logger.info(f"✓ Proxy bypass route added for {self.proxy_ip}") + + def add_routes(self) -> None: + """Добавляет split-routing маршруты через TUN-интерфейс. + + Вместо изменения default route (что на macOS проблематично для TUN), + добавляем набор более специфичных подсетей, которые суммарно + покрывают всё адресное пространство IPv4. + + Более специфичные маршруты имеют приоритет над менее специфичными + (longest prefix match), поэтому: + - Трафик к прокси → идёт через -host маршрут (самый специфичный) + - Весь остальной → идёт через наши подсети → TUN → прокси + """ + tun_ip = self.config.tun_ip + + logger.info(f"⚙ Adding {len(SPLIT_ROUTES)} split routes via {tun_ip}") + + for subnet in SPLIT_ROUTES: + self._run_cmd([ + "sudo", "route", "add", + "-net", subnet, + tun_ip, + ]) + logger.debug(f" + route {subnet} → {tun_ip}") + + self._routes_added = True + logger.info(f"✓ All {len(SPLIT_ROUTES)} routes added through TUN") + + def cleanup(self) -> None: + """Удаляет все добавленные маршруты (graceful shutdown). + + Удаляет маршруты в обратном порядке, игнорируя ошибки + (маршрут может уже не существовать, если tun2socks упал раньше). + + Идемпотентный — безопасно вызывать повторно. + """ + logger.info("🧹 Starting route cleanup...") + errors = 0 + + # 1. Удаляем split-routing маршруты + if self._routes_added: + for subnet in reversed(SPLIT_ROUTES): + if not self._run_cmd_silent([ + "sudo", "route", "delete", "-net", subnet, + ]): + errors += 1 + + self._routes_added = False + + # 2. Удаляем bypass-маршрут для прокси + self._run_cmd_silent([ + "sudo", "route", "delete", "-host", self.proxy_ip, + ]) + + if errors: + logger.warning(f"⚠ {errors} route(s) failed to delete (may already be gone)") + else: + logger.info("✓ All routes cleaned up successfully") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..c7b5e41 --- /dev/null +++ b/src/main.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +""" +main.py — Точка входа и оркестратор proxy-switcher. + +Управляет полным жизненным циклом: + 1. Парсинг CLI-аргументов + 2. Проверка / получение прав администратора + 3. Определение оригинального шлюза и сетевого интерфейса + 4. Resolve IP-адреса прокси-сервера + 5. Запуск tun2socks (создание TUN-интерфейса) + 6. Настройка TUN-интерфейса (IP, DNS) + 7. Добавление маршрута-bypass для прокси + 8. Добавление маршрутов через TUN (перехват всего трафика) + 9. Ожидание Ctrl+C / SIGTERM + 10. Graceful shutdown (cleanup маршрутов, остановка tun2socks) + +Использование: + sudo python -m src.main --proxy socks5://proxy-host:1080 + sudo python -m src.main --proxy http://user:pass@proxy:8080 --log-level debug + sudo python -m src.main --proxy socks5://proxy:1080 --dry-run +""" + +from __future__ import annotations + +import argparse +import atexit +import logging +import platform +import signal +import sys +import time +from typing import Optional + +from .config import ProxyConfig, SUPPORTED_PROXY_SCHEMES +from .network_utils import get_default_gateway +from .platform_router import PlatformRouter +from .privileges import ensure_admin +from .tun2socks_manager import Tun2socksManager + +# ============================================================================== +# Логирование +# ============================================================================== + +LOG_FORMAT = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s" +LOG_DATE_FORMAT = "%H:%M:%S" + + +def setup_logging(level: str) -> None: + """Настраивает логирование для всего приложения.""" + numeric_level = getattr(logging, level.upper(), logging.INFO) + logging.basicConfig( + level=numeric_level, + format=LOG_FORMAT, + datefmt=LOG_DATE_FORMAT, + ) + + +logger = logging.getLogger(__name__) + + +# ============================================================================== +# CLI-аргументы +# ============================================================================== + +def parse_args() -> argparse.Namespace: + """Парсит аргументы командной строки.""" + parser = argparse.ArgumentParser( + prog="proxy-switcher", + description=( + "🌐 Cross-platform transparent proxy — routes ALL OS traffic " + "through a proxy server via TUN interface + tun2socks." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " sudo python -m src.main --proxy socks5://1.2.3.4:1080\n" + " sudo python -m src.main --proxy http://user:pass@proxy:8080\n" + " sudo python -m src.main --proxy socks5://proxy:1080 --dry-run\n" + "\n" + f"Supported proxy schemes: {', '.join(sorted(SUPPORTED_PROXY_SCHEMES))}\n" + "\n" + "⚠ Requires root/administrator privileges.\n" + "⚠ Proxy must NOT be on localhost (routing loop)." + ), + ) + + parser.add_argument( + "--proxy", "-p", + required=True, + help=( + "Proxy server URL. " + "Format: scheme://[user:pass@]host:port " + "(e.g., socks5://1.2.3.4:1080)" + ), + ) + + parser.add_argument( + "--tun-name", + default=None, + help="TUN interface name (default: utun123 on macOS, wintun on Windows)", + ) + + parser.add_argument( + "--tun-ip", + default=None, + help="IP address for TUN interface (default: 198.18.0.1)", + ) + + parser.add_argument( + "--dns", + nargs="+", + default=None, + help="DNS servers for TUN (default: 8.8.8.8 8.8.4.4)", + ) + + parser.add_argument( + "--interface", "-i", + default=None, + help=( + "Primary network interface name (auto-detected if not set). " + "e.g., en0 on macOS, Wi-Fi on Windows" + ), + ) + + parser.add_argument( + "--log-level", "-l", + default="info", + choices=["debug", "info", "warn", "error"], + help="Log level (default: info)", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Print commands without executing them (for testing)", + ) + + return parser.parse_args() + + +# ============================================================================== +# Создание платформенного роутера +# ============================================================================== + +def create_router( + config: ProxyConfig, + gateway: str, + iface: str, + proxy_ip: str, +) -> PlatformRouter: + """Создаёт подходящую реализацию PlatformRouter для текущей ОС. + + Returns: + MacOSRouter или WindowsRouter. + + Raises: + RuntimeError: Если платформа не поддерживается. + """ + system = platform.system() + + if system == "Darwin": + from .macos_router import MacOSRouter + return MacOSRouter(config, gateway, iface, proxy_ip) + elif system == "Windows": + from .windows_router import WindowsRouter + return WindowsRouter(config, gateway, iface, proxy_ip) + else: + raise RuntimeError( + f"Platform '{system}' is not yet supported. " + f"Currently supported: macOS (Darwin), Windows." + ) + + +# ============================================================================== +# Главный оркестратор +# ============================================================================== + +def main() -> int: + """Главная функция — полный жизненный цикл proxy-switcher. + + Returns: + Exit code (0 = success, 1 = error). + """ + args = parse_args() + setup_logging(args.log_level) + + # Баннер + print( + "\n" + " ╔═══════════════════════════════════════════╗\n" + " ║ 🌐 Proxy Switcher — TUN-based routing ║\n" + " ║ Ctrl+C to disconnect gracefully ║\n" + " ╚═══════════════════════════════════════════╝\n" + ) + + # ── Шаг 1: Проверка прав ────────────────────────────────────────────── + + logger.info("Step 1/7: Checking privileges...") + if args.dry_run: + logger.info("⏭ Skipping privilege check in dry-run mode") + else: + ensure_admin() + + # ── Шаг 2: Создание конфигурации ────────────────────────────────────── + + logger.info("Step 2/7: Building configuration...") + + try: + config = ProxyConfig( + proxy_url=args.proxy, + tun_name=args.tun_name, + tun_ip=args.tun_ip, + dns_servers=args.dns, + log_level=args.log_level, + interface=args.interface, + dry_run=args.dry_run, + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + return 1 + + if config.dry_run: + logger.info("🔍 DRY-RUN mode: commands will be printed but NOT executed") + + # ── Шаг 3: Определение оригинального шлюза ──────────────────────────── + + logger.info("Step 3/7: Detecting default gateway...") + + try: + orig_gateway, orig_iface = get_default_gateway() + except RuntimeError as e: + logger.error(f"Failed to detect gateway: {e}") + return 1 + + # Если пользователь указал интерфейс — используем его + if config.interface: + orig_iface = config.interface + logger.info(f"Using user-specified interface: {orig_iface}") + + logger.info(f"Original gateway: {orig_gateway} via {orig_iface}") + + # ── Шаг 4: Resolve IP прокси-сервера ────────────────────────────────── + + logger.info("Step 4/7: Resolving proxy IP...") + + try: + proxy_ip = config.proxy_ip + except RuntimeError as e: + logger.error(f"Failed to resolve proxy: {e}") + return 1 + + logger.info(f"Proxy IP: {proxy_ip} ({config.proxy_host}:{config.proxy_port})") + + # ── Шаг 5-7: Запуск с гарантированным cleanup ──────────────────────── + + tun2socks = Tun2socksManager(config) + router = create_router(config, orig_gateway, orig_iface, proxy_ip) + + # Регистрируем cleanup на atexit (страховка) + def cleanup() -> None: + """Гарантированная очистка при любом завершении.""" + logger.info("🔄 Running cleanup...") + try: + router.cleanup() + except Exception as e: + logger.error(f"Router cleanup error: {e}") + try: + tun2socks.stop() + except Exception as e: + logger.error(f"tun2socks stop error: {e}") + + atexit.register(cleanup) + + # Обработка SIGTERM (kill / systemd stop) + def signal_handler(signum: int, frame) -> None: + sig_name = signal.Signals(signum).name + logger.info(f"Received {sig_name}, shutting down...") + cleanup() + sys.exit(0) + + signal.signal(signal.SIGTERM, signal_handler) + + try: + # ── Шаг 5: Запуск tun2socks ────────────────────────────────────── + + logger.info("Step 5/7: Starting tun2socks...") + tun2socks.start(network_interface=orig_iface) + + # ── Шаг 6: Настройка TUN-интерфейса ────────────────────────────── + + logger.info("Step 6/7: Configuring TUN interface...") + router.setup_interface() + + # ── Шаг 7: Настройка маршрутизации ──────────────────────────────── + + logger.info("Step 7/7: Setting up routing...") + router.add_proxy_bypass_route() + router.add_routes() + + # ── Готово! ─────────────────────────────────────────────────────── + + print( + "\n" + " ╔═══════════════════════════════════════════╗\n" + " ║ ✅ ALL TRAFFIC NOW ROUTED VIA PROXY ║\n" + f" ║ Proxy: {config.proxy_url:<33} ║\n" + f" ║ TUN: {config.tun_name:<33} ║\n" + f" ║ TUN IP: {config.tun_ip:<32} ║\n" + " ║ ║\n" + " ║ Press Ctrl+C to disconnect ║\n" + " ╚═══════════════════════════════════════════╝\n" + ) + + # Блокируемся до Ctrl+C или завершения tun2socks + while tun2socks.is_running(): + time.sleep(1) + + # Если tun2socks сам завершился — это ошибка + logger.warning("⚠ tun2socks process exited unexpectedly!") + return 1 + + except KeyboardInterrupt: + print("\n") + logger.info("🛑 Ctrl+C received, disconnecting...") + return 0 + + except Exception as e: + logger.error(f"❌ Fatal error: {e}") + return 1 + + finally: + # Cleanup ВСЕГДА выполняется (даже при exception) + cleanup() + # Снимаем atexit чтобы не вызывать cleanup дважды + atexit.unregister(cleanup) + + +# ============================================================================== +# Entry point +# ============================================================================== + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/network_utils.py b/src/network_utils.py new file mode 100644 index 0000000..ee08993 --- /dev/null +++ b/src/network_utils.py @@ -0,0 +1,208 @@ +""" +network_utils.py — Кроссплатформенные утилиты для работы с сетью. + +Функции: + - get_default_gateway() → (gateway_ip, interface_name) + - resolve_host(hostname) → ip_address +""" + +from __future__ import annotations + +import logging +import platform +import re +import socket +import subprocess +from typing import Tuple + +logger = logging.getLogger(__name__) + + +def get_default_gateway() -> Tuple[str, str]: + """Определяет текущий шлюз по умолчанию и основной сетевой интерфейс. + + Returns: + Кортеж (gateway_ip, interface_name). + Например: ("192.168.1.1", "en0") на macOS + ("192.168.1.1", "Wi-Fi") на Windows + + Raises: + RuntimeError: Если не удалось определить шлюз. + """ + system = platform.system() + + if system == "Darwin": + return _get_gateway_macos() + elif system == "Windows": + return _get_gateway_windows() + elif system == "Linux": + return _get_gateway_linux() + else: + raise RuntimeError(f"Unsupported platform: {system}") + + +def _get_gateway_macos() -> Tuple[str, str]: + """Определяет шлюз на macOS через `route -n get default`. + + Пример вывода: + route to: default + destination: default + gateway: 192.168.1.1 + interface: en0 + flags: + """ + try: + result = subprocess.run( + ["route", "-n", "get", "default"], + capture_output=True, + text=True, + timeout=10, + ) + + output = result.stdout + gateway = "" + interface = "" + + for line in output.splitlines(): + line = line.strip() + if line.startswith("gateway:"): + gateway = line.split(":", 1)[1].strip() + elif line.startswith("interface:"): + interface = line.split(":", 1)[1].strip() + + if not gateway or not interface: + raise RuntimeError( + f"Could not parse gateway/interface from route output:\n{output}" + ) + + logger.info(f"Detected default gateway: {gateway} via {interface}") + return gateway, interface + + except subprocess.TimeoutExpired: + raise RuntimeError("Timeout while detecting default gateway") + except FileNotFoundError: + raise RuntimeError("'route' command not found") + + +def _get_gateway_windows() -> Tuple[str, str]: + """Определяет шлюз на Windows через `netsh interface ipv4 show route`. + + Ищет маршрут 0.0.0.0/0 с минимальным metric. + Альтернативно парсит `ipconfig` для получения имени интерфейса. + """ + try: + # Получаем маршрут по умолчанию + result = subprocess.run( + [ + "powershell", "-Command", + "(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | " + "Sort-Object RouteMetric | Select-Object -First 1 | " + "Format-List NextHop, InterfaceAlias)" + ], + capture_output=True, + text=True, + timeout=15, + ) + + output = result.stdout + gateway = "" + interface = "" + + for line in output.splitlines(): + line = line.strip() + if line.startswith("NextHop"): + gateway = line.split(":", 1)[1].strip() + elif line.startswith("InterfaceAlias"): + interface = line.split(":", 1)[1].strip() + + if not gateway or not interface: + # Fallback: попробуем через netsh + return _get_gateway_windows_netsh() + + logger.info(f"Detected default gateway: {gateway} via {interface}") + return gateway, interface + + except (subprocess.TimeoutExpired, FileNotFoundError): + return _get_gateway_windows_netsh() + + +def _get_gateway_windows_netsh() -> Tuple[str, str]: + """Fallback-метод определения шлюза через netsh на Windows.""" + try: + result = subprocess.run( + ["netsh", "interface", "ipv4", "show", "route"], + capture_output=True, + text=True, + timeout=10, + ) + + # Ищем строку с 0.0.0.0/0 + for line in result.stdout.splitlines(): + if "0.0.0.0/0" in line: + parts = line.split() + # Типичный формат: Prefix NextHop Metric IfIndex InterfaceAlias + # Парсим по позиции + for i, part in enumerate(parts): + if "0.0.0.0/0" in part: + if i + 1 < len(parts): + gateway = parts[i + 1] + interface = parts[-1] if len(parts) > i + 3 else "unknown" + return gateway, interface + + raise RuntimeError("Could not find default route in netsh output") + + except Exception as e: + raise RuntimeError(f"Failed to detect default gateway on Windows: {e}") + + +def _get_gateway_linux() -> Tuple[str, str]: + """Определяет шлюз на Linux через `ip route show default`.""" + try: + result = subprocess.run( + ["ip", "route", "show", "default"], + capture_output=True, + text=True, + timeout=10, + ) + + # Формат: default via 192.168.1.1 dev eth0 ... + match = re.search( + r"default\s+via\s+(\S+)\s+dev\s+(\S+)", result.stdout + ) + if match: + gateway = match.group(1) + interface = match.group(2) + logger.info(f"Detected default gateway: {gateway} via {interface}") + return gateway, interface + + raise RuntimeError( + f"Could not parse default route from: {result.stdout}" + ) + + except FileNotFoundError: + raise RuntimeError("'ip' command not found") + + +def resolve_host(hostname: str) -> str: + """Резолвит hostname в IP-адрес. + + Если передан уже IP — возвращает его без изменений. + + Args: + hostname: DNS-имя или IP-адрес. + + Returns: + IPv4-адрес в виде строки. + """ + try: + socket.inet_aton(hostname) + return hostname # Уже IP + except socket.error: + pass + + try: + ip = socket.gethostbyname(hostname) + logger.info(f"Resolved {hostname} → {ip}") + return ip + except socket.gaierror as e: + raise RuntimeError(f"Failed to resolve proxy host '{hostname}': {e}") diff --git a/src/platform_router.py b/src/platform_router.py new file mode 100644 index 0000000..0b884ea --- /dev/null +++ b/src/platform_router.py @@ -0,0 +1,151 @@ +""" +platform_router.py — Абстрактный базовый класс для управления маршрутизацией. + +Определяет интерфейс, который должны реализовать платформенные модули +(macos_router.py, windows_router.py). Обеспечивает единообразный контракт: + + setup_interface() — назначить IP адрес TUN-интерфейсу + add_proxy_bypass_route() — исключить IP прокси из маршрутизации через TUN + add_routes() — перенаправить весь трафик через TUN + cleanup() — откатить все изменения (восстановить оригинал) +""" + +from __future__ import annotations + +import logging +import subprocess +from abc import ABC, abstractmethod +from typing import List + +from .config import ProxyConfig + +logger = logging.getLogger(__name__) + + +class PlatformRouter(ABC): + """Абстрактный базовый класс для платформенной маршрутизации. + + Отвечает за конфигурацию TUN-интерфейса (IP-адрес) и манипуляцию + таблицей маршрутизации ОС. Конкретные реализации — в наследниках. + + Attributes: + config: ProxyConfig с параметрами подключения. + original_gateway: Оригинальный шлюз по умолчанию (для restore). + original_iface: Оригинальный сетевой интерфейс (для restore). + proxy_ip: Resolve-нутый IP прокси-сервера. + _routes_added: Флаг, указывающий что маршруты были добавлены. + """ + + def __init__( + self, + config: ProxyConfig, + original_gateway: str, + original_iface: str, + proxy_ip: str, + ) -> None: + self.config = config + self.original_gateway = original_gateway + self.original_iface = original_iface + self.proxy_ip = proxy_ip + self._routes_added = False + self._interface_configured = False + + # ========================================================================== + # Абстрактные методы — реализуются наследниками + # ========================================================================== + + @abstractmethod + def setup_interface(self) -> None: + """Назначает IP-адрес TUN-интерфейсу и поднимает его. + + Вызывается ПОСЛЕ запуска tun2socks (который создаёт интерфейс). + """ + ... + + @abstractmethod + def add_proxy_bypass_route(self) -> None: + """Добавляет маршрут-исключение для IP прокси-сервера. + + Трафик к прокси-серверу должен идти через оригинальный шлюз, + а НЕ через TUN-интерфейс. Иначе — бесконечная петля маршрутизации. + """ + ... + + @abstractmethod + def add_routes(self) -> None: + """Перенаправляет весь трафик через TUN-интерфейс. + + Использует split-routing (набор подсетей, покрывающих 0.0.0.0/0) + вместо замены default route, чтобы не ломать существующую маршрутизацию. + """ + ... + + @abstractmethod + def cleanup(self) -> None: + """Откатывает ВСЕ изменения маршрутизации и интерфейса. + + Должен быть идемпотентным (безопасно вызывать повторно). + Вызывается в finally-блоке и при обработке SIGTERM. + """ + ... + + # ========================================================================== + # Утилиты для наследников + # ========================================================================== + + def _run_cmd(self, cmd: List[str], check: bool = True) -> subprocess.CompletedProcess: + """Выполняет системную команду с логированием. + + Args: + cmd: Список аргументов команды (как для subprocess.run). + check: Если True — бросает исключение при ненулевом exit code. + + Returns: + CompletedProcess с результатом выполнения. + """ + cmd_str = " ".join(cmd) + + if self.config.dry_run: + logger.info(f"[DRY-RUN] Would execute: {cmd_str}") + return subprocess.CompletedProcess(cmd, 0, "", "") + + logger.debug(f"Executing: {cmd_str}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0 and check: + logger.error( + f"Command failed (exit {result.returncode}): {cmd_str}\n" + f" stdout: {result.stdout.strip()}\n" + f" stderr: {result.stderr.strip()}" + ) + raise subprocess.CalledProcessError( + result.returncode, cmd, result.stdout, result.stderr + ) + + return result + + except subprocess.TimeoutExpired: + logger.error(f"Command timed out: {cmd_str}") + raise + + def _run_cmd_silent(self, cmd: List[str]) -> bool: + """Выполняет команду без exceptions при ошибке. + + Используется в cleanup() где ошибки неизбежны (маршрут может + уже не существовать). + + Returns: + True если команда выполнилась успешно (exit 0). + """ + try: + result = self._run_cmd(cmd, check=False) + return result.returncode == 0 + except Exception: + return False diff --git a/src/privileges.py b/src/privileges.py new file mode 100644 index 0000000..63f8b40 --- /dev/null +++ b/src/privileges.py @@ -0,0 +1,123 @@ +""" +privileges.py — Проверка и получение прав администратора/root. + +Создание TUN-интерфейсов и изменение таблицы маршрутизации требуют +привилегий суперпользователя на обеих платформах: + - macOS: root (через sudo) + - Windows: Administrator (через UAC / runas) +""" + +from __future__ import annotations + +import logging +import os +import platform +import sys + +logger = logging.getLogger(__name__) + + +def is_admin() -> bool: + """Проверяет, запущен ли скрипт с правами администратора. + + Returns: + True если текущий процесс имеет права root/Administrator. + """ + system = platform.system() + + if system in ("Darwin", "Linux"): + # Unix: проверяем effective user ID + return os.geteuid() == 0 + + elif system == "Windows": + # Windows: проверяем через Windows API + try: + import ctypes + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except (AttributeError, OSError): + return False + + else: + logger.warning(f"Unknown platform: {system}. Cannot check privileges.") + return False + + +def ensure_admin() -> None: + """Проверяет права администратора. Если их нет — выводит инструкцию и завершает. + + На Windows пытается перезапустить скрипт с UAC-диалогом. + На macOS/Linux — выводит инструкцию запуска через sudo. + """ + if is_admin(): + logger.debug("✓ Running with admin/root privileges.") + return + + system = platform.system() + + if system == "Windows": + _elevate_windows() + else: + _elevate_unix() + + +def _elevate_unix() -> None: + """Выводит инструкцию для запуска через sudo и завершает процесс. + + Не пытаемся запустить sudo программатически, т.к. это создаёт проблемы + с TTY и перехватом stdout/stderr. + """ + script = " ".join(sys.argv) + logger.error( + "❌ This program requires root privileges to create TUN interfaces " + "and modify routing tables." + ) + print( + "\n" + "╔══════════════════════════════════════════════════════════════╗\n" + "║ 🔒 ROOT PRIVILEGES REQUIRED ║\n" + "╠══════════════════════════════════════════════════════════════╣\n" + "║ Please re-run with sudo: ║\n" + f"║ $ sudo {sys.executable} {script:<40} ║\n" + "╚══════════════════════════════════════════════════════════════╝" + ) + sys.exit(1) + + +def _elevate_windows() -> None: + """Пытается перезапустить скрипт с правами администратора через UAC. + + Использует ShellExecuteW с verb='runas' для вызова UAC-диалога. + Текущий (непривилегированный) процесс завершается после запуска нового. + """ + try: + import ctypes + + logger.info("🔒 Requesting UAC elevation...") + + # Собираем команду: python.exe