feat(gui): add GUI (Test) implementation with documentation and admin support

- Add GUI (Test) module with Fyne-based interface (internal/gui/gui.go, internal/gui/server.go)
- Add CLI monitoring capability (internal/cli/monitor.go)
- Add main_cli.go entry point for CLI-only builds
- Add comprehensive documentation suite covering setup, build, quick start, and changelog
- Add admin manifest (admin.manifest) for Windows UAC elevation support
- Add rsrc.syso.json configuration for resource embedding
- Update .gitignore to exclude build scripts (*.bat, *.sh)
- Update main.go and cli.go to support dual GUI (Test)/CLI modes
- Update README.md with new project information
- Enables users to build and run both GUI (Test)and CLI versions with proper admin privileges on Windows
This commit is contained in:
2026-04-06 18:57:58 +06:00
parent 83fbe7afdd
commit e0a5f0f746
20 changed files with 2471 additions and 32 deletions

View File

@@ -34,6 +34,9 @@ func Run() error {
choice := readInput("Выберите действие: ")
state, _ := vpn.GetStatus()
isConnected := state != nil && state.Connected
switch choice {
case "1":
wireguardMenu()
@@ -47,10 +50,29 @@ func Run() error {
}
pause()
case "5":
if err := vpn.Disconnect(config.LogsDir); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
if isConnected {
// Мониторинг в реальном времени
if err := MonitorConnection(); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
pause()
}
} else {
// Отключение
if err := vpn.Disconnect(config.LogsDir); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
}
pause()
}
case "6":
if isConnected {
if err := vpn.Disconnect(config.LogsDir); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
}
pause()
} else {
fmt.Printf("%s Неверный выбор\n", red("✗"))
pause()
}
pause()
case "0":
fmt.Println("До свидания!")
return nil
@@ -64,25 +86,27 @@ func Run() error {
func showMainMenu() {
state, _ := vpn.GetStatus()
statusIcon := "○"
statusText := "Не подключено"
if state != nil && state.Connected {
statusIcon = "✓"
statusText = fmt.Sprintf("Подключено: %s", state.ConfigName)
}
statusLine := ShowQuickStatus()
fmt.Println("\n" + strings.Repeat("=", 50))
fmt.Println("\n" + strings.Repeat("=", 60))
fmt.Println(bold("VPN Клиент (Go)"))
fmt.Println(strings.Repeat("=", 50))
fmt.Printf("Статус: %s %s\n", statusIcon, statusText)
fmt.Println(strings.Repeat("=", 50))
fmt.Println(strings.Repeat("=", 60))
fmt.Printf("Статус: %s\n", statusLine)
fmt.Println(strings.Repeat("=", 60))
fmt.Println("1. WireGuard")
fmt.Println("2. VLESS")
fmt.Println("3. Управление подписками")
fmt.Println("4. Показать статус подключения")
fmt.Println("5. Отключиться от VPN")
if state != nil && state.Connected {
fmt.Println("5. " + green("Мониторинг в реальном времени"))
fmt.Println("6. Отключиться от VPN")
} else {
fmt.Println("5. Отключиться от VPN")
}
fmt.Println("0. Выход")
fmt.Println(strings.Repeat("=", 50))
fmt.Println(strings.Repeat("=", 60))
}
func vlessMenu() {
@@ -252,6 +276,15 @@ func connectVLESS() {
fmt.Printf("%s Ошибка подключения: %v\n", red("✗"), err)
return
}
// Предлагаем запустить мониторинг
fmt.Println("\n" + strings.Repeat("─", 60))
choice := readInput("Запустить мониторинг в реальном времени? (y/n): ")
if strings.ToLower(choice) == "y" || strings.ToLower(choice) == "д" {
if err := MonitorConnection(); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
}
}
}
func testVLESSConfig() {
@@ -861,4 +894,13 @@ func connectWireGuard() {
fmt.Printf("%s Ошибка подключения: %v\n", red("✗"), err)
return
}
// Предлагаем запустить мониторинг
fmt.Println("\n" + strings.Repeat("─", 60))
choice := readInput("Запустить мониторинг в реальном времени? (y/n): ")
if strings.ToLower(choice) == "y" || strings.ToLower(choice) == "д" {
if err := MonitorConnection(); err != nil {
fmt.Printf("%s %v\n", red("✗"), err)
}
}
}

188
internal/cli/monitor.go Normal file
View File

@@ -0,0 +1,188 @@
package cli
import (
"fmt"
"os"
"strings"
"time"
"vpn-client/internal/config"
"vpn-client/internal/wireguard"
)
// MonitorConnection показывает статус подключения в реальном времени
func MonitorConnection() error {
// Проверяем, что есть активное подключение
state, err := config.LoadState()
if err != nil {
return fmt.Errorf("ошибка загрузки состояния: %w", err)
}
if !state.Connected {
fmt.Println("VPN не подключен")
return nil
}
fmt.Println("Нажмите Ctrl+C для выхода из мониторинга\n")
time.Sleep(1 * time.Second)
// Запускаем мониторинг
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
clearScreen()
displayRealtimeStatus(state)
}
}
}
func displayRealtimeStatus(state *config.ConnectionState) {
// Перезагружаем состояние для актуальных данных
currentState, err := config.LoadState()
if err != nil || !currentState.Connected {
fmt.Println("❌ Подключение потеряно")
return
}
// Заголовок
fmt.Println(strings.Repeat("═", 70))
fmt.Println(bold("📊 VPN МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ"))
fmt.Println(strings.Repeat("═", 70))
// Статус подключения
fmt.Printf("\n%s %s\n", green("●"), bold("ПОДКЛЮЧЕНО"))
fmt.Printf("Конфигурация: %s\n", cyan(currentState.ConfigName))
fmt.Printf("Тип: %s\n", currentState.ConfigType)
// Время подключения
if currentState.StartTime != "" {
startTime, err := time.Parse(time.RFC3339, currentState.StartTime)
if err == nil {
duration := time.Since(startTime)
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % 60
seconds := int(duration.Seconds()) % 60
fmt.Printf("Время подключения: %s\n",
yellow(fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)))
}
}
// Текущее время
fmt.Printf("Текущее время: %s\n", time.Now().Format("15:04:05"))
fmt.Println(strings.Repeat("─", 70))
// Специфичная информация по типу подключения
if currentState.ConfigType == "vless" {
displayVLESSStats(currentState)
} else if currentState.ConfigType == "wireguard" {
displayWireGuardStats(currentState)
}
fmt.Println(strings.Repeat("═", 70))
fmt.Printf("\n%s Обновление каждую секунду | Нажмите Ctrl+C для выхода\n",
cyan(""))
}
func displayVLESSStats(state *config.ConnectionState) {
fmt.Println("\n" + bold("VLESS/Xray Прокси"))
fmt.Printf("Адрес прокси: %s\n", green("127.0.0.1:10808"))
fmt.Printf("Протокол: SOCKS5\n")
fmt.Printf("PID процесса: %d\n", state.ProcessPID)
if state.LogFile != "" {
fmt.Printf("\n%s Логи\n", bold("📝"))
fmt.Printf(" Трафик: %s\n", state.LogFile)
// Показываем размер лог-файла
if info, err := os.Stat(state.LogFile); err == nil {
size := info.Size()
sizeStr := formatBytes(size)
fmt.Printf(" Размер лога: %s\n", sizeStr)
}
}
// Проверяем, что процесс еще работает
if state.ProcessPID > 0 {
process, err := os.FindProcess(state.ProcessPID)
if err == nil {
if err := process.Signal(os.Signal(nil)); err != nil {
fmt.Printf("\n%s Процесс Xray не отвечает!\n", red("⚠"))
}
}
}
}
func displayWireGuardStats(state *config.ConnectionState) {
fmt.Println("\n" + bold("WireGuard Туннель"))
fmt.Printf("Интерфейс: %s\n", state.Interface)
// Получаем статистику WireGuard
stats, err := wireguard.GetStats(state.Interface)
if err != nil {
fmt.Printf("\n%s Ошибка получения статистики: %v\n", red("⚠"), err)
return
}
fmt.Printf("\n%s Статистика трафика\n", bold("📊"))
// Парсим и форматируем данные
if rx, ok := stats["rx"]; ok {
fmt.Printf(" %s Получено: %s\n", green("↓"), rx)
}
if tx, ok := stats["tx"]; ok {
fmt.Printf(" %s Отправлено: %s\n", yellow("↑"), tx)
}
// Дополнительная информация
if endpoint, ok := stats["endpoint"]; ok && endpoint != "" {
fmt.Printf("\n%s Сервер\n", bold("🌐"))
fmt.Printf(" Endpoint: %s\n", endpoint)
}
if handshake, ok := stats["latest_handshake"]; ok && handshake != "" {
fmt.Printf(" Последний handshake: %s\n", handshake)
}
}
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("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// ShowQuickStatus показывает краткий статус (для главного меню)
func ShowQuickStatus() string {
state, err := config.LoadState()
if err != nil || !state.Connected {
return fmt.Sprintf("%s Не подключено", red("○"))
}
// Вычисляем время подключения
var timeStr string
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
timeStr = fmt.Sprintf(" [%02d:%02d]", hours, minutes)
}
}
return fmt.Sprintf("%s Подключено: %s%s",
green("●"),
cyan(state.ConfigName),
yellow(timeStr))
}

39
internal/gui/gui.go Normal file
View File

@@ -0,0 +1,39 @@
package gui
import (
"fmt"
"os/exec"
"runtime"
"time"
)
func Run() error {
url := startServer()
time.Sleep(500 * time.Millisecond)
fmt.Println("🚀 VPN Client GUI запущен!")
fmt.Printf("📱 Откройте в браузере: %s\n", url)
fmt.Println("💡 Браузер откроется автоматически...")
fmt.Println("\n⚠ Не закрывайте это окно!")
fmt.Println(" Для выхода нажмите Ctrl+C\n")
openBrowser(url)
select {}
}
func openBrowser(url string) {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
if err != nil {
fmt.Printf("Не удалось открыть браузер: %v\n", err)
}
}

437
internal/gui/server.go Normal file
View File

@@ -0,0 +1,437 @@
package gui
import (
"encoding/json"
"fmt"
"net/http"
"time"
"vpn-client/internal/config"
"vpn-client/internal/subscription"
"vpn-client/internal/vless"
"vpn-client/internal/vpn"
"vpn-client/internal/wireguard"
)
// Запуск HTTP сервера
func startServer() string {
addr := "127.0.0.1:8765"
http.HandleFunc("/", handleIndex)
http.HandleFunc("/api/status", handleStatus)
http.HandleFunc("/api/wireguard/list", handleWireGuardList)
http.HandleFunc("/api/wireguard/add", handleWireGuardAdd)
http.HandleFunc("/api/wireguard/delete", handleWireGuardDelete)
http.HandleFunc("/api/wireguard/connect", handleWireGuardConnect)
http.HandleFunc("/api/vless/list", handleVLESSList)
http.HandleFunc("/api/vless/add", handleVLESSAdd)
http.HandleFunc("/api/vless/delete", handleVLESSDelete)
http.HandleFunc("/api/vless/connect", handleVLESSConnect)
http.HandleFunc("/api/vless/test", handleVLESSTest)
http.HandleFunc("/api/subscriptions/list", handleSubscriptionsList)
http.HandleFunc("/api/subscriptions/add", handleSubscriptionsAdd)
http.HandleFunc("/api/subscriptions/delete", handleSubscriptionsDelete)
http.HandleFunc("/api/subscriptions/update", handleSubscriptionsUpdate)
http.HandleFunc("/api/subscriptions/show", handleSubscriptionsShow)
http.HandleFunc("/api/subscriptions/test", handleSubscriptionsTest)
http.HandleFunc("/api/disconnect", handleDisconnect)
go http.ListenAndServe(addr, nil)
return "http://" + addr
}
// API handlers
func handleStatus(w http.ResponseWriter, r *http.Request) {
state, err := vpn.GetStatus()
response := map[string]interface{}{
"connected": false,
"info": "",
}
if err == nil && state.Connected {
response["connected"] = true
info := fmt.Sprintf("Конфиг: %s | Тип: %s", state.ConfigName, 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
info += fmt.Sprintf(" | Время: %02d:%02d:%02d", hours, minutes, seconds)
}
}
if state.ConfigType == "vless" {
info += " | Прокси: 127.0.0.1:10808"
} else if state.ConfigType == "wireguard" {
stats, err := wireguard.GetStats(state.Interface)
if err == nil {
info += fmt.Sprintf(" | ↓%s ↑%s", stats["rx"], stats["tx"])
}
}
response["info"] = info
}
json.NewEncoder(w).Encode(response)
}
func handleWireGuardList(w http.ResponseWriter, r *http.Request) {
configs, _ := config.LoadConfigs()
json.NewEncoder(w).Encode(configs.WireGuard)
}
func handleWireGuardAdd(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Config string `json:"config"`
}
json.NewDecoder(r.Body).Decode(&req)
err := wireguard.AddConfig(req.Name, req.Config)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": err == nil,
"message": func() string {
if err != nil {
return err.Error()
}
return "Конфиг добавлен"
}(),
})
}
func handleWireGuardDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, _ := config.LoadConfigs()
if req.Index >= 0 && req.Index < len(configs.WireGuard) {
name := configs.WireGuard[req.Index].Name
wireguard.DeleteConfig(name)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleWireGuardConnect(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, _ := config.LoadConfigs()
if req.Index >= 0 && req.Index < len(configs.WireGuard) {
name := configs.WireGuard[req.Index].Name
err := wireguard.Connect(name, config.LogsDir)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": err == nil,
"message": func() string {
if err != nil {
return err.Error()
}
return "Подключено к " + name
}(),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Конфиг не найден",
})
}
func handleVLESSList(w http.ResponseWriter, r *http.Request) {
configs, _ := config.LoadConfigs()
json.NewEncoder(w).Encode(configs.VLESS)
}
func handleVLESSAdd(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
URL string `json:"url"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, err := config.LoadConfigs()
if err == nil {
configs.VLESS = append(configs.VLESS, config.VLESSConfig{
Name: req.Name,
URL: req.URL,
Protocol: "VLESS",
})
config.SaveConfigs(configs)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleVLESSDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, _ := config.LoadConfigs()
if req.Index >= 0 && req.Index < len(configs.VLESS) {
configs.VLESS = append(configs.VLESS[:req.Index], configs.VLESS[req.Index+1:]...)
config.SaveConfigs(configs)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleVLESSConnect(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, _ := config.LoadConfigs()
if req.Index >= 0 && req.Index < len(configs.VLESS) {
name := configs.VLESS[req.Index].Name
err := vless.Connect(name, config.LogsDir, config.XrayDir)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": err == nil,
"message": func() string {
if err != nil {
return err.Error()
}
return "Подключено к " + name + "\n\nSOCKS5 прокси: 127.0.0.1:10808"
}(),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Конфиг не найден",
})
}
func handleVLESSTest(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
configs, _ := config.LoadConfigs()
if req.Index >= 0 && req.Index < len(configs.VLESS) {
cfg := configs.VLESS[req.Index]
success, ping, err := vless.PingServer(cfg.URL, 5*time.Second)
if err != nil || !success {
msg := "Сервер недоступен"
if err != nil {
msg += "\n\nОшибка: " + err.Error()
}
json.NewEncoder(w).Encode(map[string]string{"message": msg})
return
}
quality := "Плохо"
if ping < 50 {
quality = "Отлично"
} else if ping < 100 {
quality = "Хорошо"
} else if ping < 200 {
quality = "Средне"
}
msg := fmt.Sprintf("✓ Сервер доступен\n\nПинг: %.2f мс\nКачество: %s", ping, quality)
json.NewEncoder(w).Encode(map[string]string{"message": msg})
return
}
json.NewEncoder(w).Encode(map[string]string{"message": "Конфиг не найден"})
}
func handleSubscriptionsList(w http.ResponseWriter, r *http.Request) {
subs, _ := config.LoadSubscriptions()
json.NewEncoder(w).Encode(subs.Subscriptions)
}
func handleSubscriptionsAdd(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
URL string `json:"url"`
}
json.NewDecoder(r.Body).Decode(&req)
subs, err := config.LoadSubscriptions()
if err == nil {
subs.Subscriptions = append(subs.Subscriptions, config.Subscription{
Name: req.Name,
URL: req.URL,
})
config.SaveSubscriptions(subs)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleSubscriptionsDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
subs, _ := config.LoadSubscriptions()
if req.Index >= 0 && req.Index < len(subs.Subscriptions) {
subs.Subscriptions = append(subs.Subscriptions[:req.Index], subs.Subscriptions[req.Index+1:]...)
config.SaveSubscriptions(subs)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleSubscriptionsUpdate(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
subs, _ := config.LoadSubscriptions()
if req.Index >= 0 && req.Index < len(subs.Subscriptions) {
name := subs.Subscriptions[req.Index].Name
err := subscription.UpdateSubscription(name, config.LogsDir)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": err == nil,
"message": func() string {
if err != nil {
return err.Error()
}
return "Конфиги обновлены из подписки"
}(),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Подписка не найдена",
})
}
func handleSubscriptionsShow(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
subs, _ := config.LoadSubscriptions()
if req.Index >= 0 && req.Index < len(subs.Subscriptions) {
subName := subs.Subscriptions[req.Index].Name
configs, _ := config.LoadConfigs()
var subConfigs []config.VLESSConfig
for _, cfg := range configs.VLESS {
if cfg.Subscription == subName {
subConfigs = append(subConfigs, cfg)
}
}
if len(subConfigs) == 0 {
json.NewEncoder(w).Encode(map[string]string{
"message": "Нет конфигов из этой подписки.\n\nСначала обновите конфиги из подписки.",
})
return
}
msg := fmt.Sprintf("Конфиги из '%s':\n\n", subName)
for i, cfg := range subConfigs {
protocol := cfg.Protocol
if protocol == "" {
protocol = "Unknown"
}
msg += fmt.Sprintf("%d. [%s] %s\n", i+1, protocol, cfg.Name)
}
json.NewEncoder(w).Encode(map[string]string{"message": msg})
return
}
json.NewEncoder(w).Encode(map[string]string{"message": "Подписка не найдена"})
}
func handleSubscriptionsTest(w http.ResponseWriter, r *http.Request) {
var req struct {
Index int `json:"index"`
}
json.NewDecoder(r.Body).Decode(&req)
subs, _ := config.LoadSubscriptions()
if req.Index >= 0 && req.Index < len(subs.Subscriptions) {
subName := subs.Subscriptions[req.Index].Name
configs, _ := config.LoadConfigs()
var subConfigs []config.VLESSConfig
for _, cfg := range configs.VLESS {
if cfg.Subscription == subName {
subConfigs = append(subConfigs, cfg)
}
}
if len(subConfigs) == 0 {
json.NewEncoder(w).Encode(map[string]string{"message": "Нет конфигов из этой подписки"})
return
}
// Тестирование (упрощенная версия - первые 10)
msg := fmt.Sprintf("Тестирование серверов из '%s'...\n\n", subName)
tested := 0
for i, cfg := range subConfigs {
if i >= 10 {
break
}
tested++
success, ping, _ := vless.PingServer(cfg.URL, 3*time.Second)
if success {
msg += fmt.Sprintf("✓ %s - %.2f мс\n", cfg.Name, ping)
} else {
msg += fmt.Sprintf("✗ %s - недоступен\n", cfg.Name)
}
}
if len(subConfigs) > 10 {
msg += fmt.Sprintf("\n(Показано %d из %d серверов)", tested, len(subConfigs))
}
json.NewEncoder(w).Encode(map[string]string{"message": msg})
return
}
json.NewEncoder(w).Encode(map[string]string{"message": "Подписка не найдена"})
}
func handleDisconnect(w http.ResponseWriter, r *http.Request) {
state, err := vpn.GetStatus()
if err != nil || !state.Connected {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "VPN не подключен",
})
return
}
err = vpn.Disconnect(config.LogsDir)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": err == nil,
"message": func() string {
if err != nil {
return err.Error()
}
return "Отключено от VPN"
}(),
})
}