Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efe3c3d0a | |||
| 33ce01c0c2 | |||
| 8dccb9fddc | |||
| ec6880abd4 | |||
| b68b962128 | |||
| 7c27aff3b9 | |||
| 7268b11e5d | |||
| fd8656ffa0 | |||
| d591d61c0b | |||
| 27d3511bbe |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ vpn-client.exe
|
|||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
vendor/
|
vendor/
|
||||||
|
xray/
|
||||||
|
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|||||||
21
cmd/cli/main.go
Normal file
21
cmd/cli/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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, "config init error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cli.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cli error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
go.mod
14
go.mod
@@ -2,10 +2,20 @@ module vpn-client
|
|||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require github.com/fatih/color v1.16.0
|
require (
|
||||||
|
gioui.org v0.7.1
|
||||||
|
github.com/fatih/color v1.16.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
|
||||||
|
gioui.org/shader v1.0.8 // indirect
|
||||||
|
github.com/go-text/typesetting v0.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
golang.org/x/sys v0.16.0 // indirect
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
|
||||||
|
golang.org/x/image v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
25
go.sum
25
go.sum
@@ -1,11 +1,32 @@
|
|||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
|
||||||
|
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
|
||||||
|
gioui.org v0.7.1 h1:l7OVj47n1z8acaszQ6Wlu+Rxme+HqF3q8b+Fs68+x3w=
|
||||||
|
gioui.org v0.7.1/go.mod h1:5Kw/q7R1BWc5MKStuTNvhCgSrRqbfHc9Dzfjs4IGgZo=
|
||||||
|
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
|
||||||
|
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||||
|
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||||
|
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
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/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo=
|
||||||
|
github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
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-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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
|
||||||
|
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
|
||||||
|
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
|||||||
235
internal/gui/advanced.go
Normal file
235
internal/gui/advanced.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
"vpn-client/internal/config"
|
||||||
|
"vpn-client/internal/vless"
|
||||||
|
"vpn-client/internal/wireguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *ui) addWireGuardManual() {
|
||||||
|
name := strings.TrimSpace(u.wgNameEditor.Text())
|
||||||
|
configText := strings.TrimSpace(u.wgConfigEditor.Text())
|
||||||
|
if name == "" || configText == "" {
|
||||||
|
u.message = "WireGuard name and config text are required."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wireguard.AddConfig(name, configText); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.wgNameEditor.SetText("")
|
||||||
|
u.wgConfigEditor.SetText("")
|
||||||
|
u.message = "WireGuard config added."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addWireGuardFromFile() {
|
||||||
|
name := strings.TrimSpace(u.wgFileNameEditor.Text())
|
||||||
|
filePath := strings.TrimSpace(u.wgFilePathEditor.Text())
|
||||||
|
if name == "" || filePath == "" {
|
||||||
|
u.message = "WireGuard name and file path are required."
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wireguard.AddConfigFromFile(name, filePath); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.wgFileNameEditor.SetText("")
|
||||||
|
u.wgFilePathEditor.SetText("")
|
||||||
|
u.message = "WireGuard config imported from file."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) deleteWireGuardConfig(name string) {
|
||||||
|
if err := wireguard.DeleteConfig(name); err != nil {
|
||||||
|
u.message = err.Error()
|
||||||
|
u.messageColor = dangerColor
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.message = "WireGuard config removed."
|
||||||
|
u.messageColor = successColor
|
||||||
|
u.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) subscriptionConfigs(name string) []config.VLESSConfig {
|
||||||
|
items := make([]config.VLESSConfig, 0)
|
||||||
|
for _, cfg := range u.configs.VLESS {
|
||||||
|
if cfg.Subscription == name {
|
||||||
|
items = append(items, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) subscriptionSummary(name string) string {
|
||||||
|
items := u.subscriptionConfigs(name)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "No imported configs yet."
|
||||||
|
}
|
||||||
|
preview := strings.TrimPrefix(items[0].Name, fmt.Sprintf("[%s] ", name))
|
||||||
|
if len(items) == 1 {
|
||||||
|
return fmt.Sprintf("1 imported config: %s", trimForPreview(preview, 42))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d imported configs. First: %s", len(items), trimForPreview(preview, 36))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) testSubscriptionConfigs(name string) error {
|
||||||
|
items := u.subscriptionConfigs(name)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return fmt.Errorf("no imported configs found for %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
name string
|
||||||
|
ping float64
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
results := make([]result, 0, len(items))
|
||||||
|
for _, cfg := range items {
|
||||||
|
ok, ping, _ := vless.PingServer(cfg.URL, 3*time.Second)
|
||||||
|
results = append(results, result{
|
||||||
|
name: strings.TrimPrefix(cfg.Name, fmt.Sprintf("[%s] ", name)),
|
||||||
|
ping: ping,
|
||||||
|
ok: ok,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
successful := results[:0]
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ok {
|
||||||
|
successful = append(successful, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(successful) == 0 {
|
||||||
|
return fmt.Errorf("all %d configs from %s are unreachable", len(results), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(successful, func(i, j int) bool { return successful[i].ping < successful[j].ping })
|
||||||
|
best := successful[0]
|
||||||
|
u.message = fmt.Sprintf("%s: %d/%d reachable, best %s at %.0f ms.", name, len(successful), len(results), trimForPreview(best.name, 34), best.ping)
|
||||||
|
u.messageColor = successColor
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) layoutWireGuard(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceBetween}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H5(u.theme, "Add manually")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.layoutEditor(gtx, &u.wgNameEditor, "Config name") }),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.wgConfigEditor, "WireGuard config text")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.addWGManualBtn, "Save manual config", accentColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(12)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H5(u.theme, "Import from file")
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.wgFileNameEditor, "Config name")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(10)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutEditor(gtx, &u.wgFilePathEditor, "Path to .conf file")
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.addWGFileBtn, "Import file", accentSoftColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(16)),
|
||||||
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(18)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if len(u.configs.WireGuard) == 0 {
|
||||||
|
lbl := material.Body1(u.theme, "No WireGuard configs yet.")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}
|
||||||
|
return u.wgList.Layout(gtx, len(u.configs.WireGuard), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
|
cfg := u.configs.WireGuard[i]
|
||||||
|
return layout.Inset{Bottom: unit.Dp(12)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.card(gtx, panelAltColor, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.H6(u.theme, cfg.Name)
|
||||||
|
lbl.Color = textColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
meta := material.Body2(u.theme, trimForPreview(cfg.Config, 110))
|
||||||
|
meta.Color = mutedColor
|
||||||
|
return meta.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerH(12)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{}.Layout(
|
||||||
|
gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.wgConnectBtns[i], "Connect", accentColor)
|
||||||
|
}),
|
||||||
|
layout.Rigid(spacerW(8)),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.layoutButton(gtx, &u.wgDeleteBtns[i], "Delete", dangerColor)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
1121
internal/gui/app.go
Normal file
1121
internal/gui/app.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,21 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vpn-client/internal/config"
|
"vpn-client/internal/config"
|
||||||
"vpn-client/internal/logger"
|
"vpn-client/internal/logger"
|
||||||
|
"vpn-client/internal/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
// XrayConfig представляет конфигурацию Xray
|
// XrayConfig представляет конфигурацию Xray
|
||||||
type XrayConfig struct {
|
type XrayConfig struct {
|
||||||
Log LogConfig `json:"log"`
|
Log LogConfig `json:"log"`
|
||||||
Inbounds []InboundConfig `json:"inbounds"`
|
Inbounds []InboundConfig `json:"inbounds"`
|
||||||
Outbounds []OutboundConfig `json:"outbounds"`
|
Outbounds []OutboundConfig `json:"outbounds"`
|
||||||
Stats interface{} `json:"stats"`
|
Stats interface{} `json:"stats"`
|
||||||
Policy PolicyConfig `json:"policy"`
|
Policy PolicyConfig `json:"policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
@@ -34,11 +36,11 @@ type LogConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InboundConfig struct {
|
type InboundConfig struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Settings InboundSettings `json:"settings"`
|
Settings InboundSettings `json:"settings"`
|
||||||
Sniffing SniffingConfig `json:"sniffing"`
|
Sniffing SniffingConfig `json:"sniffing"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InboundSettings struct {
|
type InboundSettings struct {
|
||||||
@@ -53,8 +55,8 @@ type SniffingConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutboundConfig struct {
|
type OutboundConfig struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Settings OutboundSettings `json:"settings"`
|
Settings OutboundSettings `json:"settings"`
|
||||||
StreamSettings StreamSettings `json:"streamSettings"`
|
StreamSettings StreamSettings `json:"streamSettings"`
|
||||||
}
|
}
|
||||||
@@ -76,13 +78,13 @@ type UserConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StreamSettings struct {
|
type StreamSettings struct {
|
||||||
Network string `json:"network"`
|
Network string `json:"network"`
|
||||||
Security string `json:"security,omitempty"`
|
Security string `json:"security,omitempty"`
|
||||||
TLSSettings *TLSSettings `json:"tlsSettings,omitempty"`
|
TLSSettings *TLSSettings `json:"tlsSettings,omitempty"`
|
||||||
RealitySettings *RealitySettings `json:"realitySettings,omitempty"`
|
RealitySettings *RealitySettings `json:"realitySettings,omitempty"`
|
||||||
WSSettings *WSSettings `json:"wsSettings,omitempty"`
|
WSSettings *WSSettings `json:"wsSettings,omitempty"`
|
||||||
GRPCSettings *GRPCSettings `json:"grpcSettings,omitempty"`
|
GRPCSettings *GRPCSettings `json:"grpcSettings,omitempty"`
|
||||||
HTTPSettings *HTTPSettings `json:"httpSettings,omitempty"`
|
HTTPSettings *HTTPSettings `json:"httpSettings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TLSSettings struct {
|
type TLSSettings struct {
|
||||||
@@ -332,6 +334,10 @@ func getParam(params map[string]string, key, defaultValue string) string {
|
|||||||
|
|
||||||
// Connect подключается к VLESS серверу
|
// Connect подключается к VLESS серверу
|
||||||
func Connect(configName string, logsDir, xrayDir string) error {
|
func Connect(configName string, logsDir, xrayDir string) error {
|
||||||
|
if err := disconnectExistingConnection(logsDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем конфигурации
|
// Загружаем конфигурации
|
||||||
configs, err := config.LoadConfigs()
|
configs, err := config.LoadConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -375,8 +381,8 @@ func Connect(configName string, logsDir, xrayDir string) error {
|
|||||||
}
|
}
|
||||||
xrayPath := filepath.Join(xrayDir, xrayExe)
|
xrayPath := filepath.Join(xrayDir, xrayExe)
|
||||||
|
|
||||||
if _, err := os.Stat(xrayPath); os.IsNotExist(err) {
|
if xrayPath, err = ensureXrayBinary(xrayDir); err != nil {
|
||||||
return fmt.Errorf("xray не найден в %s", xrayDir)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем лог-файл трафика
|
// Создаем лог-файл трафика
|
||||||
@@ -443,28 +449,62 @@ func Connect(configName string, logsDir, xrayDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disconnectExistingConnection(logsDir string) error {
|
||||||
|
state, err := config.LoadState()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка загрузки состояния подключения: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil || !state.Connected {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state.ConfigType {
|
||||||
|
case "wireguard":
|
||||||
|
if state.Interface != "" {
|
||||||
|
if err := wireguard.Disconnect(state.Interface, logsDir); err != nil {
|
||||||
|
return fmt.Errorf("ошибка отключения текущего WireGuard: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "vless":
|
||||||
|
if state.ProcessPID > 0 {
|
||||||
|
process, err := os.FindProcess(state.ProcessPID)
|
||||||
|
if err == nil {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
_ = process.Kill()
|
||||||
|
} else {
|
||||||
|
_ = process.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.SaveState(&config.ConnectionState{})
|
||||||
|
}
|
||||||
|
|
||||||
// PingServer проверяет доступность VLESS сервера
|
// PingServer проверяет доступность VLESS сервера
|
||||||
func PingServer(vlessURL string, timeout time.Duration) (bool, float64, error) {
|
func PingServer(vlessURL string, timeout time.Duration) (bool, float64, error) {
|
||||||
// Парсим URL для получения адреса сервера
|
// Парсим URL для получения адреса сервера
|
||||||
urlStr := strings.TrimPrefix(vlessURL, "vless://")
|
urlStr := strings.TrimPrefix(vlessURL, "vless://")
|
||||||
|
|
||||||
if idx := strings.Index(urlStr, "#"); idx != -1 {
|
if idx := strings.Index(urlStr, "#"); idx != -1 {
|
||||||
urlStr = urlStr[:idx]
|
urlStr = urlStr[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(urlStr, "?"); idx != -1 {
|
if idx := strings.Index(urlStr, "?"); idx != -1 {
|
||||||
urlStr = urlStr[:idx]
|
urlStr = urlStr[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(urlStr, "@")
|
parts := strings.Split(urlStr, "@")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return false, 0, fmt.Errorf("неверный формат URL")
|
return false, 0, fmt.Errorf("неверный формат URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
serverPort := parts[1]
|
serverPort := parts[1]
|
||||||
var server string
|
var server string
|
||||||
var port string
|
var port string
|
||||||
|
|
||||||
if strings.Contains(serverPort, "[") {
|
if strings.Contains(serverPort, "[") {
|
||||||
endIdx := strings.Index(serverPort, "]")
|
endIdx := strings.Index(serverPort, "]")
|
||||||
server = serverPort[1:endIdx]
|
server = serverPort[1:endIdx]
|
||||||
@@ -474,20 +514,20 @@ func PingServer(vlessURL string, timeout time.Duration) (bool, float64, error) {
|
|||||||
server = serverPort[:lastColon]
|
server = serverPort[:lastColon]
|
||||||
port = serverPort[lastColon+1:]
|
port = serverPort[lastColon+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "443"
|
port = "443"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Измеряем время подключения
|
// Измеряем время подключения
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(server, port), timeout)
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(server, port), timeout)
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0, err
|
return false, 0, err
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
||||||
return true, float64(elapsed.Milliseconds()), nil
|
return true, float64(elapsed.Milliseconds()), nil
|
||||||
}
|
}
|
||||||
|
|||||||
224
internal/vless/xray_bootstrap.go
Normal file
224
internal/vless/xray_bootstrap.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package vless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const xrayLatestReleaseAPI = "https://api.github.com/repos/XTLS/Xray-core/releases/latest"
|
||||||
|
|
||||||
|
type xrayRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Assets []xrayReleaseAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xrayReleaseAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureXrayBinary(xrayDir string) (string, error) {
|
||||||
|
exeName := "xray"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
exeName = "xray.exe"
|
||||||
|
}
|
||||||
|
xrayPath := filepath.Join(xrayDir, exeName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(xrayPath); err == nil {
|
||||||
|
return xrayPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(xrayDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка создания папки xray: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := fetchLatestXrayAsset()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath, err := downloadXrayArchive(asset.BrowserDownloadURL, xrayDir, asset.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.Remove(archivePath)
|
||||||
|
|
||||||
|
if err := unzipXrayArchive(archivePath, xrayDir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
_ = os.Chmod(xrayPath, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(xrayPath); err != nil {
|
||||||
|
return "", fmt.Errorf("xray не найден после распаковки в %s", xrayDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return xrayPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestXrayAsset() (*xrayReleaseAsset, error) {
|
||||||
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, xrayLatestReleaseAPI, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка подготовки запроса релиза Xray: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "go-vpn-client")
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка получения релиза Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("ошибка API Xray: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var release xrayRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка разбора релиза Xray: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
if matchesXrayAsset(asset.Name) {
|
||||||
|
return &asset, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("не найден подходящий архив Xray для %s/%s в релизе %s", runtime.GOOS, runtime.GOARCH, release.TagName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesXrayAsset(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if !strings.HasSuffix(lower, ".zip") || strings.Contains(lower, ".dgst") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
osToken := map[string]string{
|
||||||
|
"windows": "windows",
|
||||||
|
"linux": "linux",
|
||||||
|
"darwin": "macos",
|
||||||
|
}[runtime.GOOS]
|
||||||
|
if osToken == "" || !strings.Contains(lower, osToken) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
archTokens := map[string][]string{
|
||||||
|
"amd64": {"64", "amd64", "x64"},
|
||||||
|
"386": {"32", "386", "x86"},
|
||||||
|
"arm64": {"arm64", "aarch64"},
|
||||||
|
"arm": {"arm32", "armv7", "arm"},
|
||||||
|
}[runtime.GOARCH]
|
||||||
|
|
||||||
|
if len(archTokens) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, token := range archTokens {
|
||||||
|
if strings.Contains(lower, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadXrayArchive(downloadURL, xrayDir, name string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Minute}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка подготовки загрузки Xray: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "go-vpn-client")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка загрузки Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("ошибка загрузки Xray: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath := filepath.Join(xrayDir, name)
|
||||||
|
file, err := os.Create(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка создания архива Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка сохранения архива Xray: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unzipXrayArchive(archivePath, targetDir string) error {
|
||||||
|
reader, err := zip.OpenReader(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка открытия архива Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if err := extractZipEntry(file, targetDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractZipEntry(file *zip.File, targetDir string) error {
|
||||||
|
cleanName := filepath.Clean(file.Name)
|
||||||
|
targetPath := filepath.Join(targetDir, cleanName)
|
||||||
|
absTargetDir, err := filepath.Abs(targetDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка определения папки Xray: %w", err)
|
||||||
|
}
|
||||||
|
absTargetPath, err := filepath.Abs(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка определения пути Xray: %w", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(absTargetPath, absTargetDir) {
|
||||||
|
return fmt.Errorf("небезопасный путь в архиве Xray: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
return os.MkdirAll(absTargetPath, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absTargetPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("ошибка создания папки Xray: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка чтения файла из архива Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(absTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка создания файла Xray: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return fmt.Errorf("ошибка распаковки Xray: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
main.go
33
main.go
@@ -1,23 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"vpn-client/internal/cli"
|
"gioui.org/app"
|
||||||
"vpn-client/internal/config"
|
"vpn-client/internal/config"
|
||||||
|
"vpn-client/internal/gui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Инициализация конфигурации
|
go func() {
|
||||||
if err := config.Init(); err != nil {
|
if err := config.Init(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Ошибка инициализации: %v\n", err)
|
log.Printf("config init failed: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запуск CLI
|
window := new(app.Window)
|
||||||
if err := cli.Run(); err != nil {
|
window.Option(
|
||||||
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
app.Title("Go VPN Client"),
|
||||||
os.Exit(1)
|
)
|
||||||
}
|
|
||||||
|
if err := gui.Run(window); err != nil {
|
||||||
|
log.Printf("gui failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
app.Main()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user