From 83fbe7afdd7f536332e8a29b906bd5ea8983742a Mon Sep 17 00:00:00 2001 From: arkonsadter Date: Sun, 5 Apr 2026 20:33:30 +0600 Subject: [PATCH] Initial commit --- .gitignore | 34 + EXAMPLES.md | 413 ++++++++++++ Makefile | 119 ++++ README.md | 180 ++++++ build.bat | 17 + build.sh | 17 + go.mod | 11 + go.sum | 11 + init.bat | 11 + init.sh | 11 + internal/cli/cli.go | 864 ++++++++++++++++++++++++++ internal/config/config.go | 172 +++++ internal/logger/logger.go | 34 + internal/subscription/subscription.go | 184 ++++++ internal/vless/vless.go | 493 +++++++++++++++ internal/vpn/vpn.go | 143 +++++ internal/wireguard/wireguard.go | 301 +++++++++ main.go | 23 + 18 files changed, 3038 insertions(+) create mode 100644 .gitignore create mode 100644 EXAMPLES.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.bat create mode 100644 build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 init.bat create mode 100644 init.sh create mode 100644 internal/cli/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/logger/logger.go create mode 100644 internal/subscription/subscription.go create mode 100644 internal/vless/vless.go create mode 100644 internal/vpn/vpn.go create mode 100644 internal/wireguard/wireguard.go create mode 100644 main.go 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) + } +}