commit 83fbe7afdd7f536332e8a29b906bd5ea8983742a Author: arkonsadter Date: Sun Apr 5 20:33:30 2026 +0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deef2e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Binaries +vpn-client +vpn-client.exe +*.exe +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Config and logs +.vpn_client/ +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..d5620e7 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,413 @@ +# Примеры использования + +## Примеры VLESS URL + +### VLESS с Reality + +``` +vless://uuid@server.com:443?security=reality&type=tcp&flow=xtls-rprx-vision&sni=example.com&fp=chrome&pbk=publickey&sid=shortid#ServerName +``` + +### VLESS с TLS + +``` +vless://uuid@server.com:443?security=tls&type=tcp&sni=example.com&fp=chrome#ServerName +``` + +### VLESS с WebSocket + +``` +vless://uuid@server.com:443?security=tls&type=ws&path=/path&host=example.com&sni=example.com#ServerName +``` + +### VLESS с gRPC + +``` +vless://uuid@server.com:443?security=tls&type=grpc&serviceName=ServiceName&mode=multi&sni=example.com#ServerName +``` + +### VLESS с HTTP/2 + +``` +vless://uuid@server.com:443?security=tls&type=http&path=/path&host=example.com#ServerName +``` + +## Примеры подписок + +### Добавление подписки + +``` +Имя подписки: MyVPN +URL подписки: https://example.com/sub/xxxxx +``` + +### Популярные VPN провайдеры + +- AliusVPN +- V2rayN +- Shadowrocket +- Clash + +## Примеры конфигурационных файлов + +### configs.json + +```json +{ + "wireguard": [], + "vless": [ + { + "name": "My Server", + "url": "vless://uuid@server.com:443?security=reality&type=tcp&flow=xtls-rprx-vision&sni=example.com&fp=chrome&pbk=publickey&sid=shortid#MyServer", + "protocol": "VLESS" + }, + { + "name": "[MyVPN] Server 1", + "url": "vless://...", + "protocol": "VLESS", + "subscription": "MyVPN" + } + ] +} +``` + +### subscriptions.json + +```json +{ + "subscriptions": [ + { + "name": "MyVPN", + "url": "https://example.com/sub/xxxxx" + }, + { + "name": "AnotherVPN", + "url": "https://another.com/subscription" + } + ] +} +``` + +### state.json (подключено) + +```json +{ + "connected": true, + "config_name": "My Server", + "config_type": "vless", + "start_time": "2024-01-01T12:00:00Z", + "interface": "xray", + "process_pid": 12345, + "log_file": "/path/to/logs/vless_traffic_20240101_120000.log" +} +``` + +### state.json (отключено) + +```json +{ + "connected": false, + "config_name": "", + "config_type": "", + "start_time": "", + "interface": "", + "process_pid": 0, + "log_file": "" +} +``` + +## Примеры использования CLI + +### Базовое использование + +```bash +# Запуск клиента +./vpn-client + +# Главное меню +# 1. VLESS +# 2. Управление подписками +# 3. Показать статус подключения +# 4. Отключиться от VPN +# 0. Выход +``` + +### Работа с VLESS + +```bash +# В главном меню выберите 1 (VLESS) + +# Меню VLESS +# 1. Список конфигураций - показать все конфиги +# 2. Добавить конфиг - добавить новый VLESS конфиг +# 3. Удалить конфиг - удалить существующий конфиг +# 4. Подключиться - подключиться к серверу +# 5. Тестировать конфиг (пинг) - проверить доступность сервера +# 0. Назад +``` + +### Работа с подписками + +```bash +# В главном меню выберите 2 (Управление подписками) + +# Меню подписок +# 1. Список подписок - показать все подписки +# 2. Добавить подписку - добавить новую подписку +# 3. Удалить подписку - удалить существующую подписку +# 4. Обновить конфиги из подписки - загрузить новые конфиги +# 5. Показать конфиги из подписки - показать конфиги конкретной подписки +# 6. Тестировать конфиги из подписки (пинг) - проверить все серверы +# 0. Назад +``` + +## Примеры сценариев + +### Сценарий 1: Первое использование + +```bash +# 1. Запустите клиент +./vpn-client + +# 2. Добавьте подписку +# Выберите: 2 (Управление подписками) +# Выберите: 2 (Добавить подписку) +# Введите имя: MyVPN +# Введите URL: https://example.com/sub/xxxxx + +# 3. Обновите конфиги +# Выберите: 4 (Обновить конфиги из подписки) +# Выберите номер подписки: 1 + +# 4. Протестируйте серверы +# Выберите: 6 (Тестировать конфиги из подписки) +# Выберите номер подписки: 1 +# Дождитесь результатов + +# 5. Подключитесь к лучшему серверу +# Выберите: 0 (Назад) +# Выберите: 1 (VLESS) +# Выберите: 4 (Подключиться) +# Выберите номер конфига с наименьшим пингом + +# 6. Проверьте статус +# Выберите: 0 (Назад) +# Выберите: 3 (Показать статус подключения) +``` + +### Сценарий 2: Быстрое подключение + +```bash +# Если у вас уже есть VLESS URL + +# 1. Запустите клиент +./vpn-client + +# 2. Добавьте конфиг +# Выберите: 1 (VLESS) +# Выберите: 2 (Добавить конфиг) +# Введите имя: Quick Server +# Вставьте VLESS URL + +# 3. Подключитесь +# Выберите: 4 (Подключиться) +# Выберите: 1 (только что добавленный конфиг) + +# 4. Готово! +``` + +### Сценарий 3: Поиск лучшего сервера + +```bash +# 1. Запустите клиент +./vpn-client + +# 2. Обновите подписку +# Выберите: 2 (Управление подписками) +# Выберите: 4 (Обновить конфиги из подписки) +# Выберите номер подписки + +# 3. Протестируйте все серверы +# Выберите: 6 (Тестировать конфиги из подписки) +# Выберите номер подписки +# Дождитесь завершения (может занять несколько минут) + +# 4. Посмотрите результаты +# Программа покажет топ-5 серверов с наименьшим пингом + +# 5. Подключитесь к лучшему +# Выберите: 0 (Назад) +# Выберите: 1 (VLESS) +# Выберите: 4 (Подключиться) +# Найдите сервер из топ-5 и подключитесь +``` + +### Сценарий 4: Переключение между серверами + +```bash +# 1. Отключитесь от текущего сервера +# В главном меню выберите: 4 (Отключиться от VPN) + +# 2. Подключитесь к другому серверу +# Выберите: 1 (VLESS) +# Выберите: 4 (Подключиться) +# Выберите другой конфиг + +# Или используйте быстрый способ: +# Просто подключитесь к новому серверу +# Клиент автоматически отключит предыдущее соединение +``` + +## Примеры настройки браузера + +### Firefox + +``` +1. Откройте: about:preferences#general +2. Прокрутите до "Параметры сети" +3. Нажмите "Настроить..." +4. Выберите "Ручная настройка прокси" +5. SOCKS Host: 127.0.0.1 +6. Port: 10808 +7. Выберите "SOCKS v5" +8. Отметьте "Использовать прокси DNS при использовании SOCKS v5" +9. Нажмите "OK" +``` + +### Chrome (с расширением) + +``` +1. Установите расширение "Proxy SwitchyOmega" +2. Создайте новый профиль +3. Protocol: SOCKS5 +4. Server: 127.0.0.1 +5. Port: 10808 +6. Сохраните и активируйте профиль +``` + +### Системный прокси (Windows) + +``` +1. Настройки → Сеть и Интернет → Прокси +2. Ручная настройка прокси +3. Использовать прокси-сервер: Вкл +4. Адрес: 127.0.0.1 +5. Порт: 10808 +6. Сохранить +``` + +### Системный прокси (macOS) + +``` +1. Системные настройки → Сеть +2. Выберите активное подключение +3. Дополнительно → Прокси +4. Отметьте "SOCKS прокси" +5. Сервер: 127.0.0.1 +6. Порт: 10808 +7. OK → Применить +``` + +### Системный прокси (Linux) + +```bash +# Временно (для текущей сессии) +export ALL_PROXY=socks5://127.0.0.1:10808 + +# Постоянно (добавьте в ~/.bashrc или ~/.zshrc) +echo 'export ALL_PROXY=socks5://127.0.0.1:10808' >> ~/.bashrc +source ~/.bashrc +``` + +## Примеры проверки подключения + +### Проверка IP адреса + +```bash +# Через curl +curl -x socks5://127.0.0.1:10808 https://api.ipify.org + +# Через браузер +# Откройте: https://whatismyipaddress.com/ +``` + +### Проверка работы прокси + +```bash +# Проверка доступности прокси +curl -x socks5://127.0.0.1:10808 https://www.google.com + +# Если работает, вы увидите HTML код страницы +``` + +## Примеры логов + +### Успешное подключение + +``` +[2024-01-01 12:00:00] Создание конфига для сервера: server.com:443 +[2024-01-01 12:00:00] UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +[2024-01-01 12:00:00] Транспорт: tcp +[2024-01-01 12:00:00] Безопасность: reality +[2024-01-01 12:00:00] Успешно подключено к 'My Server' (PID: 12345, Лог: /path/to/log) +``` + +### Ошибка подключения + +``` +[2024-01-01 12:00:00] Начало подключения к 'My Server' через Xray +[2024-01-01 12:00:01] Ошибка подключения: процесс xray завершился с ошибкой +[2024-01-01 12:00:01] Проверьте конфигурацию и доступность сервера +``` + +### Тестирование сервера + +``` +Тестирование 'My Server'... +Проверка доступности сервера... +✓ Сервер доступен + Адрес: server.com:443 + Пинг: 45.23 мс + Качество: Отлично +``` + +## Полезные команды + +### Проверка процесса Xray + +```bash +# Linux/macOS +ps aux | grep xray + +# Windows +tasklist | findstr xray +``` + +### Просмотр логов в реальном времени + +```bash +# Linux/macOS +tail -f logs/vless_traffic_*.log + +# Windows (PowerShell) +Get-Content logs\vless_traffic_*.log -Wait -Tail 50 +``` + +### Остановка зависшего процесса + +```bash +# Linux/macOS +killall xray + +# Windows +taskkill /F /IM xray.exe +``` + +## Советы и трюки + +1. **Используйте тестирование перед подключением** - это сэкономит время +2. **Регулярно обновляйте подписки** - серверы могут меняться +3. **Сохраняйте лучшие серверы** - добавьте их как отдельные конфиги +4. **Проверяйте логи при проблемах** - там обычно есть ответы +5. **Используйте серверы с пингом < 100ms** - для комфортной работы +6. **Не забывайте отключаться** - освобождайте ресурсы diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..717f64d --- /dev/null +++ b/Makefile @@ -0,0 +1,119 @@ +.PHONY: build clean run test install help + +# Переменные +BINARY_NAME=vpn-client +BINARY_UNIX=$(BINARY_NAME) +BINARY_WINDOWS=$(BINARY_NAME).exe +GO=go +GOFLAGS=-ldflags="-s -w" + +# Цвета для вывода +GREEN=\033[0;32m +NC=\033[0m # No Color + +help: ## Показать справку + @echo "Доступные команды:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + +build: ## Собрать для текущей платформы + @echo "$(GREEN)Сборка VPN Client...$(NC)" + $(GO) mod download + $(GO) build $(GOFLAGS) -o $(BINARY_NAME) main.go + @echo "$(GREEN)✓ Сборка завершена: $(BINARY_NAME)$(NC)" + +build-all: ## Собрать для всех платформ + @echo "$(GREEN)Сборка для всех платформ...$(NC)" + @$(MAKE) build-linux + @$(MAKE) build-windows + @$(MAKE) build-darwin + @echo "$(GREEN)✓ Сборка для всех платформ завершена$(NC)" + +build-linux: ## Собрать для Linux + @echo "$(GREEN)Сборка для Linux...$(NC)" + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -o $(BINARY_UNIX)-linux-amd64 main.go + @echo "$(GREEN)✓ Linux: $(BINARY_UNIX)-linux-amd64$(NC)" + +build-windows: ## Собрать для Windows + @echo "$(GREEN)Сборка для Windows...$(NC)" + GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -o $(BINARY_WINDOWS) main.go + @echo "$(GREEN)✓ Windows: $(BINARY_WINDOWS)$(NC)" + +build-darwin: ## Собрать для macOS + @echo "$(GREEN)Сборка для macOS...$(NC)" + GOOS=darwin GOARCH=amd64 $(GO) build $(GOFLAGS) -o $(BINARY_UNIX)-darwin-amd64 main.go + GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -o $(BINARY_UNIX)-darwin-arm64 main.go + @echo "$(GREEN)✓ macOS Intel: $(BINARY_UNIX)-darwin-amd64$(NC)" + @echo "$(GREEN)✓ macOS ARM: $(BINARY_UNIX)-darwin-arm64$(NC)" + +run: build ## Собрать и запустить + @echo "$(GREEN)Запуск VPN Client...$(NC)" + ./$(BINARY_NAME) + +test: ## Запустить тесты + @echo "$(GREEN)Запуск тестов...$(NC)" + $(GO) test -v ./... + +clean: ## Очистить собранные файлы + @echo "$(GREEN)Очистка...$(NC)" + $(GO) clean + rm -f $(BINARY_NAME) + rm -f $(BINARY_WINDOWS) + rm -f $(BINARY_UNIX)-* + @echo "$(GREEN)✓ Очистка завершена$(NC)" + +install: build ## Установить в систему + @echo "$(GREEN)Установка VPN Client...$(NC)" + @if [ "$(shell uname)" = "Linux" ] || [ "$(shell uname)" = "Darwin" ]; then \ + sudo cp $(BINARY_NAME) /usr/local/bin/; \ + sudo chmod +x /usr/local/bin/$(BINARY_NAME); \ + echo "$(GREEN)✓ Установлено в /usr/local/bin/$(BINARY_NAME)$(NC)"; \ + else \ + echo "Установка поддерживается только на Linux и macOS"; \ + fi + +uninstall: ## Удалить из системы + @echo "$(GREEN)Удаление VPN Client...$(NC)" + @if [ "$(shell uname)" = "Linux" ] || [ "$(shell uname)" = "Darwin" ]; then \ + sudo rm -f /usr/local/bin/$(BINARY_NAME); \ + echo "$(GREEN)✓ Удалено из /usr/local/bin/$(BINARY_NAME)$(NC)"; \ + else \ + echo "Удаление поддерживается только на Linux и macOS"; \ + fi + +deps: ## Установить зависимости + @echo "$(GREEN)Установка зависимостей...$(NC)" + $(GO) mod download + $(GO) mod tidy + @echo "$(GREEN)✓ Зависимости установлены$(NC)" + +fmt: ## Форматировать код + @echo "$(GREEN)Форматирование кода...$(NC)" + $(GO) fmt ./... + @echo "$(GREEN)✓ Код отформатирован$(NC)" + +lint: ## Проверить код линтером + @echo "$(GREEN)Проверка кода...$(NC)" + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run; \ + else \ + echo "golangci-lint не установлен. Установите: https://golangci-lint.run/usage/install/"; \ + fi + +dev: ## Режим разработки (сборка и запуск при изменениях) + @echo "$(GREEN)Режим разработки...$(NC)" + @if command -v air > /dev/null; then \ + air; \ + else \ + echo "air не установлен. Установите: go install github.com/cosmtrek/air@latest"; \ + echo "Запуск обычной сборки..."; \ + $(MAKE) run; \ + fi + +size: build ## Показать размер бинарника + @echo "$(GREEN)Размер бинарника:$(NC)" + @ls -lh $(BINARY_NAME) | awk '{print $$5 " " $$9}' + +version: ## Показать версию Go + @$(GO) version + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..68488e0 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# VPN Client (Go) + +VPN клиент на Golang с поддержкой VLESS протокола и подписок. + +## Возможности + +- ✅ Поддержка WireGuard +- ✅ Поддержка VLESS протокола через Xray +- ✅ Управление подписками +- ✅ Автоматическое обновление конфигураций из подписок +- ✅ Тестирование серверов (ping) +- ✅ Детальное логирование +- ✅ Статистика трафика для WireGuard +- ✅ Кроссплатформенность (Windows, Linux, macOS) + +## Требования + +- Go 1.21 или выше +- Xray-core (автоматически используется из папки `xray`) +- WireGuard (для Windows: https://www.wireguard.com/install/, для Linux: `apt install wireguard` или `yum install wireguard-tools`) + +## Установка + +### Сборка из исходников + +```bash +cd vpn_client_go +go mod download +go build -o vpn-client main.go +``` + +### Windows + +```bash +build.bat +``` + +### Linux/macOS + +```bash +chmod +x build.sh +./build.sh +``` + +## Использование + +### Запуск + +```bash +# Windows +vpn-client.exe + +# Linux/macOS +./vpn-client +``` + +### Основные функции + +1. **WireGuard** + - Список конфигураций + - Добавить конфиг (вручную) + - Добавить конфиг (из файла) + - Удалить конфиг + - Подключиться + - Статистика трафика + +2. **VLESS** + - Список конфигураций + - Добавить конфиг + - Удалить конфиг + - Подключиться + - Тестировать конфиг (пинг) + +3. **Управление подписками** + - Список подписок + - Добавить подписку + - Удалить подписку + - Обновить конфиги из подписки + - Показать конфиги из подписки + - Тестировать конфиги из подписки + +4. **Статус подключения** + - Показать детальный статус + - Время подключения + - Информация о прокси + - Статистика трафика (для WireGuard) + +5. **Отключение от VPN** + +## Структура проекта + +``` +vpn_client_go/ +├── main.go # Точка входа +├── go.mod # Зависимости +├── internal/ +│ ├── cli/ # CLI интерфейс +│ │ └── cli.go +│ ├── config/ # Управление конфигурацией +│ │ └── config.go +│ ├── wireguard/ # WireGuard протокол +│ │ └── wireguard.go +│ ├── vless/ # VLESS протокол +│ │ └── vless.go +│ ├── subscription/ # Управление подписками +│ │ └── subscription.go +│ ├── vpn/ # Управление VPN +│ │ └── vpn.go +│ └── logger/ # Логирование +│ └── logger.go +├── .vpn_client/ # Конфигурационные файлы +│ ├── configs.json +│ ├── subscriptions.json +│ └── state.json +└── logs/ # Логи + ├── wireguard.log + ├── vless.log + ├── vless_access.log + ├── vless_error.log + └── vless_traffic_*.log +``` + +## Конфигурация + +Все конфигурационные файлы хранятся в папке `.vpn_client/`: + +- `configs.json` - конфигурации VLESS +- `subscriptions.json` - подписки +- `state.json` - текущее состояние подключения + +## Логи + +Логи сохраняются в папке `logs/`: + +- `vless.log` - основной лог +- `vless_access.log` - лог доступа (IP, подключения) +- `vless_error.log` - лог ошибок +- `vless_traffic_*.log` - логи трафика для каждого подключения + +## Прокси + +После подключения к VLESS серверу, SOCKS5 прокси доступен по адресу: +``` +127.0.0.1:10808 +``` + +Настройте браузер или систему на использование этого прокси. + +## Отличия от Python версии + +- ✅ Более быстрая работа +- ✅ Один исполняемый файл без зависимостей +- ✅ Меньшее потребление памяти +- ✅ Нативная кроссплатформенность +- ✅ Полная поддержка WireGuard +- ✅ Полная поддержка VLESS +- ⚠️ Нет GUI версии +- ⚠️ Нет автоматической настройки системного прокси (пока) + +## Разработка + +### Добавление новых функций + +1. Создайте новый пакет в `internal/` +2. Реализуйте функциональность +3. Интегрируйте в CLI (`internal/cli/cli.go`) + +### Тестирование + +```bash +go test ./... +``` + +## Лицензия + +MIT + +## Автор + +Портировано с Python на Go diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..95306a8 --- /dev/null +++ b/build.bat @@ -0,0 +1,17 @@ +@echo off +echo Building VPN Client for Windows... + +go mod download +go build -ldflags="-s -w" -o vpn-client.exe main.go + +if %ERRORLEVEL% EQU 0 ( + echo. + echo Build successful! + echo Executable: vpn-client.exe +) else ( + echo. + echo Build failed! + exit /b 1 +) + +pause diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..d88cbe5 --- /dev/null +++ b/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +echo "Building VPN Client..." + +go mod download +go build -ldflags="-s -w" -o vpn-client main.go + +if [ $? -eq 0 ]; then + echo "" + echo "Build successful!" + echo "Executable: vpn-client" + chmod +x vpn-client +else + echo "" + echo "Build failed!" + exit 1 +fi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..766b48c --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module vpn-client + +go 1.21 + +require github.com/fatih/color v1.16.0 + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c9fe800 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/init.bat b/init.bat new file mode 100644 index 0000000..6665809 --- /dev/null +++ b/init.bat @@ -0,0 +1,11 @@ +@echo off +echo Initializing Go modules... + +go mod tidy +go get github.com/fatih/color@v1.16.0 +go get golang.org/x/sys@v0.16.0 + +echo. +echo Dependencies installed! +echo Now you can run: go build -ldflags="-s -w" -o vpn-client.exe main.go +pause diff --git a/init.sh b/init.sh new file mode 100644 index 0000000..24a5572 --- /dev/null +++ b/init.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "Initializing Go modules..." + +go mod tidy +go get github.com/fatih/color@v1.16.0 +go get golang.org/x/sys@v0.16.0 + +echo "" +echo "Dependencies installed!" +echo "Now you can run: go build -ldflags=\"-s -w\" -o vpn-client main.go" diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..5228a70 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,864 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "vpn-client/internal/config" + "vpn-client/internal/subscription" + "vpn-client/internal/vless" + "vpn-client/internal/vpn" + "vpn-client/internal/wireguard" + + "github.com/fatih/color" +) + +var ( + green = color.New(color.FgGreen).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + cyan = color.New(color.FgCyan).SprintFunc() + bold = color.New(color.Bold).SprintFunc() +) + +// Run запускает CLI интерфейс +func Run() error { + for { + clearScreen() + showMainMenu() + + choice := readInput("Выберите действие: ") + + switch choice { + case "1": + wireguardMenu() + case "2": + vlessMenu() + case "3": + subscriptionsMenu() + case "4": + if err := vpn.ShowStatus(); err != nil { + fmt.Printf("%s %v\n", red("✗"), err) + } + pause() + case "5": + if err := vpn.Disconnect(config.LogsDir); err != nil { + fmt.Printf("%s %v\n", red("✗"), err) + } + pause() + case "0": + fmt.Println("До свидания!") + return nil + default: + fmt.Printf("%s Неверный выбор\n", red("✗")) + pause() + } + } +} + +func showMainMenu() { + state, _ := vpn.GetStatus() + + statusIcon := "○" + statusText := "Не подключено" + if state != nil && state.Connected { + statusIcon = "✓" + statusText = fmt.Sprintf("Подключено: %s", state.ConfigName) + } + + fmt.Println("\n" + strings.Repeat("=", 50)) + fmt.Println(bold("VPN Клиент (Go)")) + fmt.Println(strings.Repeat("=", 50)) + fmt.Printf("Статус: %s %s\n", statusIcon, statusText) + fmt.Println(strings.Repeat("=", 50)) + fmt.Println("1. WireGuard") + fmt.Println("2. VLESS") + fmt.Println("3. Управление подписками") + fmt.Println("4. Показать статус подключения") + fmt.Println("5. Отключиться от VPN") + fmt.Println("0. Выход") + fmt.Println(strings.Repeat("=", 50)) +} + +func vlessMenu() { + for { + clearScreen() + fmt.Println("\n" + strings.Repeat("=", 50)) + fmt.Println(bold("VLESS")) + fmt.Println(strings.Repeat("=", 50)) + fmt.Println("1. Список конфигураций") + fmt.Println("2. Добавить конфиг") + fmt.Println("3. Удалить конфиг") + fmt.Println("4. Подключиться") + fmt.Println("5. Тестировать конфиг (пинг)") + fmt.Println("0. Назад") + fmt.Println(strings.Repeat("=", 50)) + + choice := readInput("\nВыберите действие: ") + + switch choice { + case "0": + return + case "1": + listVLESSConfigs() + pause() + case "2": + addVLESSConfig() + pause() + case "3": + deleteVLESSConfig() + pause() + case "4": + connectVLESS() + pause() + case "5": + testVLESSConfig() + pause() + default: + fmt.Printf("%s Неверный выбор\n", red("✗")) + pause() + } + } +} + +func listVLESSConfigs() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + fmt.Println("\n=== VLESS конфиги ===") + if len(configs.VLESS) == 0 { + fmt.Println("Нет конфигов") + return + } + + for i, cfg := range configs.VLESS { + protocol := cfg.Protocol + if protocol == "" { + protocol = "VLESS" + } + fmt.Printf("%d. [%s] %s\n", i+1, protocol, cfg.Name) + } +} + +func addVLESSConfig() { + name := readInput("Имя конфига: ") + if name == "" { + fmt.Printf("%s Имя не может быть пустым\n", red("✗")) + return + } + + vlessURL := readInput("VLESS URL: ") + if vlessURL == "" { + fmt.Printf("%s URL не может быть пустым\n", red("✗")) + return + } + + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + configs.VLESS = append(configs.VLESS, config.VLESSConfig{ + Name: name, + URL: vlessURL, + Protocol: "VLESS", + }) + + if err := config.SaveConfigs(configs); err != nil { + fmt.Printf("%s Ошибка сохранения конфигураций: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s VLESS конфиг '%s' добавлен\n", green("✓"), name) +} + +func deleteVLESSConfig() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + if len(configs.VLESS) == 0 { + fmt.Println("Нет конфигов для удаления") + return + } + + fmt.Println("\n=== VLESS конфиги ===") + for i, cfg := range configs.VLESS { + fmt.Printf("%d. %s\n", i+1, cfg.Name) + } + + numStr := readInput("\nНомер конфига для удаления: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(configs.VLESS) { + fmt.Printf("%s Неверный номер конфига\n", red("✗")) + return + } + + name := configs.VLESS[num-1].Name + configs.VLESS = append(configs.VLESS[:num-1], configs.VLESS[num:]...) + + if err := config.SaveConfigs(configs); err != nil { + fmt.Printf("%s Ошибка сохранения конфигураций: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s Конфиг '%s' удален\n", green("✓"), name) +} + +func connectVLESS() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + if len(configs.VLESS) == 0 { + fmt.Println("Нет конфигов для подключения") + return + } + + fmt.Println("\n=== VLESS конфиги ===") + for i, cfg := range configs.VLESS { + protocol := cfg.Protocol + if protocol == "" { + protocol = "VLESS" + } + fmt.Printf("%d. [%s] %s\n", i+1, protocol, cfg.Name) + } + + numStr := readInput("\nНомер конфига для подключения: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(configs.VLESS) { + fmt.Printf("%s Неверный номер конфига\n", red("✗")) + return + } + + configName := configs.VLESS[num-1].Name + + fmt.Printf("\nПодключение к '%s' через Xray...\n", configName) + + if err := vless.Connect(configName, config.LogsDir, config.XrayDir); err != nil { + fmt.Printf("%s Ошибка подключения: %v\n", red("✗"), err) + return + } +} + +func testVLESSConfig() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + if len(configs.VLESS) == 0 { + fmt.Println("Нет конфигов для тестирования") + return + } + + fmt.Println("\n=== VLESS конфиги ===") + for i, cfg := range configs.VLESS { + fmt.Printf("%d. %s\n", i+1, cfg.Name) + } + + numStr := readInput("\nНомер конфига для тестирования: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(configs.VLESS) { + fmt.Printf("%s Неверный номер конфига\n", red("✗")) + return + } + + cfg := configs.VLESS[num-1] + + fmt.Printf("\nТестирование '%s'...\n", cfg.Name) + fmt.Println("Проверка доступности сервера...") + + success, ping, err := vless.PingServer(cfg.URL, 5000000000) // 5 секунд + if err != nil || !success { + fmt.Printf("%s Сервер недоступен\n", red("✗")) + if err != nil { + fmt.Printf(" Ошибка: %v\n", err) + } + return + } + + fmt.Printf("%s Сервер доступен\n", green("✓")) + fmt.Printf(" Пинг: %.2f мс\n", ping) + + // Оценка качества + var quality string + if ping < 50 { + quality = green("Отлично") + } else if ping < 100 { + quality = cyan("Хорошо") + } else if ping < 200 { + quality = yellow("Средне") + } else { + quality = red("Плохо") + } + fmt.Printf(" Качество: %s\n", quality) +} + +func subscriptionsMenu() { + for { + clearScreen() + fmt.Println("\n" + strings.Repeat("=", 50)) + fmt.Println(bold("Управление подписками")) + fmt.Println(strings.Repeat("=", 50)) + fmt.Println("1. Список подписок") + fmt.Println("2. Добавить подписку") + fmt.Println("3. Удалить подписку") + fmt.Println("4. Обновить конфиги из подписки") + fmt.Println("5. Показать конфиги из подписки") + fmt.Println("6. Тестировать конфиги из подписки (пинг)") + fmt.Println("0. Назад") + fmt.Println(strings.Repeat("=", 50)) + + choice := readInput("\nВыберите действие: ") + + switch choice { + case "0": + return + case "1": + listSubscriptions() + pause() + case "2": + addSubscription() + pause() + case "3": + deleteSubscription() + pause() + case "4": + updateSubscription() + pause() + case "5": + showSubscriptionConfigs() + pause() + case "6": + testSubscriptionConfigs() + pause() + default: + fmt.Printf("%s Неверный выбор\n", red("✗")) + pause() + } + } +} + +func listSubscriptions() { + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + fmt.Println("\nПодписки:") + if len(subs.Subscriptions) == 0 { + fmt.Println("Нет подписок") + return + } + + for i, sub := range subs.Subscriptions { + fmt.Printf("%d. %s - %s\n", i+1, sub.Name, sub.URL) + } +} + +func addSubscription() { + name := readInput("Имя подписки: ") + if name == "" { + fmt.Printf("%s Имя не может быть пустым\n", red("✗")) + return + } + + url := readInput("URL подписки: ") + if url == "" { + fmt.Printf("%s URL не может быть пустым\n", red("✗")) + return + } + + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + subs.Subscriptions = append(subs.Subscriptions, config.Subscription{ + Name: name, + URL: url, + }) + + if err := config.SaveSubscriptions(subs); err != nil { + fmt.Printf("%s Ошибка сохранения подписок: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s Подписка '%s' добавлена\n", green("✓"), name) +} + +func deleteSubscription() { + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + if len(subs.Subscriptions) == 0 { + fmt.Println("Нет подписок для удаления") + return + } + + fmt.Println("\nПодписки:") + for i, sub := range subs.Subscriptions { + fmt.Printf("%d. %s\n", i+1, sub.Name) + } + + numStr := readInput("\nНомер подписки для удаления: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(subs.Subscriptions) { + fmt.Printf("%s Неверный номер подписки\n", red("✗")) + return + } + + name := subs.Subscriptions[num-1].Name + subs.Subscriptions = append(subs.Subscriptions[:num-1], subs.Subscriptions[num:]...) + + if err := config.SaveSubscriptions(subs); err != nil { + fmt.Printf("%s Ошибка сохранения подписок: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s Подписка '%s' удалена\n", green("✓"), name) +} + +func updateSubscription() { + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + if len(subs.Subscriptions) == 0 { + fmt.Println("Нет подписок") + return + } + + fmt.Println("\nПодписки:") + for i, sub := range subs.Subscriptions { + fmt.Printf("%d. %s\n", i+1, sub.Name) + } + + numStr := readInput("\nНомер подписки для обновления: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(subs.Subscriptions) { + fmt.Printf("%s Неверный номер подписки\n", red("✗")) + return + } + + name := subs.Subscriptions[num-1].Name + + if err := subscription.UpdateSubscription(name, config.LogsDir); err != nil { + fmt.Printf("%s Ошибка обновления: %v\n", red("✗"), err) + return + } +} + +func showSubscriptionConfigs() { + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + if len(subs.Subscriptions) == 0 { + fmt.Println("Нет подписок") + return + } + + fmt.Println("\nПодписки:") + for i, sub := range subs.Subscriptions { + fmt.Printf("%d. %s\n", i+1, sub.Name) + } + + numStr := readInput("\nНомер подписки: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(subs.Subscriptions) { + fmt.Printf("%s Неверный номер подписки\n", red("✗")) + return + } + + subName := subs.Subscriptions[num-1].Name + + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + fmt.Printf("\n=== Конфиги из подписки '%s' ===\n", subName) + count := 0 + for _, cfg := range configs.VLESS { + if cfg.Subscription == subName { + count++ + protocol := cfg.Protocol + if protocol == "" { + protocol = "Unknown" + } + displayName := strings.TrimPrefix(cfg.Name, fmt.Sprintf("[%s] ", subName)) + fmt.Printf("%d. [%s] %s\n", count, protocol, displayName) + } + } + + if count == 0 { + fmt.Println("Нет конфигов из этой подписки") + fmt.Println("Сначала обновите конфиги из подписки (пункт 4)") + } +} + +func testSubscriptionConfigs() { + subs, err := config.LoadSubscriptions() + if err != nil { + fmt.Printf("%s Ошибка загрузки подписок: %v\n", red("✗"), err) + return + } + + if len(subs.Subscriptions) == 0 { + fmt.Println("Нет подписок") + return + } + + fmt.Println("\nПодписки:") + for i, sub := range subs.Subscriptions { + fmt.Printf("%d. %s\n", i+1, sub.Name) + } + + numStr := readInput("\nНомер подписки: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(subs.Subscriptions) { + fmt.Printf("%s Неверный номер подписки\n", red("✗")) + return + } + + subName := subs.Subscriptions[num-1].Name + + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + var subConfigs []config.VLESSConfig + for _, cfg := range configs.VLESS { + if cfg.Subscription == subName { + subConfigs = append(subConfigs, cfg) + } + } + + if len(subConfigs) == 0 { + fmt.Println("Нет конфигов из этой подписки") + return + } + + fmt.Printf("\n=== Тестирование конфигов из '%s' ===\n", subName) + fmt.Printf("Найдено конфигов: %d\n\n", len(subConfigs)) + + type result struct { + name string + protocol string + ping float64 + success bool + } + + var results []result + + for i, cfg := range subConfigs { + protocol := cfg.Protocol + if protocol == "" { + protocol = "Unknown" + } + displayName := strings.TrimPrefix(cfg.Name, fmt.Sprintf("[%s] ", subName)) + + fmt.Printf("%d/%d Тестирование [%s] %s...", i+1, len(subConfigs), protocol, truncate(displayName, 50)) + + success, ping, _ := vless.PingServer(cfg.URL, 3000000000) // 3 секунды + + if success { + fmt.Printf(" %s %.2f мс\n", green("✓"), ping) + results = append(results, result{ + name: displayName, + protocol: protocol, + ping: ping, + success: true, + }) + } else { + fmt.Printf(" %s Недоступен\n", red("✗")) + results = append(results, result{ + name: displayName, + protocol: protocol, + success: false, + }) + } + } + + // Сортируем по пингу + var successful []result + var failed []result + for _, r := range results { + if r.success { + successful = append(successful, r) + } else { + failed = append(failed, r) + } + } + + // Простая сортировка пузырьком + for i := 0; i < len(successful); i++ { + for j := i + 1; j < len(successful); j++ { + if successful[i].ping > successful[j].ping { + successful[i], successful[j] = successful[j], successful[i] + } + } + } + + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("РЕЗУЛЬТАТЫ ТЕСТИРОВАНИЯ") + fmt.Println(strings.Repeat("=", 60)) + + if len(successful) > 0 { + fmt.Printf("\n%s Доступно: %d\n", green("✓"), len(successful)) + fmt.Println("\nЛучшие серверы:") + for i, r := range successful { + if i >= 5 { + break + } + fmt.Printf(" %d. [%s] %s - %.2f мс\n", i+1, r.protocol, truncate(r.name, 35), r.ping) + } + } + + if len(failed) > 0 { + fmt.Printf("\n%s Недоступно: %d\n", red("✗"), len(failed)) + } + + fmt.Println(strings.Repeat("=", 60)) +} + +func clearScreen() { + if runtime.GOOS == "windows" { + cmd := exec.Command("cmd", "/c", "cls") + cmd.Stdout = os.Stdout + cmd.Run() + } else { + cmd := exec.Command("clear") + cmd.Stdout = os.Stdout + cmd.Run() + } +} + +func readInput(prompt string) string { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input) +} + +func pause() { + fmt.Print("\nНажмите Enter для продолжения...") + bufio.NewReader(os.Stdin).ReadBytes('\n') +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + + +func wireguardMenu() { + for { + clearScreen() + fmt.Println("\n" + strings.Repeat("=", 50)) + fmt.Println(bold("WireGuard")) + fmt.Println(strings.Repeat("=", 50)) + fmt.Println("1. Список конфигураций") + fmt.Println("2. Добавить конфиг (вручную)") + fmt.Println("3. Добавить конфиг (из файла)") + fmt.Println("4. Удалить конфиг") + fmt.Println("5. Подключиться") + fmt.Println("0. Назад") + fmt.Println(strings.Repeat("=", 50)) + + choice := readInput("\nВыберите действие: ") + + switch choice { + case "0": + return + case "1": + listWireGuardConfigs() + pause() + case "2": + addWireGuardConfigManual() + pause() + case "3": + addWireGuardConfigFromFile() + pause() + case "4": + deleteWireGuardConfig() + pause() + case "5": + connectWireGuard() + pause() + default: + fmt.Printf("%s Неверный выбор\n", red("✗")) + pause() + } + } +} + +func listWireGuardConfigs() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + fmt.Println("\n=== WireGuard конфиги ===") + if len(configs.WireGuard) == 0 { + fmt.Println("Нет конфигов") + return + } + + for i, cfg := range configs.WireGuard { + fmt.Printf("%d. %s\n", i+1, cfg.Name) + } +} + +func addWireGuardConfigManual() { + name := readInput("Имя конфига: ") + if name == "" { + fmt.Printf("%s Имя не может быть пустым\n", red("✗")) + return + } + + fmt.Println("Вставьте конфиг WireGuard (завершите пустой строкой):") + + var lines []string + reader := bufio.NewReader(os.Stdin) + for { + line, _ := reader.ReadString('\n') + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + lines = append(lines, line) + } + + if len(lines) == 0 { + fmt.Printf("%s Конфиг не может быть пустым\n", red("✗")) + return + } + + configText := strings.Join(lines, "\n") + + if err := wireguard.AddConfig(name, configText); err != nil { + fmt.Printf("%s Ошибка добавления конфига: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s WireGuard конфиг '%s' добавлен\n", green("✓"), name) +} + +func addWireGuardConfigFromFile() { + name := readInput("Имя конфига: ") + if name == "" { + fmt.Printf("%s Имя не может быть пустым\n", red("✗")) + return + } + + filePath := readInput("Путь к файлу конфига: ") + if filePath == "" { + fmt.Printf("%s Путь не может быть пустым\n", red("✗")) + return + } + + if err := wireguard.AddConfigFromFile(name, filePath); err != nil { + fmt.Printf("%s Ошибка добавления конфига: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s WireGuard конфиг '%s' добавлен из файла\n", green("✓"), name) +} + +func deleteWireGuardConfig() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + if len(configs.WireGuard) == 0 { + fmt.Println("Нет конфигов для удаления") + return + } + + fmt.Println("\n=== WireGuard конфиги ===") + for i, cfg := range configs.WireGuard { + fmt.Printf("%d. %s\n", i+1, cfg.Name) + } + + numStr := readInput("\nНомер конфига для удаления: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(configs.WireGuard) { + fmt.Printf("%s Неверный номер конфига\n", red("✗")) + return + } + + name := configs.WireGuard[num-1].Name + + if err := wireguard.DeleteConfig(name); err != nil { + fmt.Printf("%s Ошибка удаления конфига: %v\n", red("✗"), err) + return + } + + fmt.Printf("%s Конфиг '%s' удален\n", green("✓"), name) +} + +func connectWireGuard() { + configs, err := config.LoadConfigs() + if err != nil { + fmt.Printf("%s Ошибка загрузки конфигураций: %v\n", red("✗"), err) + return + } + + if len(configs.WireGuard) == 0 { + fmt.Println("Нет конфигов для подключения") + return + } + + fmt.Println("\n=== WireGuard конфиги ===") + for i, cfg := range configs.WireGuard { + fmt.Printf("%d. %s\n", i+1, cfg.Name) + } + + numStr := readInput("\nНомер конфига для подключения: ") + num, err := strconv.Atoi(numStr) + if err != nil || num < 1 || num > len(configs.WireGuard) { + fmt.Printf("%s Неверный номер конфига\n", red("✗")) + return + } + + configName := configs.WireGuard[num-1].Name + + if err := wireguard.Connect(configName, config.LogsDir); err != nil { + fmt.Printf("%s Ошибка подключения: %v\n", red("✗"), err) + return + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1cee2ff --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,172 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +var ( + ConfigDir string + SubscriptionsFile string + ConfigsFile string + StateFile string + LogsDir string + XrayDir string +) + +type WireGuardConfig struct { + Name string `json:"name"` + Config string `json:"config"` +} + +type VLESSConfig struct { + Name string `json:"name"` + URL string `json:"url"` + Protocol string `json:"protocol,omitempty"` + Subscription string `json:"subscription,omitempty"` +} + +type Configs struct { + WireGuard []WireGuardConfig `json:"wireguard"` + VLESS []VLESSConfig `json:"vless"` +} + +type Subscription struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type Subscriptions struct { + Subscriptions []Subscription `json:"subscriptions"` +} + +type ConnectionState struct { + Connected bool `json:"connected"` + ConfigName string `json:"config_name"` + ConfigType string `json:"config_type"` + StartTime string `json:"start_time"` + Interface string `json:"interface"` + ProcessPID int `json:"process_pid"` + LogFile string `json:"log_file"` +} + +// Init инициализирует конфигурационные директории и файлы +func Init() error { + // Получаем рабочую директорию + workDir, err := os.Getwd() + if err != nil { + return err + } + + ConfigDir = filepath.Join(workDir, ".vpn_client") + SubscriptionsFile = filepath.Join(ConfigDir, "subscriptions.json") + ConfigsFile = filepath.Join(ConfigDir, "configs.json") + StateFile = filepath.Join(ConfigDir, "state.json") + LogsDir = filepath.Join(workDir, "logs") + XrayDir = filepath.Join(workDir, "xray") + + // Создаем директории + if err := os.MkdirAll(ConfigDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(LogsDir, 0755); err != nil { + return err + } + + // Инициализируем файлы если их нет + if err := initFileIfNotExists(SubscriptionsFile, Subscriptions{Subscriptions: []Subscription{}}); err != nil { + return err + } + if err := initFileIfNotExists(ConfigsFile, Configs{WireGuard: []WireGuardConfig{}, VLESS: []VLESSConfig{}}); err != nil { + return err + } + if err := initFileIfNotExists(StateFile, ConnectionState{}); err != nil { + return err + } + + return nil +} + +func initFileIfNotExists(path string, defaultData interface{}) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + data, err := json.MarshalIndent(defaultData, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) + } + return nil +} + +// LoadConfigs загружает конфигурации +func LoadConfigs() (*Configs, error) { + data, err := os.ReadFile(ConfigsFile) + if err != nil { + return nil, err + } + + var configs Configs + if err := json.Unmarshal(data, &configs); err != nil { + return nil, err + } + + return &configs, nil +} + +// SaveConfigs сохраняет конфигурации +func SaveConfigs(configs *Configs) error { + data, err := json.MarshalIndent(configs, "", " ") + if err != nil { + return err + } + return os.WriteFile(ConfigsFile, data, 0644) +} + +// LoadSubscriptions загружает подписки +func LoadSubscriptions() (*Subscriptions, error) { + data, err := os.ReadFile(SubscriptionsFile) + if err != nil { + return nil, err + } + + var subs Subscriptions + if err := json.Unmarshal(data, &subs); err != nil { + return nil, err + } + + return &subs, nil +} + +// SaveSubscriptions сохраняет подписки +func SaveSubscriptions(subs *Subscriptions) error { + data, err := json.MarshalIndent(subs, "", " ") + if err != nil { + return err + } + return os.WriteFile(SubscriptionsFile, data, 0644) +} + +// LoadState загружает состояние подключения +func LoadState() (*ConnectionState, error) { + data, err := os.ReadFile(StateFile) + if err != nil { + return nil, err + } + + var state ConnectionState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// SaveState сохраняет состояние подключения +func SaveState(state *ConnectionState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(StateFile, data, 0644) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..1a9187f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,34 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// LogMessage записывает сообщение в лог-файл +func LogMessage(logFile, message string) error { + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + timestamp := time.Now().Format("2006-01-02 15:04:05") + logEntry := fmt.Sprintf("[%s] %s\n", timestamp, message) + + _, err = f.WriteString(logEntry) + return err +} + +// GetLogPath возвращает путь к лог-файлу +func GetLogPath(logsDir, logType string) string { + return filepath.Join(logsDir, fmt.Sprintf("%s.log", logType)) +} + +// GetTrafficLogPath возвращает путь к лог-файлу трафика с временной меткой +func GetTrafficLogPath(logsDir string) string { + timestamp := time.Now().Format("20060102_150405") + return filepath.Join(logsDir, fmt.Sprintf("vless_traffic_%s.log", timestamp)) +} diff --git a/internal/subscription/subscription.go b/internal/subscription/subscription.go new file mode 100644 index 0000000..bf7cfe3 --- /dev/null +++ b/internal/subscription/subscription.go @@ -0,0 +1,184 @@ +package subscription + +import ( + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "vpn-client/internal/config" + "vpn-client/internal/logger" +) + +// FetchConfigs загружает конфигурации из подписки +func FetchConfigs(subscriptionURL, logsDir string) ([]config.VLESSConfig, error) { + logFile := logger.GetLogPath(logsDir, "subscription") + logger.LogMessage(logFile, fmt.Sprintf("Начало загрузки подписки: %s", subscriptionURL)) + + // Создаем HTTP клиент с таймаутом + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(subscriptionURL) + if err != nil { + logger.LogMessage(logFile, fmt.Sprintf("Ошибка загрузки подписки: %v", err)) + return nil, fmt.Errorf("ошибка загрузки подписки: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ошибка HTTP: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("ошибка чтения ответа: %w", err) + } + + // Пытаемся декодировать base64 + content := string(body) + if decoded, err := base64.StdEncoding.DecodeString(content); err == nil { + content = string(decoded) + } + + // Парсим конфигурации + var configs []config.VLESSConfig + lines := strings.Split(content, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var protocol string + var name string + var configURL string + + // VLESS конфиги + if strings.HasPrefix(line, "vless://") { + protocol = "VLESS" + configURL = line + if parsed, err := url.Parse(line); err == nil && parsed.Fragment != "" { + name, _ = url.QueryUnescape(parsed.Fragment) + } else { + name = fmt.Sprintf("VLESS_%d", len(configs)+1) + } + configs = append(configs, config.VLESSConfig{ + Name: name, + URL: configURL, + Protocol: protocol, + }) + } else if strings.HasPrefix(line, "vmess://") { + protocol = "VMess" + configURL = line + if parsed, err := url.Parse(line); err == nil && parsed.Fragment != "" { + name, _ = url.QueryUnescape(parsed.Fragment) + } else { + name = fmt.Sprintf("VMess_%d", len(configs)+1) + } + configs = append(configs, config.VLESSConfig{ + Name: name, + URL: configURL, + Protocol: protocol, + }) + } else if strings.HasPrefix(line, "trojan://") { + protocol = "Trojan" + configURL = line + if parsed, err := url.Parse(line); err == nil && parsed.Fragment != "" { + name, _ = url.QueryUnescape(parsed.Fragment) + } else { + name = fmt.Sprintf("Trojan_%d", len(configs)+1) + } + configs = append(configs, config.VLESSConfig{ + Name: name, + URL: configURL, + Protocol: protocol, + }) + } else if strings.HasPrefix(line, "ss://") { + protocol = "Shadowsocks" + configURL = line + if parsed, err := url.Parse(line); err == nil && parsed.Fragment != "" { + name, _ = url.QueryUnescape(parsed.Fragment) + } else { + name = fmt.Sprintf("SS_%d", len(configs)+1) + } + configs = append(configs, config.VLESSConfig{ + Name: name, + URL: configURL, + Protocol: protocol, + }) + } + } + + logger.LogMessage(logFile, fmt.Sprintf("Успешно загружено %d конфигов", len(configs))) + return configs, nil +} + +// UpdateSubscription обновляет конфигурации из подписки +func UpdateSubscription(subscriptionName, logsDir string) error { + // Загружаем подписки + subs, err := config.LoadSubscriptions() + if err != nil { + return fmt.Errorf("ошибка загрузки подписок: %w", err) + } + + // Ищем подписку + var sub *config.Subscription + for i := range subs.Subscriptions { + if subs.Subscriptions[i].Name == subscriptionName { + sub = &subs.Subscriptions[i] + break + } + } + + if sub == nil { + return fmt.Errorf("подписка '%s' не найдена", subscriptionName) + } + + fmt.Printf("Загрузка конфигов из '%s'...\n", subscriptionName) + + // Загружаем конфиги + newConfigs, err := FetchConfigs(sub.URL, logsDir) + if err != nil { + return fmt.Errorf("ошибка загрузки конфигов: %w", err) + } + + if len(newConfigs) == 0 { + return fmt.Errorf("не удалось загрузить конфиги") + } + + // Загружаем текущие конфигурации + allConfigs, err := config.LoadConfigs() + if err != nil { + return fmt.Errorf("ошибка загрузки конфигураций: %w", err) + } + + // Удаляем старые конфиги из этой подписки + var filteredConfigs []config.VLESSConfig + for _, cfg := range allConfigs.VLESS { + if cfg.Subscription != subscriptionName { + filteredConfigs = append(filteredConfigs, cfg) + } + } + allConfigs.VLESS = filteredConfigs + + // Добавляем новые конфиги с префиксом подписки + for _, cfg := range newConfigs { + cfg.Name = fmt.Sprintf("[%s] %s", subscriptionName, cfg.Name) + cfg.Subscription = subscriptionName + allConfigs.VLESS = append(allConfigs.VLESS, cfg) + } + + // Сохраняем + if err := config.SaveConfigs(allConfigs); err != nil { + return fmt.Errorf("ошибка сохранения конфигураций: %w", err) + } + + fmt.Printf("✓ Обновлено %d конфигов из подписки\n", len(newConfigs)) + return nil +} diff --git a/internal/vless/vless.go b/internal/vless/vless.go new file mode 100644 index 0000000..3f401cc --- /dev/null +++ b/internal/vless/vless.go @@ -0,0 +1,493 @@ +package vless + +import ( + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "vpn-client/internal/config" + "vpn-client/internal/logger" +) + +// XrayConfig представляет конфигурацию Xray +type XrayConfig struct { + Log LogConfig `json:"log"` + Inbounds []InboundConfig `json:"inbounds"` + Outbounds []OutboundConfig `json:"outbounds"` + Stats interface{} `json:"stats"` + Policy PolicyConfig `json:"policy"` +} + +type LogConfig struct { + Loglevel string `json:"loglevel"` + Access string `json:"access"` + Error string `json:"error"` + DNSLog bool `json:"dnsLog"` +} + +type InboundConfig struct { + Port int `json:"port"` + Protocol string `json:"protocol"` + Settings InboundSettings `json:"settings"` + Sniffing SniffingConfig `json:"sniffing"` + Tag string `json:"tag"` +} + +type InboundSettings struct { + UDP bool `json:"udp"` + Auth string `json:"auth"` +} + +type SniffingConfig struct { + Enabled bool `json:"enabled"` + DestOverride []string `json:"destOverride"` + MetadataOnly bool `json:"metadataOnly"` +} + +type OutboundConfig struct { + Protocol string `json:"protocol"` + Tag string `json:"tag"` + Settings OutboundSettings `json:"settings"` + StreamSettings StreamSettings `json:"streamSettings"` +} + +type OutboundSettings struct { + Vnext []VnextConfig `json:"vnext"` +} + +type VnextConfig struct { + Address string `json:"address"` + Port int `json:"port"` + Users []UserConfig `json:"users"` +} + +type UserConfig struct { + ID string `json:"id"` + Encryption string `json:"encryption"` + Flow string `json:"flow,omitempty"` +} + +type StreamSettings struct { + Network string `json:"network"` + Security string `json:"security,omitempty"` + TLSSettings *TLSSettings `json:"tlsSettings,omitempty"` + RealitySettings *RealitySettings `json:"realitySettings,omitempty"` + WSSettings *WSSettings `json:"wsSettings,omitempty"` + GRPCSettings *GRPCSettings `json:"grpcSettings,omitempty"` + HTTPSettings *HTTPSettings `json:"httpSettings,omitempty"` +} + +type TLSSettings struct { + ServerName string `json:"serverName"` + AllowInsecure bool `json:"allowInsecure"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type RealitySettings struct { + ServerName string `json:"serverName"` + Fingerprint string `json:"fingerprint"` + Show bool `json:"show"` + PublicKey string `json:"publicKey,omitempty"` + ShortID string `json:"shortId,omitempty"` + SpiderX string `json:"spiderX,omitempty"` +} + +type WSSettings struct { + Path string `json:"path"` + Headers map[string]string `json:"headers"` +} + +type GRPCSettings struct { + ServiceName string `json:"serviceName"` + MultiMode bool `json:"multiMode"` +} + +type HTTPSettings struct { + Path []string `json:"path"` + Host []string `json:"host"` +} + +type PolicyConfig struct { + Levels map[string]LevelPolicy `json:"levels"` + System SystemPolicy `json:"system"` +} + +type LevelPolicy struct { + StatsUserUplink bool `json:"statsUserUplink"` + StatsUserDownlink bool `json:"statsUserDownlink"` +} + +type SystemPolicy struct { + StatsInboundUplink bool `json:"statsInboundUplink"` + StatsInboundDownlink bool `json:"statsInboundDownlink"` + StatsOutboundUplink bool `json:"statsOutboundUplink"` + StatsOutboundDownlink bool `json:"statsOutboundDownlink"` +} + +// ParseVLESSURL парсит VLESS URL и создает конфигурацию Xray +func ParseVLESSURL(vlessURL, logsDir string) (*XrayConfig, error) { + // Убираем префикс vless:// + urlStr := strings.TrimPrefix(vlessURL, "vless://") + + // Разделяем на части + var name string + if idx := strings.Index(urlStr, "#"); idx != -1 { + name = urlStr[idx+1:] + urlStr = urlStr[:idx] + } + + // Парсим параметры + var paramsStr string + var connection string + if idx := strings.Index(urlStr, "?"); idx != -1 { + connection = urlStr[:idx] + paramsStr = urlStr[idx+1:] + } else { + connection = urlStr + } + + // Парсим connection (uuid@server:port) + parts := strings.Split(connection, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("неверный формат VLESS URL") + } + uuid := parts[0] + serverPort := parts[1] + + // Парсим server:port + var server string + var port int + if strings.Contains(serverPort, "[") { + // IPv6 + endIdx := strings.Index(serverPort, "]") + server = serverPort[1:endIdx] + portStr := strings.TrimPrefix(serverPort[endIdx+1:], ":") + port, _ = strconv.Atoi(portStr) + } else { + lastColon := strings.LastIndex(serverPort, ":") + server = serverPort[:lastColon] + portStr := serverPort[lastColon+1:] + port, _ = strconv.Atoi(portStr) + } + + if port == 0 { + port = 443 + } + + // Парсим параметры + params := make(map[string]string) + if paramsStr != "" { + for _, param := range strings.Split(paramsStr, "&") { + kv := strings.SplitN(param, "=", 2) + if len(kv) == 2 { + key, _ := url.QueryUnescape(kv[0]) + value, _ := url.QueryUnescape(kv[1]) + params[key] = value + } + } + } + + // Создаем базовую конфигурацию + cfg := &XrayConfig{ + Log: LogConfig{ + Loglevel: "debug", + Access: filepath.Join(logsDir, "vless_access.log"), + Error: filepath.Join(logsDir, "vless_error.log"), + DNSLog: true, + }, + Inbounds: []InboundConfig{ + { + Port: 10808, + Protocol: "socks", + Settings: InboundSettings{ + UDP: true, + Auth: "noauth", + }, + Sniffing: SniffingConfig{ + Enabled: true, + DestOverride: []string{"http", "tls"}, + MetadataOnly: false, + }, + Tag: "socks-in", + }, + }, + Outbounds: []OutboundConfig{ + { + Protocol: "vless", + Tag: "proxy", + Settings: OutboundSettings{ + Vnext: []VnextConfig{ + { + Address: server, + Port: port, + Users: []UserConfig{ + { + ID: uuid, + Encryption: getParam(params, "encryption", "none"), + Flow: params["flow"], + }, + }, + }, + }, + }, + StreamSettings: StreamSettings{ + Network: getParam(params, "type", "tcp"), + }, + }, + }, + Stats: struct{}{}, + Policy: PolicyConfig{ + Levels: map[string]LevelPolicy{ + "0": { + StatsUserUplink: true, + StatsUserDownlink: true, + }, + }, + System: SystemPolicy{ + StatsInboundUplink: true, + StatsInboundDownlink: true, + StatsOutboundUplink: true, + StatsOutboundDownlink: true, + }, + }, + } + + // Удаляем пустой flow + if cfg.Outbounds[0].Settings.Vnext[0].Users[0].Flow == "" { + cfg.Outbounds[0].Settings.Vnext[0].Users[0].Flow = "" + } + + // Настройки безопасности + security := params["security"] + if security == "tls" { + cfg.Outbounds[0].StreamSettings.Security = "tls" + cfg.Outbounds[0].StreamSettings.TLSSettings = &TLSSettings{ + ServerName: getParam(params, "sni", server), + AllowInsecure: params["allowInsecure"] == "1", + Fingerprint: params["fp"], + } + } else if security == "reality" { + cfg.Outbounds[0].StreamSettings.Security = "reality" + cfg.Outbounds[0].StreamSettings.RealitySettings = &RealitySettings{ + ServerName: getParam(params, "sni", server), + Fingerprint: getParam(params, "fp", "chrome"), + Show: false, + PublicKey: params["pbk"], + ShortID: params["sid"], + SpiderX: params["spx"], + } + } + + // Настройки транспорта + network := getParam(params, "type", "tcp") + if network == "ws" { + headers := make(map[string]string) + if host := params["host"]; host != "" { + headers["Host"] = host + } + cfg.Outbounds[0].StreamSettings.WSSettings = &WSSettings{ + Path: getParam(params, "path", "/"), + Headers: headers, + } + } else if network == "grpc" { + cfg.Outbounds[0].StreamSettings.GRPCSettings = &GRPCSettings{ + ServiceName: getParam(params, "serviceName", params["path"]), + MultiMode: params["mode"] == "multi", + } + } else if network == "h2" || network == "http" { + cfg.Outbounds[0].StreamSettings.Network = "http" + cfg.Outbounds[0].StreamSettings.HTTPSettings = &HTTPSettings{ + Path: []string{getParam(params, "path", "/")}, + Host: []string{getParam(params, "host", server)}, + } + } + + // Логируем информацию + logFile := logger.GetLogPath(logsDir, "vless") + logger.LogMessage(logFile, fmt.Sprintf("Создание конфига для сервера: %s:%d", server, port)) + logger.LogMessage(logFile, fmt.Sprintf("UUID: %s", uuid)) + logger.LogMessage(logFile, fmt.Sprintf("Транспорт: %s", network)) + logger.LogMessage(logFile, fmt.Sprintf("Безопасность: %s", security)) + if name != "" { + logger.LogMessage(logFile, fmt.Sprintf("Имя: %s", name)) + } + + return cfg, nil +} + +func getParam(params map[string]string, key, defaultValue string) string { + if val, ok := params[key]; ok && val != "" { + return val + } + return defaultValue +} + +// Connect подключается к VLESS серверу +func Connect(configName string, logsDir, xrayDir string) error { + // Загружаем конфигурации + configs, err := config.LoadConfigs() + if err != nil { + return fmt.Errorf("ошибка загрузки конфигураций: %w", err) + } + + // Ищем конфиг + var vlessConfig *config.VLESSConfig + for i := range configs.VLESS { + if configs.VLESS[i].Name == configName { + vlessConfig = &configs.VLESS[i] + break + } + } + + if vlessConfig == nil { + return fmt.Errorf("конфиг '%s' не найден", configName) + } + + // Создаем конфигурацию Xray + xrayConfig, err := ParseVLESSURL(vlessConfig.URL, logsDir) + if err != nil { + return fmt.Errorf("ошибка парсинга VLESS URL: %w", err) + } + + // Сохраняем конфигурацию + configPath := filepath.Join(config.ConfigDir, "xray_config.json") + data, err := json.MarshalIndent(xrayConfig, "", " ") + if err != nil { + return fmt.Errorf("ошибка сериализации конфига: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("ошибка сохранения конфига: %w", err) + } + + // Путь к xray + xrayExe := "xray" + if runtime.GOOS == "windows" { + xrayExe = "xray.exe" + } + xrayPath := filepath.Join(xrayDir, xrayExe) + + if _, err := os.Stat(xrayPath); os.IsNotExist(err) { + return fmt.Errorf("xray не найден в %s", xrayDir) + } + + // Создаем лог-файл трафика + trafficLog := logger.GetTrafficLogPath(logsDir) + logFile, err := os.Create(trafficLog) + if err != nil { + return fmt.Errorf("ошибка создания лог-файла: %w", err) + } + + // Записываем заголовок + fmt.Fprintf(logFile, "=== VPN Подключение начато: %s ===\n", time.Now().Format("2006-01-02 15:04:05")) + fmt.Fprintf(logFile, "Конфиг: %s\n", configName) + fmt.Fprintf(logFile, "Клиент: Xray\n") + fmt.Fprintf(logFile, "Прокси: 127.0.0.1:10808\n") + fmt.Fprintf(logFile, "%s\n\n", strings.Repeat("=", 60)) + + // Запускаем xray + cmd := exec.Command(xrayPath, "run", "-c", configPath) + cmd.Stdout = logFile + cmd.Stderr = logFile + + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("ошибка запуска xray: %w", err) + } + + // Ждем немного для проверки запуска + time.Sleep(3 * time.Second) + + // Проверяем, что процесс еще работает + if err := cmd.Process.Signal(os.Signal(nil)); err != nil { + logFile.Close() + return fmt.Errorf("процесс xray завершился с ошибкой") + } + + // Сохраняем состояние + state := &config.ConnectionState{ + Connected: true, + ConfigName: configName, + ConfigType: "vless", + StartTime: time.Now().Format(time.RFC3339), + Interface: "xray", + ProcessPID: cmd.Process.Pid, + LogFile: trafficLog, + } + + if err := config.SaveState(state); err != nil { + cmd.Process.Kill() + logFile.Close() + return fmt.Errorf("ошибка сохранения состояния: %w", err) + } + + // Логируем успешное подключение + logPath := logger.GetLogPath(logsDir, "vless") + logger.LogMessage(logPath, fmt.Sprintf("Успешно подключено к '%s' (PID: %d, Лог: %s)", configName, cmd.Process.Pid, trafficLog)) + + fmt.Printf("✓ Подключено к '%s'\n", configName) + fmt.Printf("SOCKS5 прокси: 127.0.0.1:10808\n") + fmt.Printf("\nЛоги трафика:\n") + fmt.Printf(" - Основной: %s\n", trafficLog) + fmt.Printf(" - Доступ (IP): %s\n", filepath.Join(logsDir, "vless_access.log")) + fmt.Printf(" - Ошибки: %s\n", filepath.Join(logsDir, "vless_error.log")) + + return nil +} + +// PingServer проверяет доступность VLESS сервера +func PingServer(vlessURL string, timeout time.Duration) (bool, float64, error) { + // Парсим URL для получения адреса сервера + urlStr := strings.TrimPrefix(vlessURL, "vless://") + + if idx := strings.Index(urlStr, "#"); idx != -1 { + urlStr = urlStr[:idx] + } + + if idx := strings.Index(urlStr, "?"); idx != -1 { + urlStr = urlStr[:idx] + } + + parts := strings.Split(urlStr, "@") + if len(parts) != 2 { + return false, 0, fmt.Errorf("неверный формат URL") + } + + serverPort := parts[1] + var server string + var port string + + if strings.Contains(serverPort, "[") { + endIdx := strings.Index(serverPort, "]") + server = serverPort[1:endIdx] + port = strings.TrimPrefix(serverPort[endIdx+1:], ":") + } else { + lastColon := strings.LastIndex(serverPort, ":") + server = serverPort[:lastColon] + port = serverPort[lastColon+1:] + } + + if port == "" { + port = "443" + } + + // Измеряем время подключения + start := time.Now() + conn, err := net.DialTimeout("tcp", net.JoinHostPort(server, port), timeout) + elapsed := time.Since(start) + + if err != nil { + return false, 0, err + } + conn.Close() + + return true, float64(elapsed.Milliseconds()), nil +} diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go new file mode 100644 index 0000000..3fadad8 --- /dev/null +++ b/internal/vpn/vpn.go @@ -0,0 +1,143 @@ +package vpn + +import ( + "fmt" + "os" + "runtime" + "syscall" + "time" + + "vpn-client/internal/config" + "vpn-client/internal/logger" + "vpn-client/internal/wireguard" +) + +// Disconnect отключает VPN +func Disconnect(logsDir string) error { + // Загружаем состояние + state, err := config.LoadState() + if err != nil { + return fmt.Errorf("ошибка загрузки состояния: %w", err) + } + + if !state.Connected { + return fmt.Errorf("VPN не подключен") + } + + fmt.Printf("Отключение от '%s'...\n", state.ConfigName) + + // Логируем отключение + var logFile string + if state.ConfigType == "wireguard" { + logFile = logger.GetLogPath(logsDir, "wireguard") + } else if state.ConfigType == "vless" { + logFile = logger.GetLogPath(logsDir, "vless") + } + + if logFile != "" { + logger.LogMessage(logFile, fmt.Sprintf("Начало отключения от '%s'", state.ConfigName)) + } + + // Останавливаем процесс в зависимости от типа + if state.ConfigType == "wireguard" { + // Отключаем WireGuard + if err := wireguard.Disconnect(state.Interface, logsDir); err != nil { + fmt.Printf("%s Ошибка отключения WireGuard: %v\n", "⚠", err) + } + } else if state.ProcessPID > 0 { + // Останавливаем процесс VLESS + process, err := os.FindProcess(state.ProcessPID) + if err == nil { + if runtime.GOOS == "windows" { + // На Windows используем taskkill + process.Kill() + } else { + // На Unix используем SIGTERM + process.Signal(syscall.SIGTERM) + } + + // Ждем завершения процесса + time.Sleep(1 * time.Second) + + if logFile != "" { + logger.LogMessage(logFile, fmt.Sprintf("Отключено от '%s' (PID: %d)", state.ConfigName, state.ProcessPID)) + } + + if state.LogFile != "" && logFile != "" { + logger.LogMessage(logFile, fmt.Sprintf("Лог трафика сохранен: %s", state.LogFile)) + } + } + } + + // Сбрасываем состояние + newState := &config.ConnectionState{ + Connected: false, + ConfigName: "", + ConfigType: "", + StartTime: "", + Interface: "", + ProcessPID: 0, + LogFile: "", + } + + if err := config.SaveState(newState); err != nil { + return fmt.Errorf("ошибка сохранения состояния: %w", err) + } + + fmt.Println("✓ Отключено от VPN") + return nil +} + +// GetStatus возвращает текущий статус подключения +func GetStatus() (*config.ConnectionState, error) { + return config.LoadState() +} + +// ShowStatus отображает детальный статус подключения +func ShowStatus() error { + state, err := GetStatus() + if err != nil { + return fmt.Errorf("ошибка получения статуса: %w", err) + } + + if !state.Connected { + fmt.Println("\n❌ VPN не подключен") + return nil + } + + fmt.Println("\n" + "==================================================") + fmt.Println("📊 Статус VPN") + fmt.Println("==================================================") + fmt.Printf("Статус: ✓ Подключено\n") + fmt.Printf("Конфиг: %s\n", state.ConfigName) + fmt.Printf("Тип: %s\n", state.ConfigType) + + if state.StartTime != "" { + startTime, err := time.Parse(time.RFC3339, state.StartTime) + if err == nil { + duration := time.Since(startTime) + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + seconds := int(duration.Seconds()) % 60 + fmt.Printf("Время подключения: %02d:%02d:%02d\n", hours, minutes, seconds) + } + } + + if state.ConfigType == "vless" { + fmt.Printf("Прокси: 127.0.0.1:10808\n") + if state.LogFile != "" { + fmt.Printf("Лог трафика: %s\n", state.LogFile) + } + } else if state.ConfigType == "wireguard" { + // Получаем статистику WireGuard + stats, err := wireguard.GetStats(state.Interface) + if err == nil { + fmt.Printf("\nСтатистика трафика:\n") + fmt.Printf(" Получено: %s\n", stats["rx"]) + fmt.Printf(" Отправлено: %s\n", stats["tx"]) + } + } + + fmt.Println("==================================================") + return nil +} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go new file mode 100644 index 0000000..585bdca --- /dev/null +++ b/internal/wireguard/wireguard.go @@ -0,0 +1,301 @@ +package wireguard + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "vpn-client/internal/config" + "vpn-client/internal/logger" +) + +// Connect подключается к WireGuard серверу +func Connect(configName string, logsDir string) error { + // Загружаем конфигурации + configs, err := config.LoadConfigs() + if err != nil { + return fmt.Errorf("ошибка загрузки конфигураций: %w", err) + } + + // Ищем конфиг + var wgConfig *config.WireGuardConfig + for i := range configs.WireGuard { + if configs.WireGuard[i].Name == configName { + wgConfig = &configs.WireGuard[i] + break + } + } + + if wgConfig == nil { + return fmt.Errorf("конфиг '%s' не найден", configName) + } + + // Создаем файл конфига + interfaceName := strings.ReplaceAll(configName, " ", "_") + configPath := filepath.Join(config.ConfigDir, interfaceName+".conf") + + if err := os.WriteFile(configPath, []byte(wgConfig.Config), 0600); err != nil { + return fmt.Errorf("ошибка создания файла конфига: %w", err) + } + + fmt.Printf("Подключение к WireGuard '%s'...\n", configName) + + logFile := logger.GetLogPath(logsDir, "wireguard") + logger.LogMessage(logFile, fmt.Sprintf("Начало подключения к '%s'", configName)) + + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + // Для Windows используем wireguard.exe + wgPaths := []string{ + `C:\Program Files\WireGuard\wireguard.exe`, + `C:\Program Files (x86)\WireGuard\wireguard.exe`, + } + + var wgExe string + for _, path := range wgPaths { + if _, err := os.Stat(path); err == nil { + wgExe = path + break + } + } + + if wgExe == "" { + return fmt.Errorf("WireGuard не найден. Установите WireGuard для Windows\nСкачать: https://www.wireguard.com/install/") + } + + // Импортируем туннель + cmd = exec.Command(wgExe, "/installtunnelservice", configPath) + output, err := cmd.CombinedOutput() + + if err != nil { + logger.LogMessage(logFile, fmt.Sprintf("Ошибка подключения к '%s': %s", configName, string(output))) + return fmt.Errorf("ошибка подключения: %s", string(output)) + } + + // Сохраняем состояние + state := &config.ConnectionState{ + Connected: true, + ConfigName: configName, + ConfigType: "wireguard", + StartTime: time.Now().Format(time.RFC3339), + Interface: interfaceName, + ProcessPID: 0, // WireGuard на Windows работает как служба + LogFile: "", + } + + if err := config.SaveState(state); err != nil { + return fmt.Errorf("ошибка сохранения состояния: %w", err) + } + + logger.LogMessage(logFile, fmt.Sprintf("Успешно подключено к '%s'", configName)) + fmt.Printf("✓ Подключено к '%s'\n", configName) + + } else { + // Для Linux/Mac используем wg-quick + cmd = exec.Command("sudo", "wg-quick", "up", configPath) + output, err := cmd.CombinedOutput() + + if err != nil { + logger.LogMessage(logFile, fmt.Sprintf("Ошибка подключения к '%s': %s", configName, string(output))) + return fmt.Errorf("ошибка подключения: %s", string(output)) + } + + // Сохраняем состояние + state := &config.ConnectionState{ + Connected: true, + ConfigName: configName, + ConfigType: "wireguard", + StartTime: time.Now().Format(time.RFC3339), + Interface: interfaceName, + ProcessPID: 0, + LogFile: "", + } + + if err := config.SaveState(state); err != nil { + return fmt.Errorf("ошибка сохранения состояния: %w", err) + } + + logger.LogMessage(logFile, fmt.Sprintf("Успешно подключено к '%s'", configName)) + fmt.Printf("✓ Подключено к '%s'\n", configName) + } + + return nil +} + +// Disconnect отключается от WireGuard +func Disconnect(interfaceName, logsDir string) error { + logFile := logger.GetLogPath(logsDir, "wireguard") + logger.LogMessage(logFile, fmt.Sprintf("Начало отключения от '%s'", interfaceName)) + + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + wgPaths := []string{ + `C:\Program Files\WireGuard\wireguard.exe`, + `C:\Program Files (x86)\WireGuard\wireguard.exe`, + } + + var wgExe string + for _, path := range wgPaths { + if _, err := os.Stat(path); err == nil { + wgExe = path + break + } + } + + if wgExe != "" { + cmd = exec.Command(wgExe, "/uninstalltunnelservice", interfaceName) + cmd.Run() + } + } else { + configPath := filepath.Join(config.ConfigDir, interfaceName+".conf") + cmd = exec.Command("sudo", "wg-quick", "down", configPath) + cmd.Run() + } + + logger.LogMessage(logFile, fmt.Sprintf("Отключено от '%s'", interfaceName)) + return nil +} + +// GetStats получает статистику WireGuard +func GetStats(interfaceName string) (map[string]string, error) { + stats := map[string]string{ + "rx": "N/A", + "tx": "N/A", + } + + if runtime.GOOS == "windows" { + // Для Windows пытаемся получить статистику через wg.exe + wgPaths := []string{ + `C:\Program Files\WireGuard\wg.exe`, + `C:\Program Files (x86)\WireGuard\wg.exe`, + } + + var wgExe string + for _, path := range wgPaths { + if _, err := os.Stat(path); err == nil { + wgExe = path + break + } + } + + if wgExe != "" { + cmd := exec.Command(wgExe, "show", interfaceName, "transfer") + output, err := cmd.Output() + if err == nil && len(output) > 0 { + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + parts := strings.Fields(line) + if len(parts) >= 3 { + rx := parseBytes(parts[1]) + tx := parseBytes(parts[2]) + stats["rx"] = formatBytes(rx) + stats["tx"] = formatBytes(tx) + break + } + } + } + } + } else { + // Для Linux/Mac + cmd := exec.Command("wg", "show", interfaceName, "transfer") + output, err := cmd.Output() + if err == nil && len(output) > 0 { + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) > 0 { + parts := strings.Fields(lines[0]) + if len(parts) >= 2 { + rx := parseBytes(parts[0]) + tx := parseBytes(parts[1]) + stats["rx"] = formatBytes(rx) + stats["tx"] = formatBytes(tx) + } + } + } + } + + return stats, nil +} + +// AddConfig добавляет WireGuard конфигурацию +func AddConfig(name, configText string) error { + configs, err := config.LoadConfigs() + if err != nil { + return fmt.Errorf("ошибка загрузки конфигураций: %w", err) + } + + configs.WireGuard = append(configs.WireGuard, config.WireGuardConfig{ + Name: name, + Config: configText, + }) + + if err := config.SaveConfigs(configs); err != nil { + return fmt.Errorf("ошибка сохранения конфигураций: %w", err) + } + + return nil +} + +// AddConfigFromFile добавляет WireGuard конфигурацию из файла +func AddConfigFromFile(name, filePath string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("ошибка чтения файла: %w", err) + } + + return AddConfig(name, string(data)) +} + +// DeleteConfig удаляет WireGuard конфигурацию +func DeleteConfig(name string) error { + configs, err := config.LoadConfigs() + if err != nil { + return fmt.Errorf("ошибка загрузки конфигураций: %w", err) + } + + var filtered []config.WireGuardConfig + found := false + for _, cfg := range configs.WireGuard { + if cfg.Name != name { + filtered = append(filtered, cfg) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("конфиг '%s' не найден", name) + } + + configs.WireGuard = filtered + + if err := config.SaveConfigs(configs); err != nil { + return fmt.Errorf("ошибка сохранения конфигураций: %w", err) + } + + return nil +} + +func parseBytes(s string) int64 { + var val int64 + fmt.Sscanf(s, "%d", &val) + return val +} + +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..028bb54 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + "vpn-client/internal/cli" + "vpn-client/internal/config" +) + +func main() { + // Инициализация конфигурации + if err := config.Init(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка инициализации: %v\n", err) + os.Exit(1) + } + + // Запуск CLI + if err := cli.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } +}