first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/tun2socks*
|
||||
bin/wintun.dll
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
129
README.md
Normal file
129
README.md
Normal file
@@ -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` под текущую ОС и архитектуру.
|
||||
8
bin/.gitkeep
Normal file
8
bin/.gitkeep
Normal file
@@ -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/)
|
||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -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
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# proxy-switcher — Cross-platform TUN-based proxy routing manager
|
||||
164
src/config.py
Normal file
164
src/config.py
Normal file
@@ -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"
|
||||
156
src/macos_router.py
Normal file
156
src/macos_router.py
Normal file
@@ -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 <ip> <ip> up
|
||||
2. add_proxy_bypass_route() → route add -host <proxy_ip> <orig_gateway>
|
||||
3. add_routes() → route add -net <subnet> <tun_ip> (×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 <proxy_ip> <original_gateway>
|
||||
"""
|
||||
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")
|
||||
343
src/main.py
Normal file
343
src/main.py
Normal file
@@ -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())
|
||||
208
src/network_utils.py
Normal file
208
src/network_utils.py
Normal file
@@ -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: <UP,GATEWAY,DONE,STATIC,PRCLONING,AUTOCONF>
|
||||
"""
|
||||
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}")
|
||||
151
src/platform_router.py
Normal file
151
src/platform_router.py
Normal file
@@ -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
|
||||
123
src/privileges.py
Normal file
123
src/privileges.py
Normal file
@@ -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 <script> <args>
|
||||
params = " ".join(sys.argv)
|
||||
|
||||
# ShellExecuteW(hwnd, verb, file, parameters, directory, showCmd)
|
||||
# verb='runas' вызывает UAC prompt
|
||||
result = ctypes.windll.shell32.ShellExecuteW(
|
||||
None, # hwnd — нет родительского окна
|
||||
"runas", # verb — запустить с повышенными правами
|
||||
sys.executable, # file — путь к python.exe
|
||||
params, # parameters — аргументы скрипта
|
||||
None, # directory — текущая директория
|
||||
1, # nShowCmd — SW_SHOWNORMAL
|
||||
)
|
||||
|
||||
# ShellExecuteW возвращает HINSTANCE > 32 при успехе
|
||||
if result <= 32:
|
||||
logger.error(f"❌ UAC elevation failed (error code: {result}).")
|
||||
print("\n Please run this script as Administrator manually.")
|
||||
sys.exit(1)
|
||||
|
||||
# Текущий процесс завершается — новый запущен с правами
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to elevate privileges: {e}")
|
||||
print("\n Please run this script as Administrator manually.")
|
||||
sys.exit(1)
|
||||
283
src/tun2socks_manager.py
Normal file
283
src/tun2socks_manager.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
tun2socks_manager.py — Управление процессом tun2socks.
|
||||
|
||||
Отвечает за:
|
||||
1. Определение пути к бинарнику tun2socks (по платформе + архитектуре)
|
||||
2. Формирование CLI-аргументов
|
||||
3. Запуск через subprocess.Popen
|
||||
4. Мониторинг здоровья процесса
|
||||
5. Graceful shutdown (SIGTERM → wait → SIGKILL)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .config import ProxyConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Таймаут ожидания корректного завершения tun2socks (секунды)
|
||||
SHUTDOWN_TIMEOUT = 5
|
||||
|
||||
# Задержка после запуска tun2socks для создания TUN-интерфейса (секунды)
|
||||
STARTUP_DELAY = 2
|
||||
|
||||
|
||||
class Tun2socksManager:
|
||||
"""Менеджер жизненного цикла процесса tun2socks.
|
||||
|
||||
Attributes:
|
||||
config: ProxyConfig с параметрами подключения.
|
||||
process: subprocess.Popen — запущенный процесс tun2socks.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ProxyConfig) -> None:
|
||||
self.config = config
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self._binary_path: Optional[str] = None
|
||||
|
||||
# ==========================================================================
|
||||
# Определение бинарника
|
||||
# ==========================================================================
|
||||
|
||||
def _find_binary(self) -> str:
|
||||
"""Находит бинарник tun2socks для текущей платформы.
|
||||
|
||||
Порядок поиска:
|
||||
1. ./bin/tun2socks-<platform>-<arch>[.exe]
|
||||
2. ./bin/tun2socks[.exe]
|
||||
3. tun2socks в PATH (shutil.which)
|
||||
|
||||
Returns:
|
||||
Абсолютный путь к бинарнику.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если бинарник не найден.
|
||||
"""
|
||||
system = platform.system().lower() # 'darwin', 'windows', 'linux'
|
||||
machine = platform.machine().lower()
|
||||
|
||||
# Нормализация архитектуры
|
||||
arch_map = {
|
||||
"x86_64": "amd64",
|
||||
"amd64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"aarch64": "arm64",
|
||||
}
|
||||
arch = arch_map.get(machine, machine)
|
||||
|
||||
# Расширение файла
|
||||
ext = ".exe" if system == "windows" else ""
|
||||
|
||||
# Директория bin/ относительно корня проекта
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
bin_dir = project_root / "bin"
|
||||
|
||||
# Кандидаты для поиска
|
||||
candidates = [
|
||||
bin_dir / f"tun2socks-{system}-{arch}{ext}",
|
||||
bin_dir / f"tun2socks{ext}",
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
logger.info(f"Found tun2socks binary: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
# Fallback: ищем в PATH
|
||||
found = shutil.which("tun2socks")
|
||||
if found:
|
||||
logger.info(f"Found tun2socks in PATH: {found}")
|
||||
return found
|
||||
|
||||
# Не найден — подробная ошибка с инструкцией
|
||||
raise FileNotFoundError(
|
||||
f"\n"
|
||||
f"╔══════════════════════════════════════════════════════════════╗\n"
|
||||
f"║ ❌ tun2socks binary not found ║\n"
|
||||
f"╠══════════════════════════════════════════════════════════════╣\n"
|
||||
f"║ Expected locations: ║\n"
|
||||
f"║ {candidates[0]} \n"
|
||||
f"║ {candidates[1]} \n"
|
||||
f"║ or 'tun2socks' in system PATH ║\n"
|
||||
f"║ ║\n"
|
||||
f"║ Download from: ║\n"
|
||||
f"║ https://github.com/xjasonlyu/tun2socks/releases ║\n"
|
||||
f"║ ║\n"
|
||||
f"║ Platform: {system}/{arch} \n"
|
||||
f"╚══════════════════════════════════════════════════════════════╝"
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Запуск
|
||||
# ==========================================================================
|
||||
|
||||
def start(self, network_interface: str) -> None:
|
||||
"""Запускает процесс tun2socks.
|
||||
|
||||
Args:
|
||||
network_interface: Имя основного сетевого интерфейса ОС (en0, Wi-Fi, etc.)
|
||||
tun2socks привязывается к нему для исходящих соединений.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если бинарник не найден.
|
||||
RuntimeError: Если процесс не запустился.
|
||||
"""
|
||||
|
||||
# На macOS: используем формат utun://utunN (с префиксом utun://)
|
||||
# На Windows: используем просто имя "wintun"
|
||||
device = self.config.tun_name
|
||||
|
||||
# В dry-run режиме не ищем бинарник — просто показываем команду
|
||||
if self.config.dry_run:
|
||||
cmd_str = (
|
||||
f"tun2socks -device {device} "
|
||||
f"-proxy {self.config.proxy_url} "
|
||||
f"-loglevel {self.config.log_level}"
|
||||
)
|
||||
if network_interface:
|
||||
cmd_str += f" -interface {network_interface}"
|
||||
logger.info(f"[DRY-RUN] Would start: {cmd_str}")
|
||||
return
|
||||
|
||||
binary = self._find_binary()
|
||||
self._binary_path = binary
|
||||
|
||||
# Формируем аргументы CLI
|
||||
cmd = [
|
||||
binary,
|
||||
"-device", device,
|
||||
"-proxy", self.config.proxy_url,
|
||||
"-loglevel", self.config.log_level,
|
||||
]
|
||||
|
||||
# Привязка к основному интерфейсу (предотвращает routing loop
|
||||
# на уровне tun2socks для исходящих соединений)
|
||||
if network_interface:
|
||||
cmd.extend(["-interface", network_interface])
|
||||
|
||||
cmd_str = " ".join(cmd)
|
||||
logger.info(f"🚀 Starting tun2socks: {cmd_str}")
|
||||
|
||||
# Убеждаемся, что бинарник исполняемый (macOS/Linux)
|
||||
if not self.config.is_windows:
|
||||
os.chmod(binary, 0o755)
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
# На Windows: не создаём новое окно консоли
|
||||
creationflags=(
|
||||
subprocess.CREATE_NO_WINDOW
|
||||
if self.config.is_windows
|
||||
else 0
|
||||
),
|
||||
)
|
||||
except PermissionError:
|
||||
raise RuntimeError(
|
||||
f"Permission denied for '{binary}'. "
|
||||
f"Try: chmod +x {binary}"
|
||||
)
|
||||
|
||||
# Ждём startup — tun2socks должен создать TUN-интерфейс
|
||||
logger.info(
|
||||
f"⏳ Waiting {STARTUP_DELAY}s for TUN interface creation..."
|
||||
)
|
||||
time.sleep(STARTUP_DELAY)
|
||||
|
||||
# Проверяем, что процесс не умер
|
||||
if self.process.poll() is not None:
|
||||
_, stderr = self.process.communicate(timeout=5)
|
||||
raise RuntimeError(
|
||||
f"tun2socks exited immediately (exit code: {self.process.returncode}).\n"
|
||||
f"stderr: {stderr.strip()}"
|
||||
)
|
||||
|
||||
logger.info(f"✓ tun2socks started (PID: {self.process.pid})")
|
||||
|
||||
# ==========================================================================
|
||||
# Мониторинг
|
||||
# ==========================================================================
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Проверяет, работает ли процесс tun2socks."""
|
||||
if self.process is None:
|
||||
return False
|
||||
return self.process.poll() is None
|
||||
|
||||
def wait(self) -> int:
|
||||
"""Блокирующее ожидание завершения процесса.
|
||||
|
||||
Returns:
|
||||
Exit code процесса.
|
||||
"""
|
||||
if self.process is None:
|
||||
return -1
|
||||
return self.process.wait()
|
||||
|
||||
# ==========================================================================
|
||||
# Остановка
|
||||
# ==========================================================================
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Корректно останавливает tun2socks (graceful shutdown).
|
||||
|
||||
Стратегия:
|
||||
1. SIGTERM (мягкая остановка)
|
||||
2. Ожидание SHUTDOWN_TIMEOUT секунд
|
||||
3. SIGKILL (принудительная остановка), если не завершился
|
||||
|
||||
Идемпотентный — безопасно вызывать повторно.
|
||||
"""
|
||||
if self.process is None:
|
||||
return
|
||||
|
||||
if self.process.poll() is not None:
|
||||
logger.debug(
|
||||
f"tun2socks already exited (code: {self.process.returncode})"
|
||||
)
|
||||
self.process = None
|
||||
return
|
||||
|
||||
pid = self.process.pid
|
||||
logger.info(f"🛑 Stopping tun2socks (PID: {pid})...")
|
||||
|
||||
if self.config.dry_run:
|
||||
logger.info("[DRY-RUN] Would terminate tun2socks process")
|
||||
return
|
||||
|
||||
# Шаг 1: мягкая остановка
|
||||
try:
|
||||
self.process.terminate()
|
||||
logger.debug(
|
||||
f"Sent SIGTERM to PID {pid}, waiting {SHUTDOWN_TIMEOUT}s..."
|
||||
)
|
||||
self.process.wait(timeout=SHUTDOWN_TIMEOUT)
|
||||
logger.info(f"✓ tun2socks stopped gracefully (PID: {pid})")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Шаг 2: принудительная остановка
|
||||
logger.warning(
|
||||
f"⚠ tun2socks did not stop in {SHUTDOWN_TIMEOUT}s, "
|
||||
f"sending SIGKILL..."
|
||||
)
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
logger.info(f"✓ tun2socks killed (PID: {pid})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping tun2socks: {e}")
|
||||
|
||||
finally:
|
||||
self.process = None
|
||||
167
src/windows_router.py
Normal file
167
src/windows_router.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
windows_router.py — Реализация маршрутизации для Windows.
|
||||
|
||||
Особенности Windows:
|
||||
1. Требуется wintun.dll (драйвер TUN для Windows) рядом с tun2socks.exe или в PATH
|
||||
2. tun2socks создаёт интерфейс "wintun" при запуске
|
||||
3. IP назначается через: netsh interface ipv4 set address name="wintun" ...
|
||||
4. DNS через: netsh interface ipv4 set dnsservers name="wintun" ...
|
||||
5. Маршрут: netsh interface ipv4 add route 0.0.0.0/0 "wintun" <gateway> metric=1
|
||||
6. На Windows можно добавить единственный default route 0.0.0.0/0 с низким metric
|
||||
(в отличие от macOS, где нужен split-routing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .platform_router import PlatformRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WindowsRouter(PlatformRouter):
|
||||
"""Реализация маршрутизации для Windows.
|
||||
|
||||
Полный цикл:
|
||||
1. setup_interface() → netsh set address + set dnsservers
|
||||
2. add_proxy_bypass_route() → netsh add route <proxy_ip>/32
|
||||
3. add_routes() → netsh add route 0.0.0.0/0 metric=1
|
||||
4. cleanup() → netsh delete route ...
|
||||
"""
|
||||
|
||||
def setup_interface(self) -> None:
|
||||
"""Назначает IP-адрес и DNS wintun-интерфейсу.
|
||||
|
||||
На Windows tun2socks + wintun создаёт адаптер автоматически,
|
||||
но IP и DNS нужно настроить вручную через netsh.
|
||||
|
||||
Команды:
|
||||
netsh interface ipv4 set address name="wintun"
|
||||
source=static addr=192.168.123.1 mask=255.255.255.0
|
||||
netsh interface ipv4 set dnsservers name="wintun"
|
||||
static address=8.8.8.8 register=none validate=no
|
||||
"""
|
||||
tun = self.config.tun_name
|
||||
ip = self.config.tun_ip
|
||||
mask = self.config.tun_mask
|
||||
|
||||
logger.info(f"⚙ Configuring interface {tun} with IP {ip}/{mask}")
|
||||
|
||||
# Назначаем IP-адрес
|
||||
self._run_cmd([
|
||||
"netsh", "interface", "ipv4", "set", "address",
|
||||
f"name={tun}",
|
||||
"source=static",
|
||||
f"addr={ip}",
|
||||
f"mask={mask}",
|
||||
])
|
||||
|
||||
# Назначаем DNS-серверы
|
||||
if self.config.dns_servers:
|
||||
primary_dns = self.config.dns_servers[0]
|
||||
|
||||
self._run_cmd([
|
||||
"netsh", "interface", "ipv4", "set", "dnsservers",
|
||||
f"name={tun}",
|
||||
"static",
|
||||
f"address={primary_dns}",
|
||||
"register=none",
|
||||
"validate=no",
|
||||
])
|
||||
|
||||
# Добавляем дополнительные DNS-серверы (если есть)
|
||||
for dns in self.config.dns_servers[1:]:
|
||||
self._run_cmd([
|
||||
"netsh", "interface", "ipv4", "add", "dnsservers",
|
||||
f"name={tun}",
|
||||
f"address={dns}",
|
||||
"validate=no",
|
||||
])
|
||||
|
||||
logger.debug(f" DNS servers: {', '.join(self.config.dns_servers)}")
|
||||
|
||||
self._interface_configured = True
|
||||
logger.info(f"✓ Interface {tun} configured with IP {ip}")
|
||||
|
||||
def add_proxy_bypass_route(self) -> None:
|
||||
"""Добавляет маршрут-исключение: трафик к прокси → оригинальный шлюз.
|
||||
|
||||
Команда:
|
||||
netsh interface ipv4 add route <proxy_ip>/32
|
||||
<original_interface> <original_gateway> metric=1
|
||||
"""
|
||||
logger.info(
|
||||
f"⚙ Adding bypass route: {self.proxy_ip} → "
|
||||
f"{self.original_gateway} via {self.original_iface}"
|
||||
)
|
||||
|
||||
self._run_cmd([
|
||||
"netsh", "interface", "ipv4", "add", "route",
|
||||
f"{self.proxy_ip}/32",
|
||||
self.original_iface,
|
||||
self.original_gateway,
|
||||
"metric=1",
|
||||
])
|
||||
|
||||
logger.info(f"✓ Proxy bypass route added for {self.proxy_ip}")
|
||||
|
||||
def add_routes(self) -> None:
|
||||
"""Добавляет default route через TUN-интерфейс с низким metric.
|
||||
|
||||
На Windows можно добавить маршрут 0.0.0.0/0 напрямую, задав
|
||||
metric=1 (ниже, чем у оригинального default route), что даёт
|
||||
приоритет TUN-интерфейсу.
|
||||
|
||||
Команда:
|
||||
netsh interface ipv4 add route 0.0.0.0/0
|
||||
"wintun" 192.168.123.1 metric=1
|
||||
"""
|
||||
tun = self.config.tun_name
|
||||
tun_ip = self.config.tun_ip
|
||||
|
||||
logger.info(f"⚙ Adding default route via {tun} (metric=1)")
|
||||
|
||||
self._run_cmd([
|
||||
"netsh", "interface", "ipv4", "add", "route",
|
||||
"0.0.0.0/0",
|
||||
tun,
|
||||
tun_ip,
|
||||
"metric=1",
|
||||
])
|
||||
|
||||
self._routes_added = True
|
||||
logger.info(f"✓ Default route added through {tun}")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Удаляет все добавленные маршруты (graceful shutdown).
|
||||
|
||||
Идемпотентный — безопасно вызывать повторно.
|
||||
"""
|
||||
logger.info("🧹 Starting route cleanup...")
|
||||
tun = self.config.tun_name
|
||||
tun_ip = self.config.tun_ip
|
||||
errors = 0
|
||||
|
||||
# 1. Удаляем default route через TUN
|
||||
if self._routes_added:
|
||||
if not self._run_cmd_silent([
|
||||
"netsh", "interface", "ipv4", "delete", "route",
|
||||
"0.0.0.0/0", tun, tun_ip,
|
||||
]):
|
||||
errors += 1
|
||||
|
||||
self._routes_added = False
|
||||
|
||||
# 2. Удаляем bypass-маршрут для прокси
|
||||
self._run_cmd_silent([
|
||||
"netsh", "interface", "ipv4", "delete", "route",
|
||||
f"{self.proxy_ip}/32",
|
||||
self.original_iface,
|
||||
self.original_gateway,
|
||||
])
|
||||
|
||||
if errors:
|
||||
logger.warning(f"⚠ {errors} route(s) failed to delete (may already be gone)")
|
||||
else:
|
||||
logger.info("✓ All routes cleaned up successfully")
|
||||
Reference in New Issue
Block a user