package main import ( "encoding/json" "errors" "fmt" "image/color" "log" "net/url" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "syscall" "unsafe" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "fyne.io/fyne/v2" "golang.org/x/sys/windows/registry" ) // Config represents a VPN configuration type Config struct { Title string URL string } var ( currentProcess *exec.Cmd configs []Config // Store all configurations activeConfig int // Index of the active config (-1 if none) ) // checkSingBox checks if sing-box.exe exists in the bin folder func checkSingBox() bool { singBoxPath := filepath.Join("bin", "sing-box.exe") if _, err := os.Stat(singBoxPath); os.IsNotExist(err) { return false } return true } // isValidVLESS checks if the given URL is a valid VLESS URL func isValidVLESS(vlessURL string) bool { // Basic validation for VLESS URL format if !strings.HasPrefix(vlessURL, "vless://") { return false } u, err := url.Parse(vlessURL) if err != nil { return false } // Check if it's a valid VLESS link matched, _ := regexp.MatchString(`^vless://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}@.*`, vlessURL) if !matched { return false } // Additional validation checks can be added here return u.Scheme == "vless" && u.Host != "" } // addConfig adds a new configuration to the list func addConfig(title, url string) { configs = append(configs, Config{Title: title, URL: url}) } // updateConfig updates an existing configuration func updateConfig(index int, title, url string) { if index >= 0 && index < len(configs) { configs[index].Title = title configs[index].URL = url } } // removeConfig removes a configuration by index func removeConfig(index int) { if index >= 0 && index < len(configs) { configs = append(configs[:index], configs[index+1:]...) if activeConfig >= index { activeConfig-- } if activeConfig >= len(configs) { activeConfig = -1 } } } // generateAndRunSingBox generates a Sing-box config from VLESS URL and runs sing-box func generateAndRunSingBox(vlessURL string) error { // Generate unique config filename based on timestamp or hash configPath := "config.json" // Generate the Sing-box configuration from the VLESS URL err := generateConfigFromVLESSURL(vlessURL, configPath) if err != nil { return fmt.Errorf("failed to generate config: %v", err) } // Run sing-box with the generated config return runSingBox(configPath) } // runSingBox starts the sing-box process with the given config path func runSingBox(configPath string) error { singBoxPath := filepath.Join("bin", "sing-box.exe") if !checkSingBox() { return nil } cmd := exec.Command(singBoxPath, "run", "-c", configPath) // Make the process run as hidden cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, } err := cmd.Start() if err != nil { return err } currentProcess = cmd return nil } // killCurrentProcess terminates the current sing-box process if running func killCurrentProcess() { if currentProcess != nil && currentProcess.Process != nil { currentProcess.Process.Kill() currentProcess = nil } } // VLESSConfig represents parsed VLESS URL data type VLESSConfig struct { UUID string ServerAddr string ServerPort int Type string SNI string Network string Path string Host string Fingerprint string Flow string AllowInsecure bool } // parseVLESS parses a VLESS URL and extracts relevant information func parseVLESS(vlessURL string) (*VLESSConfig, error) { u, err := url.Parse(vlessURL) if err != nil { return nil, err } // Extract UUID from the user part of the URL uuid := u.User.Username() // Extract server address and port serverAddr := u.Hostname() portStr := u.Port() if portStr == "" { return nil, fmt.Errorf("port not specified in VLESS URL") } port, err := strconv.Atoi(portStr) if err != nil { return nil, err } // Parse query parameters params := u.Query() vlessConfig := &VLESSConfig{ UUID: uuid, ServerAddr: serverAddr, ServerPort: port, Type: params.Get("type"), SNI: params.Get("sni"), Network: params.Get("network"), Path: params.Get("path"), Host: params.Get("host"), Fingerprint: params.Get("fp"), Flow: params.Get("flow"), AllowInsecure: params.Get("allowInsecure") == "true", } // Set default network type if not specified if vlessConfig.Network == "" { vlessConfig.Network = "tcp" } return vlessConfig, nil } // SingBoxOutbound represents an outbound configuration for Sing-box type SingBoxOutbound struct { Type string `json:"type"` Tag string `json:"tag,omitempty"` Server string `json:"server"` ServerPort int `json:"server_port"` UUID string `json:"uuid"` Flow string `json:"flow,omitempty"` Network string `json:"network,omitempty"` TLS *SingBoxTLS `json:"tls,omitempty"` Transport map[string]interface{} `json:"transport,omitempty"` V2RayTransport *V2RayTransport `json:"v2ray_transport,omitempty"` } // SingBoxTLS represents TLS configuration for Sing-box type SingBoxTLS struct { Enabled bool `json:"enabled"` ServerName string `json:"server_name,omitempty"` Insecure bool `json:"insecure,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` UTLS *UTLS `json:"utls,omitempty"` } // UTLS represents uTLS configuration type UTLS struct { Enabled bool `json:"enabled"` Fingerprint string `json:"fingerprint"` } // V2RayTransport represents V2Ray transport configuration type V2RayTransport struct { Type string `json:"type"` Headers map[string]string `json:"headers,omitempty"` Host string `json:"host,omitempty"` Method string `json:"method,omitempty"` Path string `json:"path,omitempty"` ServiceName string `json:"service_name,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // SingBoxInbound represents an inbound configuration for Sing-box type SingBoxInbound struct { Type string `json:"type"` Tag string `json:"tag,omitempty"` Listen string `json:"listen"` ListenPort int `json:"listen_port"` SetSystemProxy bool `json:"set_system_proxy,omitempty"` } // SingBoxConfig represents the full Sing-box configuration type SingBoxConfig struct { Log map[string]interface{} `json:"log"` Inbounds []SingBoxInbound `json:"inbounds"` Outbounds []SingBoxOutbound `json:"outbounds"` } // generateSingBoxConfig generates a Sing-box configuration from VLESSConfig func generateSingBoxConfig(vlessConfig *VLESSConfig, configPath string) error { // Create TLS configuration if SNI is present var tlsConfig *SingBoxTLS if vlessConfig.SNI != "" || vlessConfig.Fingerprint != "" { tlsConfig = &SingBoxTLS{ Enabled: true, ServerName: vlessConfig.SNI, Insecure: vlessConfig.AllowInsecure, } if vlessConfig.Fingerprint != "" { tlsConfig.Fingerprint = vlessConfig.Fingerprint // Enable uTLS if fingerprint is specified tlsConfig.UTLS = &UTLS{ Enabled: true, Fingerprint: vlessConfig.Fingerprint, } } } // Create V2Ray transport if specific transport settings are present var v2rayTransport *V2RayTransport if vlessConfig.Path != "" || vlessConfig.Host != "" { v2rayTransport = &V2RayTransport{ Type: vlessConfig.Network, } if vlessConfig.Path != "" { v2rayTransport.Path = vlessConfig.Path } if vlessConfig.Host != "" { v2rayTransport.Host = vlessConfig.Host } // For HTTP headers if vlessConfig.Host != "" { v2rayTransport.Headers = map[string]string{ "Host": vlessConfig.Host, } } } // Create the outbound configuration outbound := SingBoxOutbound{ Type: "vless", Server: vlessConfig.ServerAddr, ServerPort: vlessConfig.ServerPort, UUID: vlessConfig.UUID, Flow: vlessConfig.Flow, Network: vlessConfig.Network, } if tlsConfig != nil { outbound.TLS = tlsConfig } if v2rayTransport != nil { outbound.V2RayTransport = v2rayTransport } // Special handling for different transport types switch vlessConfig.Network { case "ws": // WebSocket transport transport := map[string]interface{}{ "type": vlessConfig.Network, } if vlessConfig.Path != "" { transport["path"] = vlessConfig.Path } if vlessConfig.Host != "" { headers := map[string]interface{}{ "Host": vlessConfig.Host, } transport["headers"] = headers } outbound.Transport = transport case "grpc": // gRPC transport transport := map[string]interface{}{ "type": vlessConfig.Network, } if vlessConfig.Path != "" { transport["service_name"] = vlessConfig.Path } outbound.Transport = transport } // Create inbounds - mixed (SOCKS5 + HTTP) on port 1080 inbounds := []SingBoxInbound{ { Type: "mixed", Listen: "127.0.0.1", ListenPort: 1080, SetSystemProxy: true, }, } // Create the full configuration config := SingBoxConfig{ Log: map[string]interface{}{ "level": "info", }, Inbounds: inbounds, Outbounds: []SingBoxOutbound{outbound}, } // Convert to JSON jsonData, err := json.MarshalIndent(config, "", " ") if err != nil { return err } // Write to file err = os.WriteFile(configPath, jsonData, 0644) if err != nil { return err } log.Printf("Sing-box configuration written to %s", configPath) return nil } // generateConfigFromVLESSURL parses a VLESS URL and generates a Sing-box config file func generateConfigFromVLESSURL(vlessURL, outputPath string) error { // Parse the VLESS URL vlessConfig, err := parseVLESS(vlessURL) if err != nil { return err } // Generate the Sing-box configuration err = generateSingBoxConfig(vlessConfig, outputPath) if err != nil { return err } return nil } // setSystemProxy enables the system proxy with the specified server and port func setSystemProxy(proxyServer string) error { key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.SET_VALUE) if err != nil { return fmt.Errorf("failed to open registry key: %v", err) } defer key.Close() // Set ProxyEnable to 1 to enable proxy err = key.SetDWordValue("ProxyEnable", 1) if err != nil { return fmt.Errorf("failed to set ProxyEnable: %v", err) } // Set ProxyServer to the specified server and port err = key.SetStringValue("ProxyServer", proxyServer) if err != nil { return fmt.Errorf("failed to set ProxyServer: %v", err) } // Optionally, set ProxyOverride to bypass proxy for local addresses err = key.SetStringValue("ProxyOverride", "localhost;127.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*") if err != nil { return fmt.Errorf("failed to set ProxyOverride: %v", err) } // Notify Windows that the proxy settings have changed notifyProxyChange() return nil } // disableSystemProxy disables the system proxy func disableSystemProxy() error { key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.SET_VALUE) if err != nil { return fmt.Errorf("failed to open registry key: %v", err) } defer key.Close() // Set ProxyEnable to 0 to disable proxy err = key.SetDWordValue("ProxyEnable", 0) if err != nil { return fmt.Errorf("failed to set ProxyEnable: %v", err) } // Notify Windows that the proxy settings have changed notifyProxyChange() return nil } // notifyProxyChange notifies Windows that proxy settings have changed func notifyProxyChange() { // Use Windows API to notify about proxy change // Call InternetSetOption to refresh proxy settings procInternetSetOption := syscall.NewLazyDLL("wininet.dll").NewProc("InternetSetOptionW") procInternetSetOption.Call( 0, // hInternet = NULL 39, // INTERNET_OPTION_SETTINGS_CHANGED 0, // lpBuffer = NULL 0, // dwBufferLength = 0 ) // Also send WM_SETTINGCHANGE to broadcast the change procSendMessageTimeout := syscall.NewLazyDLL("user32.dll").NewProc("SendMessageTimeoutW") procSendMessageTimeout.Call( 0xFFFF, // HWND_BROADCAST 0x001A, // WM_SETTINGCHANGE uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Proxy"))), 0, 0x0002, // SMTO_ABORTIFHUNG 5000, // timeout ) } func main() { myApp := app.New() myWindow := myApp.NewWindow("Hamy VPN Client") // Set the window size to 200x300 as requested myWindow.Resize(fyne.NewSize(200, 300)) // Initialize active config to -1 (no config selected) activeConfig = -1 // Connection state - true for connected, false for disconnected var isConnected bool = false // Update connection button text based on connection state updateConnectionButtonText := func(button *widget.Button) { if isConnected { button.SetText("Отключить") } else { button.SetText("Подключить") } } // Create program title with proper padding titleText := canvas.NewText("Hamy VPN", nil) titleText.TextStyle.Bold = true titleText.TextSize = 16 titleText.Alignment = fyne.TextAlignLeading // Create settings button (gear icon) settingsButton := widget.NewButtonWithIcon("", theme.SettingsIcon(), func() { // Show settings dialog in a separate fixed-size window settingsWindow := myApp.NewWindow("Настройки") settingsWindow.Resize(fyne.NewSize(400, 300)) settingsWindow.SetFixedSize(true) // Add content to settings window (for now just a placeholder) settingsLabel := widget.NewLabel("Настройки приложения") settingsContent := container.NewVBox( settingsLabel, widget.NewLabel("Добавьте элементы настроек здесь"), ) settingsWindow.SetContent(settingsContent) settingsWindow.Show() }) // Create HBox for title and settings button with proper padding titleContainer := container.NewBorder(nil, nil, titleText, settingsButton, layout.NewSpacer()) // Status indicator (bold, smaller padding) statusLabel := canvas.NewText("Отключено", color.RGBA{R: 128, G: 128, B: 128, A: 255}) // Gray color statusLabel.Alignment = fyne.TextAlignCenter statusLabel.TextSize = 14 // Small font statusLabel.TextStyle.Bold = true // Make it bold // Connect/Disconnect button (with dynamic text) var connectButton *widget.Button connectButton = widget.NewButton("Подключить", func() { if activeConfig == -1 || len(configs) == 0 { dialog.ShowError(errors.New("Выберите конфигурацию"), myWindow) return } // Toggle connection state if !isConnected { // Connect - generate config and run sing-box err := generateAndRunSingBox(configs[activeConfig].URL) if err != nil { dialog.ShowError(fmt.Errorf("failed to start connection: %v", err), myWindow) // Revert connection state on failure isConnected = false updateConnectionButtonText(connectButton) statusLabel.Text = "Отключено" statusLabel.Color = color.RGBA{R: 128, G: 128, B: 128, A: 255} statusLabel.Refresh() return } // Set proxy to route traffic through VPN err = setSystemProxy("127.0.0.1:1080") if err != nil { log.Printf("Failed to set system proxy: %v", err) dialog.ShowError(fmt.Errorf("failed to set system proxy: %v", err), myWindow) } // Update connection state after successful connection isConnected = true } else { // Disconnect - kill the current process killCurrentProcess() // Disable proxy when disconnecting from VPN err := disableSystemProxy() if err != nil { log.Printf("Failed to disable system proxy: %v", err) } // Update connection state after disconnection isConnected = false } // Update button text updateConnectionButtonText(connectButton) // Update status text if isConnected { statusLabel.Text = "Подключено" statusLabel.Color = color.RGBA{R: 0, G: 128, B: 0, A: 255} // Green color for connected } else { statusLabel.Text = "Отключено" statusLabel.Color = color.RGBA{R: 128, G: 128, B: 128, A: 255} // Gray color for disconnected } statusLabel.Refresh() }) connectButton.Importance = widget.HighImportance if len(configs) == 0 { connectButton.Disable() // Disable initially until a config is selected } else { connectButton.Enable() // Enable if we have configs } // Configurations button - opens a separate window configsButton := widget.NewButton("Конфигурации", func() { // Create a separate fixed-size window for configurations configWindow := myApp.NewWindow("Конфигурации") configWindow.Resize(fyne.NewSize(400, 300)) configWindow.SetFixedSize(true) // Declare variables before use var listContainer *widget.List var scrollContainer *container.Scroll var refreshList func() // Entry for importing new configuration - fixed to expand properly importEntry := widget.NewEntry() importEntry.PlaceHolder = "vless://..." // Container for import field and button - use HBox to allow proper expansion importButton := widget.NewButtonWithIcon("", theme.ContentAddIcon(), func() { url := importEntry.Text if url != "" && isValidVLESS(url) { title := fmt.Sprintf("Конфиг %d", len(configs)+1) addConfig(title, url) dialog.ShowInformation("Успешно", "Конфиг добавлен", configWindow) // Refresh the list refreshList() importEntry.SetText("") } else if url != "" { dialog.ShowError(errors.New("Невалидная ссылка"), configWindow) } }) // Fixed the order of operations to avoid nil reference refreshList = func() { // Create the list first newListContainer := widget.NewList( func() int { return len(configs) }, func() fyne.CanvasObject { return container.NewBorder(nil, nil, widget.NewLabel("Template"), container.NewHBox( widget.NewButtonWithIcon("", theme.DocumentCreateIcon(), func() {}), widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {}), ), ) }, func(id widget.ListItemID, obj fyne.CanvasObject) { containerObj := obj.(*fyne.Container) // For a container.NewBorder(nil, nil, left, right, nil), the structure is: // Objects[0] is the left element (label) // Objects[1] is the right element (buttons container) titleLabel := containerObj.Objects[0].(*widget.Label) buttonsContainer := containerObj.Objects[1].(*fyne.Container) editButton := buttonsContainer.Objects[0].(*widget.Button) deleteButton := buttonsContainer.Objects[1].(*widget.Button) // Update visual indication for active config if id == activeConfig { titleLabel.SetText(fmt.Sprintf("● %s", configs[id].Title)) } else { titleLabel.SetText(fmt.Sprintf("○ %s", configs[id].Title)) } // Set up edit button editButton.OnTapped = func() { showEditDialog(configWindow, id) } // Set up delete button deleteButton.OnTapped = func() { dialog.ShowConfirm("Удалить конфигурацию", fmt.Sprintf("Вы уверены, что хотите удалить конфигурацию '%s'?", configs[id].Title), func(confirmed bool) { if confirmed { removeConfig(id) dialog.ShowInformation("Удалено", "Конфигурация удалена", configWindow) // Refresh the list refreshList() } }, configWindow) } }, ) newListContainer.OnSelected = func(id widget.ListItemID) { activeConfig = id dialog.ShowInformation("Config Selected", fmt.Sprintf("Active config: %s", configs[id].Title), configWindow) configWindow.Close() } // Assign to the variable before using it in the scroll container listContainer = newListContainer // Create scroll container only once if scrollContainer == nil { scrollContainer = container.NewScroll(listContainer) } else { scrollContainer.Content = listContainer scrollContainer.Refresh() } } // Call refreshList to initialize the list first refreshList() // Container for import field and button - use HBox to allow proper expansion // Using container.NewBorder to make the entry expand properly importContainer := container.NewBorder(nil, nil, nil, importButton, importEntry) // Main content for the config window using a border container to properly size the scroll area configContent := container.NewBorder(importContainer, nil, nil, nil, scrollContainer) configWindow.SetContent(configContent) configWindow.Show() }) // Main content container with vertical layout contentContainer := container.NewVBox( titleContainer, layout.NewSpacer(), container.NewCenter(statusLabel), layout.NewSpacer(), container.NewCenter(connectButton), layout.NewSpacer(), container.NewCenter(configsButton), layout.NewSpacer(), ) // Create a top-level container mainContainer := container.NewStack(contentContainer) // Register an app lifecycle listener to handle cleanup when the app exits myApp.Lifecycle().SetOnStopped(func() { // Kill the current process if running if currentProcess != nil && currentProcess.Process != nil { currentProcess.Process.Kill() } // Disable proxy when the application exits err := disableSystemProxy() if err != nil { log.Printf("Failed to disable system proxy on exit: %v", err) } }) // Set the content and show the window myWindow.SetContent(mainContainer) myWindow.ShowAndRun() } // Function to show the edit dialog func showEditDialog(parentWindow fyne.Window, configIndex int) { // Create entry fields for title and URL with existing values titleEntry := widget.NewEntry() titleEntry.SetText(configs[configIndex].Title) titleEntry.PlaceHolder = "Название конфигурации" urlEntry := widget.NewEntry() urlEntry.SetText(configs[configIndex].URL) urlEntry.PlaceHolder = "vless://..." // Create a custom dialog editDialog := dialog.NewForm("Редактировать конфигурацию", "Сохранить", "Отмена", []*widget.FormItem{ widget.NewFormItem("Заголовок", titleEntry), widget.NewFormItem("Ссылка", urlEntry), }, func(ok bool) { if ok { title := titleEntry.Text url := urlEntry.Text if title == "" { title = fmt.Sprintf("Конфиг %d", configIndex+1) // Default title } if isValidVLESS(url) { updateConfig(configIndex, title, url) dialog.ShowInformation("Успешно", "Конфиг обновлен", parentWindow) } else { dialog.ShowError(errors.New("Невалидная ссылка"), parentWindow) } } }, parentWindow, ) editDialog.Show() }